Compare commits

...

172 commits

Author SHA1 Message Date
e327e5e2ee Fix name of message_id_domain in config example
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2024-09-21 11:25:38 +02:00
39393eb612 Adapt goreleaser config for gorelease v2.0.0
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2024-06-08 12:51:34 +02:00
a8f16192a8 Update dependencies 2024-06-08 12:51:34 +02:00
11582d3590 Update linter config and apply suggestions
- remove copyright years (they are in git)
- remove outdated linter bug workarounds
- update .golangci.yml to match current schema (as of golangci-lint 1.59.0)
2024-06-08 12:51:34 +02:00
3d16034c44 Add configurable domain part for message ids
Fixes #1
2024-06-08 12:51:14 +02:00
20d324f5cb Fix linter error
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
- remove nextPotentialRun.Add that had no effect
2023-05-12 19:18:15 +02:00
4276594f8d Bump copyright year
Some checks failed
cacert-boardvoting/pipeline/head There was a failure building this commit
2023-05-12 17:56:45 +02:00
4a8307e16a Fix display of user 2023-05-12 17:56:17 +02:00
12796486d2 Fix summarizing vote results 2023-05-12 17:51:42 +02:00
f27f2bf801 Update to go 1.19
- update go tool in Jenkinsfile
- update go version in go.mod
- update README.md
- update dependencies
2023-05-12 17:37:04 +02:00
d0052ff3dc Import go-sqlite3 to fix test
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-16 12:05:31 +02:00
c9d3f2a20a Fix permission issues for unauthenticated users
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-16 11:37:51 +02:00
fd287e4f55 Add Gitea URL for goreleaser
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-15 21:33:55 +02:00
e9af4bfd86 Improve documentation
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-15 21:31:11 +02:00
bb06fb95a2 Improve packaging
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
- gzip the README.md before packaging
- just release the binary
2022-10-15 21:14:16 +02:00
070bac6314 Fix ui build in Makefile
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-15 20:18:37 +02:00
41965ca076 Fix build
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-10-15 20:04:03 +02:00
c2eef9cf7c Refactoring away from main package
Some checks failed
cacert-boardvoting/pipeline/head There was a failure building this commit
This commit is a refactoring of code that has been located in the main
package. We introduce separate packages for the main application, jobs,
notifications, and request handlers.

Dependencies are injected from the main application, this will make
testing easier.
2022-10-15 19:58:58 +02:00
3dc3160945 Fix proxy_vote nil pointer dereference 2022-10-15 15:06:03 +02:00
dbf774b727 Increase timeout for golangci-lint
All checks were successful
cacert-boardvoting/pipeline/head This commit looks good
2022-09-26 19:28:25 +02:00
17867421eb Add Linting and tests to Jenkins pipeline 2022-09-26 19:15:13 +02:00
57fd5364fc Fix golangci-lint warnings
- fix spelling
- mark false positives of bodyclose (see
  https://github.com/timakin/bodyclose/issues/30)
2022-09-26 17:18:09 +02:00
c1c9ed5dec Fix golangci-lint warnings 2022-09-26 11:58:36 +02:00
c37bfb3b9a Fix mail config name in example configuration 2022-08-07 13:04:49 +02:00
ff58bf721c Fix test code 2022-06-04 19:30:58 +02:00
be77e5f05d Make remind voter job useful 2022-06-04 19:30:07 +02:00
f966cbd62f Implement user creation and voter management 2022-06-04 19:00:57 +02:00
39bd724381 Switch routing to chi
Routing with httprouter and alice became a bit too complex. This commit
replaces the routing and middleware composition with chi.
2022-06-04 14:48:24 +02:00
de4c6faef6 Implement email address deletion 2022-06-04 13:53:07 +02:00
99a2cde144 Improve flash message handling 2022-06-04 13:52:53 +02:00
be14a37b4d Implement add email address 2022-06-03 20:57:20 +02:00
a5475ec16e Implement user editing 2022-06-02 23:14:38 +02:00
5efc57d2c3 Implement user deletion
- add audit logging for user changes
- refactor model errors into functions
- implement user delete form and submit handlers
2022-06-01 18:57:38 +02:00
db52f88e25 Implement user list 2022-05-29 20:46:52 +02:00
71fc599a10 Fix goreleaser config 2022-05-29 15:51:53 +02:00
c625b6dc9e Add TODO tags for handlers that have no implementation 2022-05-29 15:47:18 +02:00
7f0b52c5b5 Fix linter warnings 2022-05-29 15:43:45 +02:00
368bd8eefb 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
2022-05-29 15:36:27 +02:00
28ddbd2ce6 Implement motion withdraw 2022-05-29 12:01:58 +02:00
c12aaf4d89 Implement proxy voting 2022-05-27 20:45:04 +02:00
0c02daf29a Implement direct voting 2022-05-27 17:40:01 +02:00
164495c818 Improve design
- improve icons
- implement VoteChoice and VoteStatus as real types with Scanner and Value
  methods
2022-05-27 14:42:39 +02:00
b8b6899cf3 Update modified timestamp when updating motion 2022-05-27 13:43:57 +02:00
2b98712aa8 Implement motion editing 2022-05-26 21:04:47 +02:00
335ce16547 Add tests for handlers and middleware
- drop migration 2022052601_drop_unused_decisions_colums because it was implicitly part of an earlier migration
- add /health endpoint for database health check
- add tests for the health check endpoint
- add tests for middleware secureHeaders, logRequest and tryAuthenticate
- add models.UserModel.CreateUser method
2022-05-26 19:22:56 +02:00
c3d0733e27 Replace gorilla/csrf with justinas/nosurf
- replace dependency for less indirect dependencies
- remove unused configuration options
2022-05-26 17:25:25 +02:00
257a777e03 Add Cache-Control for authenticated pages 2022-05-26 17:05:47 +02:00
d5d7525a31 Tighten http timeout settings 2022-05-26 16:53:52 +02:00
1695ce0168 Implement small improvements
- fix golangci-lint warnings
- start setup for user management
- nicer formatting of user login information
2022-05-26 16:47:57 +02:00
47092bfa9b Implement flash messages
- configure session cookie security
- setup flash message handling in newTemplateData
- show flash message in base.html
- add flash message for new motion
2022-05-26 16:30:30 +02:00
f8fbf00c4d Refactor new motion form processing
- extract form parsing into helper method app.decodePostForm
- extract field checks into form.Validate
2022-05-26 16:06:31 +02:00
a1a66b7245 Finish new motion create implementation
- rename config block mail_server to mail_config
- rename smtp server settings
- move mail notification settings to mail_config
- improve navigation templates
- prepare routes for user management
2022-05-26 15:27:25 +02:00
aa3a1b0cc7 Implement database mapping for vote types 2022-05-22 21:47:27 +02:00
e1af6876c1 Implement new motion form
- add session handler
- add form decoder from go-playground
- implement custom decoder for VoteType
2022-05-22 21:15:54 +02:00
47af34f1cd Use httprouter.NotFound and httprouter.PanicHandler 2022-05-22 15:00:50 +02:00
0ad88fe5f4 Implement motion detail view
- add httprouter for parameterized routing
- improve styling
- add routes and handlers
- implement motion detail handler
2022-05-22 14:08:02 +02:00
44a6180a09 Use alice to chain middleware 2022-05-22 12:23:42 +02:00
2ddc013c84 Add panic recovery middleware 2022-05-22 12:19:25 +02:00
c0a73494c3 Add request logging 2022-05-22 12:06:23 +02:00
4ce321dc36 Implement security headers and HTTPS 2022-05-22 11:02:37 +02:00
c4c64d0202 Add output buffering for rendered pages 2022-05-21 21:02:15 +02:00
65cce5b723 Use embedded templates 2022-05-21 20:57:32 +02:00
ff93acb65c Refactorings
- fix typo in nav.html and template functions
- implement template cache and render function
- refactor motion list methods to reduce cyclomatic complexity
2022-05-21 20:49:35 +02:00
ec7623a51a Implement timestamp pagination for motion list 2022-05-21 19:18:17 +02:00
2b8beadb77 Remove unused Print method 2022-05-21 14:30:57 +02:00
49295d2caa Add context to model methods 2022-05-21 14:27:46 +02:00
3346cb5dba Fix golangci-lint warnings 2022-05-21 14:09:19 +02:00
68d6f4bcdc Reimplement decision close job 2022-05-21 13:51:17 +02:00
01b95f2253 Start notification job refactoring 2022-05-15 20:10:49 +02:00
933f21a43c Move migrations to internal/migrations 2022-05-14 12:45:53 +02:00
ec7d2fe324 Start refactoring to packages 2022-05-09 21:09:24 +02:00
a5c1a64a3c Update go and dependency versions 2022-05-09 18:06:11 +02:00
8d0d968441 Start move to new directory structure
- create cmd/boardvoting/main.go
- adapt .goreleaser.yml and Makefile to use cmd/boardvoting for the main
  package
2022-05-08 19:17:40 +02:00
d7a742d97d Update UI framework
- add package.json for npm/npx
- update to fomantic-ui
- move ui files to ui directory
- add UI build documentation to README.md
- add ui target to Makefile
- add addPrefix handler in boardvoting.go to allow the same /static/
  prefix for static resources
2022-05-08 16:13:50 +02:00
d22f31e823 Fix copyright headers in Go files 2022-03-21 13:39:00 +01:00
87b87f7de3 Fix golangci-lint configuration
- remove redundante whitespace
- rename wrong linter-settings to linters-settings
- ignore strconv functions for gomnd linter
- use australian locale for spell checking
2022-03-21 13:37:44 +01:00
7dbef080b1 Update README 2022-03-21 12:43:30 +01:00
3a25296b37 Handle client certificate failures 2022-03-21 12:18:55 +01:00
0c2fbf9d54 Wrap error when db connection closing fails 2022-03-21 12:18:20 +01:00
623bdf6d56 Replace magic numbers with constants
- use strconv.Atoi and strconv.Itoa where appropriate
- use constants for number base and size
- use constant for reminder job interval
2022-03-21 12:16:24 +01:00
63c748bb1d Update golangci-lint configuration
- replace deprecated linters
- add more linters
2022-03-21 12:14:25 +01:00
a9290b9717 Update to go 1.17, remove duplicate go-mail
- update module to go 1.17 level
- ran go mod tidy
- bump go version in Jenkinsfile
- drop github.com/go-mail/mail
2022-03-21 12:12:09 +01:00
c3adfd9e8b Add short section explaining the project ideas 2021-04-17 21:08:48 +02:00
975f3c0837 Adapt Makefile and boardvoting.go to new ldflags 2021-04-14 19:02:44 +02:00
4d87e35ec2 Add nfpms configuration 2021-04-14 18:37:17 +02:00
806d706d4f Add goreleaser config 2021-04-14 18:37:17 +02:00
32f271ca7a Remove old assets, improve Makefile 2021-03-14 18:10:51 +01:00
96797ec4ef Remove space character from go version 2021-03-07 20:28:01 +01:00
fdc5c5cc61 Use tools section for Jenkins 2021-03-07 20:22:06 +01:00
23d586e99d Attempt to define pipeline for go1.16 2021-03-07 19:58:57 +01:00
70cc0942ca Upgrade to Go 1.16 and newer libraries
- use embed from the standard library instead of packr
- upgrade to sprig v3
- upgrade gomail version
- use golang-migrate instead of sql-migrate to get embed support
- use statigz to deliver compressed static assets
2021-03-07 19:42:11 +01:00
03827874cf Configure golangci-lint and apply suggestions 2021-01-09 15:49:19 +01:00
594df29dc1 Explicitly define timeouts for http and https 2020-04-26 13:18:58 +02:00
bf67dfbc10 Remove call to deprecated BuildNameToCertificate 2020-04-26 13:18:01 +02:00
e6fb26e5ef Fix Goland warnings caused by large assets.go 2020-04-26 13:17:24 +02:00
b0aa52fc24 Fix issue with stricter template syntax 2020-04-26 13:16:47 +02:00
03247b420d Update go.mod for Go 1.14 2020-04-26 13:16:22 +02:00
58898b29a7 Add new table user_roles
This commit adds a new database table user_roles to prepare for the
introduction of a voter management system. All existing enabled voters
are added to the VOTER role.
2020-04-14 23:25:11 +02:00
6c9bf09f1a Add missing newline in README 2019-08-12 15:19:22 +02:00
ea9641cfb1 Refine HTML layout
This commit improves the page structure and unifies the layout. Some
reusable parts of the HTML code have been moved into
page_fragments.html.
2019-08-03 01:39:55 +02:00
1f32b6d25b Add icon images 2019-08-02 23:55:51 +02:00
96089d49df Improve documentation
- fix parameter name in curl invocation
- add default port of Python aoismtpd
2019-08-02 22:42:09 +02:00
fc9d0042c0 Remove .htaccess from PHP age 2019-07-31 18:37:46 +02:00
6ff78cde48 Initialize logger with timestamps 2019-07-31 17:45:05 +02:00
41a8261552 Ensure that the application can start with a new database
This commit changes the NewDB function to run migrations before preparing
SQL statements.
2019-07-31 17:42:12 +02:00
56741a1089 Add documentation for how to setup and use the software 2019-07-31 17:31:44 +02:00
c55617edc0 Remove unused MigrationsPath config parameter 2019-07-31 17:31:22 +02:00
29a7a1c90c Apply Apache License 2.0 2019-07-31 17:30:58 +02:00
eaced9af06 Remove unused styles.css file 2019-07-31 17:29:41 +02:00
4266620eff Update to latest jQuery and Semantic-UI 2019-07-31 15:04:39 +02:00
a69e017ead Update semantic UI to 2.4.2 2019-07-31 14:48:02 +02:00
2d7f0cc0e3 Modernize Go code
- switch from go-logging to logrus
- handle all errors
- use gomail's NewDialer instead of deprecated NewPlainDialer
2019-07-31 14:14:21 +02:00
870e3ab1d2 Update .gitignore 2019-07-31 14:09:02 +02:00
2a6debbf33 Fix syntax error 2019-07-31 12:47:34 +02:00
317aa7a91a Build go-bindata before building the application 2019-07-31 12:46:33 +02:00
0aa9ef7b5d Simplify Jenkinsfile 2019-07-31 12:35:54 +02:00
93f5305d8e Switch to go modules 2019-07-31 11:59:57 +02:00
fd2f3a6e5d Ignore count of applied migrations from migrate.Exec 2018-03-31 10:50:31 +02:00
5977eb5a7a Implement CSRF protection 2018-03-31 10:50:06 +02:00
33f75bdf1d Remove unused goose dependency constraints 2018-03-31 09:24:03 +02:00
8cf5ad44a4 Include $GOPATH in $PATH 2018-03-29 22:12:09 +02:00
369c9dab16 Install the go-bindata binary before build 2018-03-29 22:08:59 +02:00
431fba6120 Make sure go-bindata is installed before building 2018-03-29 22:07:50 +02:00
e8720798fb Only archive the cacert-boardvoting binary and the config example 2018-03-29 22:04:03 +02:00
4f013ebf3f Update dependencies 2018-03-29 22:02:21 +02:00
5d68bae54f Use assets for mail templates 2018-03-29 22:00:56 +02:00
94dcb5bd75 Use static assets for HTML templates
- implement custom http.Filesystem boardvoting.AssetFS
- replace "footer" and "header" with "footer.html" and "header.html"
- change renderTemplate to use Assets
- use boardvoting.GetAssetFS() with http.Fileserver
2018-03-29 21:26:12 +02:00
4dd5e09820 Embed database migrations
- switch from goose to github.com/rubenv/sql-migrate
- move assets (static, templates, migrations) to boardvoting package
- add generated boardvoting/assets.go
- remove unused static files from static directory
- add package db with db migration configuration
2018-03-29 20:08:41 +02:00
aea93c328e Update dependencies 2018-01-14 14:36:48 +01:00
e5d0b98514 Improve denied error page and output current authenticated user 2018-01-14 14:25:41 +01:00
a30a29a4e6 Remove duplicate jenkins- from BUILD id 2017-08-27 22:43:13 +02:00
8943fafeca Use a Jenkins build number for build id 2017-08-27 22:37:30 +02:00
ace63025ea Fix missing single quote 2017-08-27 21:48:10 +02:00
4afeb6ddfc Change to build directory inside shell blocks 2017-08-27 21:46:49 +02:00
06e0a52737 Move environment declaration to stage blocks 2017-08-27 21:44:02 +02:00
3fb815f6f1 Define target build directory environment variable 2017-08-27 21:42:25 +02:00
ebb15fc538 Reduce shell calls to single steps 2017-08-27 21:37:26 +02:00
9398d90a38 Use temporary path for gopath 2017-08-27 21:29:58 +02:00
9b07f3e538 Fix compile errors with pinned dependency versions 2017-08-27 16:30:49 +02:00
c62801fcb7 Add direct call to dep 2017-08-27 16:12:46 +02:00
91e6d9ad29 Set PATH to include gocode/bin 2017-08-27 16:06:03 +02:00
58aed9abd1 Use dep in Jenkinsfile 2017-08-27 15:56:23 +02:00
bf511ae4db Add dep dependency management
This commit introduces dependency management using
https://github.com/golang/dep to make builds reproducible.
2017-08-27 15:52:55 +02:00
aff6bf1fff Add Jenkinsfile to enable Jenkins build 2017-05-14 19:49:12 +02:00
14ed5a5020 Change motion content formatting
Use a linebreak sensitive paragraph instead of <pre> for motion content
output.
2017-05-14 15:01:12 +02:00
c48bd9e356 Use Semantic UI for all HTML templates 2017-04-30 02:37:29 +02:00
1c989fdfa3 Work on Semantic UI theming 2017-04-29 22:17:58 +02:00
2a38b6bcad Add jQuery and Semantic-UI for theming 2017-04-22 21:24:52 +02:00
4d23b6a48f Switch to more flexible go-logging
This commit switches from loggo to the more flexible go-logging
framework. Logs of severity INFO or higher are now written to a separate
boardvoting.log file.

Errors during execution of mail templates are now logged.

A reasoning for the vote result is now logged and put into the mail
notification when a decision is closed.
2017-04-22 20:07:39 +02:00
eec8620e49 Make compilation more verbose 2017-04-22 14:36:05 +02:00
5a449926f4 Use loggo for logging 2017-04-22 00:14:38 +02:00
fd0a8ed972 Run goose migration on application startup 2017-04-22 00:14:38 +02:00
c6b1435875 Add goose database migrations 2017-04-22 00:14:35 +02:00
dad5d58158 Remove PHP code 2017-04-22 00:14:11 +02:00
8d0e0eeb1b Use INSERT OR REPLACE to allow changing votes 2017-04-22 00:14:11 +02:00
12dd0717ad Refactor notifications to use a cleaner interface 2017-04-22 00:14:11 +02:00
8d1f18e16d Implement direct voting 2017-04-22 00:14:11 +02:00
2cac50ee86 Implement proxy voting 2017-04-22 00:14:08 +02:00
b6ad5d8ad3 Implement reminder job 2017-04-22 00:12:38 +02:00
dcdd5f715f Implement decision closing job 2017-04-22 00:12:38 +02:00
2de96dc13d Implement vote closing, refactor notifications 2017-04-22 00:12:38 +02:00
0ce9ad6dcc Implement withdraw motion 2017-04-22 00:12:38 +02:00
bc194e8943 Implement motion editing 2017-04-22 00:12:38 +02:00
cc0f5c0b7b Implement motion creation mail template 2017-04-22 00:12:38 +02:00
bcfbad42b6 Add version and build number output 2017-04-22 00:12:32 +02:00
471daf12ea Partialy add new motion creation 2017-04-22 00:12:32 +02:00
57e3d53245 Hide own votes link if no voter is authenticated 2017-04-22 00:12:31 +02:00
e0be1a6aa5 Switch to context API 2017-04-22 00:12:24 +02:00
6fe515ea52 Implement proper model, actions and template structure 2017-04-22 00:12:24 +02:00
f4360b98c8 Implement more RESTful URLs for motions
This commit implements URLs /motions/ and /motions/{:tag} handlers.
2017-04-22 00:12:24 +02:00
74987ce184 Initial Go code for reimplementation 2017-04-15 19:23:40 +02:00
37c6f2efe6 Rewrap database.sql for better readability 2017-04-15 19:23:05 +02:00
612 changed files with 233521 additions and 921 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
*.crt
*.key
*.log
*.pem
*.req.conf
*.sqlite
*.sqlite-journal
.*.swp
.idea/
/debian/*.gz
/dist/
/ui/semantic/dist/
cacert-boardvoting
config.yaml
node_modules/

76
.golangci.yml Normal file
View file

@ -0,0 +1,76 @@
---
run:
go: "1.22"
issues:
exclude-files:
- boardvoting/assets.go
output:
sort-results: true
linters-settings:
goheader:
values:
const:
ORGANIZATION: CAcert Inc.
template: |-
Copyright {{ ORGANIZATION }}
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.
mnd:
ignored-functions:
- 'strconv.*'
ignored-numbers:
- '-1,0,1,2,8'
goimports:
local-prefixes: git.cacert.org/cacert-boardvoting
misspell:
locale: "US"
ignore-words:
- CAcert
linters:
disable-all: false
enable:
- bodyclose
- containedctx
- contextcheck
- cyclop
- decorder
- errorlint
- exportloopref
- forbidigo
- forcetypeassert
- gocognit
- goconst
- gocritic
- gofmt
- goheader
- goimports
- mnd
- gosec
- lll
- makezero
- misspell
- nakedret
- nestif
- nlreturn
- nolintlint
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- wrapcheck
- wsl

55
.goreleaser.yml Normal file
View file

@ -0,0 +1,55 @@
# This is an example .goreleaser.yml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
project_name: cacert-boardvoting
version: 2
before:
hooks:
- go mod tidy
- sh -c "gzip < README.md > debian/README.md.gz"
builds:
- env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -buildmode=pie
- -trimpath
- -v
main: ./cmd/boardvoting
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
nfpms:
-
package_name: cacert-boardvoting
file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}"
maintainer: Jan Dittberner <jandd@cacert.org>
section: misc
formats:
- deb
dependencies:
- libsqlite3-0
- adduser
priority: optional
bindir: /srv/cacert-boardvoting
contents:
- src: debian/README.md.gz
dst: /usr/share/doc/cacert-boardvoting/README.md.gz
- src: debian/copyright
dst: /usr/share/doc/cacert-boardvoting/copyright
- src: config.yaml.example
dst: /usr/share/doc/cacert-boardvoting/examples/config.yaml.example
- src: cacert-boardvoting.service
dst: /lib/systemd/system/cacert-boardvoting.service
gitea_urls:
api: https://code.cacert.org/api/v1/
download: https://code.cacert.org

View file

@ -1,33 +0,0 @@
<IfModule mod_php5.c>
php_flag display_errors Off
php_flag log_errors On
php_value error_log syslog
php_flag safe_mode On
php_flag safe_mode_gid On
php_value open_basedir /var/www/board
php_value safe_mode_exec_dir /var/empty
</IfModule>
<FilesMatch "^(database.*|remind.php|closevotes.php.*)$">
Order Deny,Allow
Deny from all
</FilesMatch>
<FilesMatch "^(motions?|vote|proxy)\.php$">
# these files require authentication
<IfModule mod_ssl.c>
SSLOptions +OptRenegotiate +StdEnvVars +ExportCertData
SSLUserName SSL_CLIENT_S_DN_Email
SSLVerifyClient require
# <IfModule mod_rewrite.c>
# RewriteEngine on
# RewriteCond %{SSL:SSL_CLIENT_VERIFY} !=SUCCESS
# RewriteRule .? - [F]
# ErrorDocument 403 "You need a client side certificate issued by CAcert to access this url"
# </IfModule>
</IfModule>
</FilesMatch>

60
Jenkinsfile vendored Normal file
View file

@ -0,0 +1,60 @@
#!groovy
/*
Copyright 2017-2023 Jan Dittberner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this program 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.
*/
pipeline {
agent any
tools {
go "go-1.19"
}
environment {
GOPATH = "${env.WORKSPACE_TMP}/go"
}
stages {
stage('Lint') {
when { not { branch 'debian' } }
steps {
script {
if (!fileExists("${env.GOPATH}/bin/golangci-lint")) {
sh label: 'Install golangci-lint', script: 'mkdir -p "$(go env GOPATH)"; curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin'
}
}
sh label: 'Show environment', script: 'go env GOPATH'
sh label: 'Run golangci-lint', script: '$(go env GOPATH)/bin/golangci-lint run --timeout=10m0s --sort-results --verbose --max-same-issues 0 --max-issues-per-linter 0'
}
}
stage('Build') {
when { not { branch 'debian' } }
steps {
sh label: 'Build binary', script: 'make clean && make'
}
}
stage('Test') {
when { not { branch 'debian' } }
steps {
sh label: 'Run tests', script: 'make test'
}
}
stage('Create build output') {
when { not { branch 'debian' } }
steps {
archiveArtifacts artifacts: 'cacert-boardvoting,config.yaml.example'
}
}
}
}

202
LICENSE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.

27
Makefile Normal file
View file

@ -0,0 +1,27 @@
VERSION := $(shell git describe --always --dirty=-dev)
COMMIT := $(shell git show-ref --hash refs/heads/master)
DATE := $(shell date --iso-8601=seconds --utc)
GOFILES = $(shell find . -type f -name '*.go')
UIFILES = package.json package-lock.json semantic.json $(shell find ui/semantic -type f )
all: cacert-boardvoting
cacert-boardvoting: ${GOFILES}
go build -o $@ -buildmode=pie -trimpath -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" ./cmd/boardvoting
test:
go test -v ./...
lint:
golangci-lint run
clean:
rm -rf cacert-boardvoting dist/ debian/README.md.gz
ui/static/semantic.min.css: ${UIFILES}
npm install
cd node_modules/fomantic-ui ; npx gulp build
ui: ui/static/semantic.min.css
.PHONY: clean all ui test lint

211
README.md Normal file
View file

@ -0,0 +1,211 @@
# CAcert board voting service
This project contains the source code for the CAcert board voting software running on https://motion.cacert.org/.
## Ideas
The board voting system is meant to be used by the elected committee members of CAcert Inc. to allow them to do votes on
decisions in a distributed way. The system keeps track of the individual decisions and votes. It takes care of
authenticating board members using client certificates and performs timekeeping for decisions. The system sends voting
requests to all board members and takes care of sending reminders as well es decision results.
There is a concept of proxy votes that mean that one member of the board is allowed to vote in representation of another
member of a board.
## License
The CAcert board voting software is licensed under the terms of the Apache License, Version 2.0.
Copyright 2017-2022 Jan Dittberner
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this program 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.
## History
The CAcert board voting software is a [Go] reimplementation of the ancient PHP implementation that had been serving the
CAcert board. The Subversion repository at https://svn.cacert.cl/Software does not exist anymore, so the last available
version from http://community.cacert.org/board/ has been taken from the system. The latest file changed was `proxy.php`
with a change date of 2011-05-15 23:13 UTC. The latest svn revision was:
```text
Path: .
URL: https://svn.cacert.cl/Software/Voting/vote
Repository Root: https://svn.cacert.cl/Software
Repository UUID: d4452222-2f33-11de-9270-010000000000
Revision: 66
Node Kind: directory
Schedule: normal
Last Changed Author: community.cacert.org
Last Changed Rev: 66
Last Changed Date: 2009-07-12 04:02:38 +0000 (Sun, 12 Jul 2009)
```
---
## Development requirements
Local development requires
* golang >= 1.19
* sqlite3 and development headers
* GNU make
* nodejs, npm and gulp (only needed if you intend to update the [jQuery] or [Fomantic-UI] CSS and JavaScript)
On a Debian 12 (Bookworm) system you can run the following command to get all required dependencies:
```bash
sudo apt install libsqlite3-dev golang-go make gulp
```
## Getting started
Clone the code via git:
```shell script
git clone ssh://git.cacert.org/var/cache/git/cacert-boardvoting.git
```
To get started copy `config.yaml.example` to `config.yaml` and customize the parameters. You will also need a set of
X.509 certificates and a private key because the application performs TLS Client certificate authentication. You might
use `openssl` to create a self-signed server certificate and retrieve the CAcert class 3 root from the CAcert website:
```shell script
openssl req -new -newkey rsa:2048 -keyout server.key -x509 -out server.crt -subj '/CN=localhost'
curl -o cacert_class3.pem http://www.cacert.org/certs/class3_X0E.crt
```
It is advisable to have a local mail setup that intercepts outgoing email or to use email addresses that you control.
You can use the following table to find useful values for the parameters in `config.yaml`.
| Parameter | Description | How to get a valid value |
|-------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| `database_file` | a SQLite database file (production value is `database.sqlite`) | keep the default or use something like `local.sqlite` |
| `client_ca_certificates` | File containing allowed client certificate CA certificates (production value is `cacert_class3.pem`) | use the shell code above |
| `server_certificate` | X.509 certificate that is used to identify your server (i.e. `server.crt`) | use the filename used as `-out` parameter in the `openssl` invocation above |
| `server_key` | PEM encoded private key file (i.e. `server.key`) | use the filename used as `-keyout` parameter in the `openssl` invocation above |
| `mail_config.smtp_host` | Mail server host (production value is `localhost`) | `localhost` |
| `mail_config.smtp_port` | Mail server TCP port (production value is `25` | see [how to setup a debugging SMTP server](#debugging-smtp-server) below and choose the port of that (default `8025`) |
| `mail_config.base_url` | The base URL of your application instance (production value is https://motions.cacert.org) | use https://localhost:8443 |
| `mail_config.notice_mail_address` | email address where notifications about votes are sent (production value is cacert-board@lists.cacert.org) | be creative but do not spam others (i.e. use user+board@your-domain.org) |
| `mail_config.vote_notice_mail_address` | email address where notifications about individual votes are sent (production value is cacert-board-votes@lists.cacert.org) | be creative but do not spam others (i.e. use user+votes@your-domain.org) |
| `mail_config.notification_sender_address` | sender address for all mails sent by the system (production value is returns@cacert.org) | be creative but do not spam others (i.e. use user+returns@your-domain.org) |
| `timeouts.idle` | idle timeout setting for HTTP and HTTPS (default: 1 minute) | specify a nano second value |
| `timeouts.read` | read timeout setting for HTTP and HTTPS (default: 5 seconds) | |
| `timeouts.read_header` | header read timeout setting for HTTP and HTTPS (default: 5 seconds) | |
| `timeouts.write` | write timeout setting for HTTP and HTTPS (default: 10 seconds) | |
### Generating random byte values
```shell script
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64
```
### Debugging SMTP server
You can use [aiosmtpd](https://aiosmtpd.readthedocs.io/en/latest/cli.html) to set up a small testing SMTP
server that logs to stdout:
```shell script
sudo apt install python3-aiosmtpd
python3 -m aiosmtpd -n
```
Another good local SMTP debugging tool is [MailHog](https://github.com/mailhog/MailHog) which provides a web based
user interface and a REST API to inspect received mails.
### Build and run
```shell script
make
./cacert-boardvoting
```
### Build UI resources
[Fomantic-UI] is used as a CSS framework. Configuration is stored in `semantic.json` in the
project root directory.
Building the UI resource requires
* NodeJS >= v8
* NPM >= v5
To install fomantic-ui and build the UI resources do:
```
npm install
cd node_modules/fomantic-ui
npx gulp build
```
## Code structure
```
├── cmd
│   └── boardvoting
├── config.yaml.example
├── debian
├── go.mod
├── go.sum
├── internal
│   ├── app
│   ├── forms
│   ├── handlers
│   ├── jobs
│   ├── mailtemplates
│   ├── mailtemplates.go
│   ├── middleware
│   ├── migrations
│   ├── migrations.go
│   ├── models
│   ├── notifications
│   └── validator
├── Jenkinsfile
├── LICENSE
├── Makefile
├── package.json
├── package-lock.json
├── README.md
├── semantic.json
└── ui
├── efs.go
├── html
├── semantic
└── static
```
The `cmd/boardvoting` directory contains the application code.
The `internal/migrations` directory contains database migration scripts.
Static assets and [Go templates] for HTML pages are stored in `ui/static` and `ui/html`.
Email templates are stored in `internal/mailtemplates`.
All Go code besides the main application is stored in subdirectories of `internal`.
The `ui/semantic` directory contains a download of [Fomantic-UI].
The entry point into the application is `cmd/boardvoting/main.go`. `Makefile` controls the build
`Jenkinsfile` contains the pipeline definition for the [Continuous Integration Job].
`package-lock.json` contains the pinned versions of external JavaScript and CSS assets (use
`npm install` to download them into a local `node_modules` directory). `semantic.json` is the
configuration file for the [Fomantic-UI] CSS framework.
[Continuous Integration Job]: https://jenkins.cacert.org/job/cacert-boardvoting/
[Go]: https://golang.org/
[Go templates]: https://golang.org/pkg/text/template/
[jQuery]: https://jquery.com/
[Fomantic-UI]: https://fomantic-ui.com/

View file

@ -0,0 +1,12 @@
[Unit]
Description=CAcert board voting software
Documentation=file:/usr/share/doc/cacert-boardvoting/README.md.gz
After=network.target
ConditionPathExists=/srv/cacert-boardvoting/config.yaml
[Service]
ExecStart=/srv/cacert-boardvoting/cacert-boardvoting -config /srv/cacert-boardvoting/config.yaml
User=cacert-boardvoting
[Install]
WantedBy=multi-user.target

View file

@ -1,8 +0,0 @@
#!/usr/bin/php
<?
require_once("database.php");
$db = new DB();
$db->closeVotes();
?>

View file

@ -1,2 +0,0 @@
# echo "select strftime('%H:%M %m%d%Y',due) from decisions where status=0;" | sqlite3 database.sqlite | xargs -n1 -I^ sudo -u www-data at -f closevotes.php-script ^ +1minute
/var/www/board/closevotes.php

86
cmd/boardvoting/config.go Normal file
View file

@ -0,0 +1,86 @@
/*
Copyright 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"
"os"
"time"
"gopkg.in/yaml.v2"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
const (
httpIdleTimeout = time.Minute
httpReadHeaderTimeout = 5 * time.Second
httpReadTimeout = 5 * time.Second
httpWriteTimeout = 10 * time.Second
smtpPort = 25
smtpTimeout = 10 * time.Second
)
type httpTimeoutConfig struct {
Idle time.Duration `yaml:"idle,omitempty"`
Read time.Duration `yaml:"read,omitempty"`
ReadHeader time.Duration `yaml:"read_header,omitempty"`
Write time.Duration `yaml:"write,omitempty"`
}
type Config struct {
DatabaseFile string `yaml:"database_file"`
ClientCACertificates string `yaml:"client_ca_certificates"`
ServerCert string `yaml:"server_certificate"`
ServerKey string `yaml:"server_key"`
CookieSecretStr string `yaml:"cookie_secret"`
CsrfKeyStr string `yaml:"csrf_key"`
HTTPAddress string `yaml:"http_address,omitempty"`
HTTPSAddress string `yaml:"https_address,omitempty"`
MailConfig *notifications.MailConfig `yaml:"mail_config"`
Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"`
}
func parseConfig(configFile string) (*Config, error) {
source, err := os.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("could not read configuration file %s: %w", configFile, err)
}
config := &Config{
HTTPAddress: "127.0.0.1:8000",
HTTPSAddress: "127.0.0.1:8443",
Timeouts: &httpTimeoutConfig{
Idle: httpIdleTimeout,
ReadHeader: httpReadHeaderTimeout,
Read: httpReadTimeout,
Write: httpWriteTimeout,
},
MailConfig: &notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: smtpPort,
SMTPTimeOut: smtpTimeout,
},
}
if err := yaml.Unmarshal(source, config); err != nil {
return nil, fmt.Errorf("could not parse configuration: %w", err)
}
return config, nil
}

208
cmd/boardvoting/main.go Normal file
View file

@ -0,0 +1,208 @@
/*
Copyright 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.
*/
// The CAcert board voting software.
package main
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
u "git.cacert.org/cacert-boardvoting/internal/app"
"git.cacert.org/cacert-boardvoting/internal"
)
const sessionHours = 12
var (
version = "undefined"
commit = "undefined"
date = "undefined"
)
func main() {
configFile := flag.String("config", "config.yaml", "Configuration file name")
flag.Parse()
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime)
infoLog.Printf("CAcert Board Voting version %s, commit %s built at %s", version, commit, date)
config, err := parseConfig(*configFile)
if err != nil {
errorLog.Fatal(err)
}
db, err := openDB(config.DatabaseFile)
if err != nil {
errorLog.Fatal(err)
}
defer func(db io.Closer) {
_ = db.Close()
}(db)
if err != nil {
errorLog.Fatalf("could not setup decision model: %v", err)
}
sessionManager := scs.New()
sessionManager.Store = sqlite3store.New(db.DB)
sessionManager.Lifetime = sessionHours * time.Hour
sessionManager.Cookie.SameSite = http.SameSiteStrictMode
sessionManager.Cookie.Secure = true
application, err := u.New(errorLog, infoLog, db, config.MailConfig, sessionManager)
if err != nil {
errorLog.Fatalf("could not setup application: %v", err)
}
err = internal.InitializeDb(db.DB, infoLog)
if err != nil {
errorLog.Fatal(err)
}
defer func(application io.Closer) {
_ = application.Close()
}(application)
infoLog.Printf("Starting server on %s", config.HTTPAddress)
errChan := make(chan error, 1)
infoLog.Printf("TLS config setup, starting TLS server on %s", config.HTTPSAddress)
go setupHTTPRedirect(config, errChan)
err = startHTTPSServer(config, errorLog, application.Routes(), func() { _ = application.Close() })
if err != nil {
errorLog.Fatalf("ListenAndServeTLS (HTTPS) failed: %v", err)
}
if err := <-errChan; err != nil {
errorLog.Fatalf("ListenAndServe (HTTP) failed: %v", err)
}
}
func startHTTPSServer(config *Config, errorLog *log.Logger, routes http.Handler, shutdownFunc func()) error {
tlsConfig, err := setupTLSConfig(config)
if err != nil {
return fmt.Errorf("could not setup TLS configuration: %w", err)
}
srv := &http.Server{
Addr: config.HTTPSAddress,
TLSConfig: tlsConfig,
ErrorLog: errorLog,
Handler: routes,
IdleTimeout: config.Timeouts.Idle,
ReadHeaderTimeout: config.Timeouts.ReadHeader,
ReadTimeout: config.Timeouts.Read,
WriteTimeout: config.Timeouts.Write,
}
srv.RegisterOnShutdown(shutdownFunc)
err = srv.ListenAndServeTLS(config.ServerCert, config.ServerKey)
if err != nil {
return fmt.Errorf("")
}
return nil
}
func setupHTTPRedirect(config *Config, errChan chan error) {
redirect := &http.Server{
Addr: config.HTTPAddress,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectURL := url.URL{
Scheme: "https://",
Host: strings.Join(
[]string{
strings.Split(r.URL.Host, ":")[0],
strings.Split(config.HTTPSAddress, ":")[1],
},
":",
),
Path: r.URL.Path,
}
http.Redirect(w, r, redirectURL.String(), http.StatusMovedPermanently)
}),
IdleTimeout: config.Timeouts.Idle,
ReadHeaderTimeout: config.Timeouts.ReadHeader,
ReadTimeout: config.Timeouts.Read,
WriteTimeout: config.Timeouts.Write,
}
if err := redirect.ListenAndServe(); err != nil {
errChan <- err
}
close(errChan)
}
func setupTLSConfig(config *Config) (*tls.Config, error) {
caCert, err := os.ReadFile(config.ClientCACertificates)
if err != nil {
return nil, fmt.Errorf("could not read client certificate CAs %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf(
"could not initialize client CA certificate pool from %s",
config.ClientCACertificates,
)
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
ClientCAs: caCertPool,
ClientAuth: tls.VerifyClientCertIfGiven,
}, nil
}
func openDB(dbFile string) (*sqlx.DB, error) {
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
return nil, fmt.Errorf("could not open database file %s: %w", dbFile, err)
}
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("could not ping database: %w", err)
}
return sqlx.NewDb(db, "sqlite3"), nil
}

13
config.yaml.example Normal file
View file

@ -0,0 +1,13 @@
---
database_file: database.sqlite
client_ca_certificates: cacert_class3.pem
server_certificate: server.crt
server_key: server.key
mail_config:
smtp_host: localhost
smtp_port: 25
base_url: https://motions.cacert.org
message_id_domain: motions.cacert.org
notice_mail_address: cacert-board@lists.cacert.org
vote_notice_mail_address: cacert-board-votes@lists.cacert.org
notification_sender_address: returns@cacert.org

View file

@ -1,160 +0,0 @@
<?php
class DB {
var $board = "cacert-board@lists.cacert.org";
var $notices = "cacert-board-votes@lists.cacert.org";
function __construct() {
$this->dbh = new PDO("sqlite:".dirname(__FILE__)."/database.sqlite");
$this->statement = array();
$this->statement['list decisions'] = $this->dbh->prepare("SELECT decisions.id AS id, decisions.tag AS tag, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=1) AS ayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=-1) AS nayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=0) AS abstains FROM decisions, voters WHERE decisions.proponent=voters.id ORDER BY proposed DESC LIMIT 10 OFFSET 10 * (:page - 1);");
$this->statement['list my unvoted decisions'] = $this->dbh->prepare("SELECT * FROM (SELECT decisions.id AS id, decisions.tag AS tag, voters.name AS proposer,decisions.proposed AS proposed, decisions.title AS title, decisions.content AS content, decisions.votetype AS votetype, decisions.status AS status, decisions.due AS due, decisions.modified AS modified,(SELECT COUNT(*) AS ayes FROM votes WHERE decision=decisions.id AND vote=1), (SELECT COUNT(*) AS nayes FROM votes WHERE decision=decisions.id AND vote=-1), (SELECT COUNT(*) AS abstains FROM votes WHERE decision=decisions.id AND vote=0) FROM decisions, voters WHERE decisions.proponent=voters.id AND decisions.status=0) WHERE NOT EXISTS (SELECT vote FROM votes WHERE votes.decision=id AND votes.voter=:id) ORDER BY proposed DESC LIMIT 10 OFFSET 10 * (:page - 1);");
$this->statement['list decision'] = $this->dbh->prepare("SELECT decisions.id AS id, decisions.tag AS tag, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=1) AS ayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=-1) AS nayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=0) AS abstains FROM decisions, voters WHERE decisions.proponent=voters.id AND decisions.tag=:id ORDER BY proposed DESC;");
$this->statement['closed decisions'] = $this->dbh->prepare("SELECT decisions.id, decisions.tag, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=1) AS ayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=-1) AS nayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=0) AS abstains FROM decisions, voters WHERE decisions.proponent=voters.id AND decisions.status=0 AND datetime('now','utc') > datetime(due);");
$this->statement['get decision'] = $this->dbh->prepare("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, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=1) AS ayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=-1) AS nayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=0) AS abstains FROM decisions, voters WHERE decisions.proponent=voters.id AND decisions.id=:decision;");
$this->statement['get new decision'] = $this->dbh->prepare("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, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=1) AS ayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=-1) AS nayes, (SELECT COUNT(*) FROM votes WHERE decision=decisions.id AND vote=0) AS abstains FROM decisions, voters WHERE decisions.proponent=voters.id AND decisions.id=last_insert_rowid();");
$this->statement['get voter'] = $this->dbh->prepare("SELECT voters.id, voters.name FROM voters, emails WHERE voters.id=emails.voter AND emails.address=? AND voters.enabled=1");
$this->statement['get voter by id'] = $this->dbh->prepare("SELECT voters.id, voters.name FROM voters WHERE id=:id;");
$this->statement['get voters'] = $this->dbh->prepare("SELECT voters.id, voters.name FROM voters WHERE voters.enabled=1 ORDER BY name ASC;");
$this->statement['get reminder voters'] = $this->dbh->prepare("SELECT voters.id, voters.name, voters.reminder AS email FROM voters WHERE voters.enabled=1 AND voters.reminder!='' ORDER BY name ASC;");
$this->statement['del vote'] = $this->dbh->prepare("DELETE FROM votes WHERE decision=:decision AND voter=:voter;");
$this->statement['do vote'] = $this->dbh->prepare("INSERT INTO votes (decision, voter, vote, voted, notes) VALUES (:decision, :voter, :vote, datetime('now','utc'), :notes);");
$this->statement['stats'] = $this->dbh->prepare("SELECT COUNT(*) AS voters FROM voters WHERE enabled=1;");
$this->statement['list votes'] = $this->dbh->prepare("SELECT voters.name AS name, votes.vote AS vote FROM voters,votes WHERE voters.id=votes.voter AND votes.decision=:id;");
$this->statement['create decision'] = $this->dbh->prepare("INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag) VALUES (datetime('now','utc'), :proponent, :title, :content, :votetype, 0, datetime(date('now','utc'),'utc', :due,'+1 day','-1 second'), datetime('now','utc'),'m' || strftime('%Y%m%d','now') || '.' || (select count(*)+1 as num from decisions where proposed between date('now') and date('now','1 day')));");
$this->statement['update decision'] = $this->dbh->prepare("UPDATE decisions SET proposed=datetime('now','utc'), proponent=:proponent, title=:title, content=:content, votetype=:votetype, status=0, due=datetime(date('now','utc'),'utc', :due,'+1 day','-1 second'), modified=datetime('now','utc') WHERE id=:id;");
$this->statement['close decision'] = $this->dbh->prepare("UPDATE decisions SET status=:status, modified=datetime('now','utc') WHERE id=:decision");
ini_set('mbstring.internal_encoding', 'UTF-8');
}
function getStatement($name) {
return $this->statement[$name];
}
function closeVotes() {
$stmt = $this->getStatement("closed decisions");
$upd = $this->getStatement("close decision");
if ($stmt->execute()) {
while ($decision = $stmt->fetch()) {
switch ($decision['votetype']) {
case 0: // motion
$quorum = 3; $majority = 50; break;
case 1: // veto
default:
$quorum = 1; $majority = 99; break;
}
$votes = $decision['ayes'] + $decision['nayes'] + $decision['abstains'];
if ($votes < $quorum) {
$decision['status'] = -1;
} else {
$votes = $decision['ayes'] + $decision['nayes'];
if (($decision['ayes'] / $votes) > ($majority / 100)) {
$decision['status'] = 1;
} else {
$decision['status'] = -1;
}
}
$upd->bindParam(":decision",$decision['id']);
$upd->bindParam(":status",$decision['status']);
$upd->execute();
$state = $decision['status']==1?"accepted":"declined";
$tag = $decision['tag'];
$title = $decision['title'];
$content = $decision['content'];
$votetype = !$decision['votetype']?'motion':'veto';
$ayes = $decision['ayes'];
$nayes = $decision['nayes'];
$abstains = $decision['abstains'];
$totalvotes = $decision['ayes']+$decision['nayes'];
if ($totalvotes <= 0) $percent = 0;
else $percent = $decision['ayes'] * 100 / $totalvotes;
$body = <<<BODY
Dear Board,
The motion with the identifier $tag has been $state.
Motion:
$title
$content
Vote type: $votetype
Ayes: $ayes
Nayes: $nayes
Abstentions: $abstains
Percentage: $percent%
Kind regards,
the voting system.
BODY;
$this->notify("Re: ".$decision['tag']." - ".$decision['title']." - finalised",$body,$decision['tag']);
}
}
}
function notify($subject,$body,$tag,$first=FALSE)
{
$header = "Content-Type: text/plain; charset=UTF-8\r\n";
if ($first) {
$header .= "Message-id: <".$tag.">\r\n";
} else {
$header .= "References: <".$tag.">\r\nIn-reply-to: <".$tag.">\r\n";
}
mail($this->board, mb_encode_mimeheader($subject,"UTF-8", "B", "\n"),$body,$header."From: Voting System <returns@cacert.org>");
}
function vote_notify($subject,$body,$tag)
{
$header = "Content-Type: text/plain; charset=UTF-8\r\n";
$header .= "References: <".$tag.">\r\nIn-reply-to: <".$tag.">\r\n";
mail($this->notices, mb_encode_mimeheader($subject,"UTF-8", "B", "\n"),$body,$header."From: Voting System <returns@cacert.org>");
}
function remind_notify($email,$subject,$body)
{
$header = "Content-Type: text/plain; charset=UTF-8\r\n";
mail($email,$subject,$body,$header."From: Voting System <returns@cacert.org>");
}
function auth()
{
$stmt = $this->getStatement("get voter");
$stmt->execute(array($_SERVER['REMOTE_USER']));
$user = $stmt->fetch();
if ($user) return $user;
if ($_SERVER['SSL_CLIENT_S_DN_Email']) {
$stmt->execute(array($_SERVER['SSL_CLIENT_S_DN_Email']));
$user = $stmt->fetch();
if ($user) return $user;
}
$d=0;
while ($email=$_SERVER["SSL_CLIENT_S_DN_Email_$d"]) {
$stmt->execute(array($email));
$user = $stmt->fetch();
if ($user) return $user;
++$d;
}
$dn=$_SERVER['SSL_CLIENT_S_DN'];
if (preg_match_all('/\/emailAddress=([^\/]*)/',$dn,$reg,PREG_SET_ORDER)) {
foreach ($reg as $emailarr) {
$stmt->execute(array($emailarr[1]));
$user = $stmt->fetch();
if ($user) return $user;
}
}
if ($_SERVER['SSL_CLIENT_CERT']) {
# subjectAltName unpresented by Apache http://httpd.apache.org/docs/trunk/mod/mod_ssl.html
# subjectAltName http://tools.ietf.org/html/rfc5280#section-4.2.1.6
# WARNING WARNING openssl_x509_parse is an unstable PHP API
$x509 = openssl_x509_parse($_SERVER['SSL_CLIENT_CERT']);
$subjectAltName = $x509['extensions']['subjectAltName']; // going off https://foaf.me/testSSL.php
#print_r(split("[, ]",$subjectAltName));
#print_r($x509);
#echo $subjectAltName;
if (preg_match_all('/email:([^, ]*)/',$subjectAltName,$reg,PREG_SET_ORDER)) {
foreach ($reg as $emailarr) {
$stmt->execute(array($emailarr[1]));
$user = $stmt->fetch();
if ($user) return $user;
}
}
}
return FALSE;
}
}
?>

View file

@ -1,4 +0,0 @@
CREATE TABLE decisions (id INTEGER PRIMARY KEY, proposed DATETIME, proponent INTEGER, title VARCHAR(255), content TEXT, quorum INTEGER, majority INTEGER, status INTEGER, due DATETIME, modified DATETIME, tag varchar(255), votetype INT4 DEFAULT 0 NOT NULL);
CREATE TABLE emails (voter INT4, address VARCHAR(255));
CREATE TABLE voters (id INTEGER PRIMARY KEY, name VARCHAR(255), enabled INTEGER default 0, reminder VARCHAR(255));
CREATE TABLE votes (decision INT4, voter INT4, vote INT4, voted DATETIME, notes text default '');

22
debian/copyright vendored Normal file
View file

@ -0,0 +1,22 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: CAcert board voting software
Upstream-Contact: Jan Dittberner <jandd@cacert.org>
Source: https://code.cacert.org/cacert/cacert-boardvoting.git
Files: *
Copyright: 2017-2022 Jan Dittberner
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this program 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.
.
On Debian systems, the full text of the Apache License, Version 2.0 can be
found in the file `/usr/share/common-licenses/Apache-2.0`

View file

@ -1,12 +0,0 @@
<html>
<head>
<title>CAcert Board Decisions</title>
<meta http-equiv="Content-Type" content="text/html; charset='UTF-8'" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<b>You are not authorized to act here!</b><br/>
<i>If you think this is in error, please contact the administrator</i>
<i>If you don't know who that is, it is definitely not an error ;)</i>
</body>
</html>

46
go.mod Normal file
View file

@ -0,0 +1,46 @@
module git.cacert.org/cacert-boardvoting
go 1.22
require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/jmoiron/sqlx v1.4.0
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a
github.com/mattn/go-sqlite3 v1.14.22
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/vearutop/statigz v1.4.0
golang.org/x/crypto v0.24.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885
github.com/alexedwards/scs/v2 v2.8.0
github.com/go-chi/chi/v5 v5.0.12
github.com/go-playground/form/v4 v4.2.1
github.com/justinas/nosurf v1.1.1
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb
github.com/stretchr/testify v1.8.3
golang.org/x/text v0.16.0
)
require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

681
go.sum Normal file
View file

@ -0,0 +1,681 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.63.0/go.mod h1:GmezbQc7T2snqkEXWfZ0sy0VfkB/ivI2DdtJL2DEmlg=
cloud.google.com/go v0.64.0/go.mod h1:xfORb36jGvE+6EexW71nMEtL025s3x6xvuYUKM4JLv4=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/spanner v1.9.0/go.mod h1:xvlEn0NZ5v1iJPYsBnUVRDNvccDxsBTEi16pJRKQVws=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/clickhouse-go v1.3.12/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0=
github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bool64/dev v0.2.28 h1:6ayDfrB/jnNr2iQAZHI+uT3Qi6rErSbJYQs1y8rSrwM=
github.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/cockroach-go v0.0.0-20190925194419-606b3d062051/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk=
github.com/containerd/containerd v1.4.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20200620013148-b91950f658ec/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhui/dktest v0.3.3/go.mod h1:EML9sP4sqJELHn4jV7B0TY8oF6077nk83/tz7M56jcQ=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v17.12.0-ce-rc1.0.20200618181300-9dc6525e6118+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw=
github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/golang-migrate/migrate/v4 v4.14.2-0.20201125065321-a53e6fc42574/go.mod h1:l7Ks0Au6fYHuUIxUhQ0rcVX1uLlJg54C/VvW7tvxSz0=
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.0.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.3.2/go.mod h1:LvCquS3HbBKwgl7KbX9KyqEIumJAbm1UMcTvGaIf3bM=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a h1:89hRqHzTmEoJi8TY11K42F0isvNR0UAhL4V3hYD74pk=
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a/go.mod h1:lzH77MbyyahK7YO90wGRb65i9xLSoy2fD0dUSm23yMs=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/k0kubun/pp v2.3.0+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4=
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb h1:sb9NxqWoS17VT3aZd4mlBm48bsaHB1Fvwro3H/uiuZM=
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb/go.mod h1:bBamYL9/WjNn0b2CS4v4F8cHmWRpClSxrpEoAY+maJo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mutecomm/go-sqlcipher/v4 v4.4.0/go.mod h1:PyN04SaWalavxRGH9E8ZftG6Ju7rsPrGmQRjrEaVpiY=
github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA=
github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/snowflakedb/glog v0.0.0-20180824191149-f5055e6f21ce/go.mod h1:EB/w24pR5VKI60ecFnKqXzxX3dOorz1rnVicQTQrGM0=
github.com/snowflakedb/gosnowflake v1.3.5/go.mod h1:13Ky+lxzIm3VqNDZJdyvu9MCGy+WgRdYFdXp96UcLZU=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190225153610-fe579d43d832/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201029080932-201ba4db2418/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200806022845-90696ccdc692/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200814230902-9882f1d1823d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200818005847-188abfa75333/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.0.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200911024640-645f7a48b24f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201030142918-24207fddd1c3/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg=
modernc.org/db v1.0.0/go.mod h1:kYD/cO29L/29RM0hXYl4i3+Q5VojL31kTUVpVJDw0s8=
modernc.org/file v1.0.0/go.mod h1:uqEokAEn1u6e+J45e54dsEA/pw4o7zLrA2GwyntZzjw=
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk=
modernc.org/internal v1.0.0/go.mod h1:VUD/+JAkhCpvkUitlEOnhpVxCgsBI90oTzSCRcqQVSM=
modernc.org/lldb v1.0.0/go.mod h1:jcRvJGWfCGodDZz8BPwiKMJxGJngQ/5DrRapkQnLob8=
modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k=
modernc.org/ql v1.0.0/go.mod h1:xGVyrLIatPcO2C1JvI/Co8c0sr6y91HKFNy4pt9JXEY=
modernc.org/sortutil v1.1.0/go.mod h1:ZyL98OQHJgH9IEfN71VsamvJgrtRX9Dj2gX+vH86L1k=
modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View file

@ -1,5 +0,0 @@
<?php
header("HTTP/1.0 301 Redirect");
header("Location: motions.php");
exit();
?>

243
internal/app/app.go Normal file
View file

@ -0,0 +1,243 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package u
import (
"context"
"fmt"
"io/fs"
"log"
"net/http"
"strconv"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-playground/form/v4"
"github.com/jmoiron/sqlx"
"github.com/vearutop/statigz"
"github.com/vearutop/statigz/brotli"
"git.cacert.org/cacert-boardvoting/internal/handlers"
"git.cacert.org/cacert-boardvoting/internal/jobs"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
"git.cacert.org/cacert-boardvoting/ui"
)
type Application struct {
errorLog, infoLog *log.Logger
users *models.UserModel
motions *models.MotionModel
jobScheduler *jobs.JobScheduler
mailNotifier *notifications.MailNotifier
mailConfig *notifications.MailConfig
templateCache *handlers.TemplateCache
sessionManager *scs.SessionManager
formDecoder *form.Decoder
}
func New(
errorLog, infoLog *log.Logger,
db *sqlx.DB,
mailConfig *notifications.MailConfig,
sessionManager *scs.SessionManager,
) (*Application, error) {
app := &Application{
errorLog: errorLog,
infoLog: infoLog,
mailConfig: mailConfig,
motions: &models.MotionModel{DB: db},
users: &models.UserModel{DB: db},
sessionManager: sessionManager,
}
var err error
app.templateCache, err = handlers.NewTemplateCache()
if err != nil {
return nil, fmt.Errorf("could not initialize template cache: %w", err)
}
app.setupFormDecoder()
app.mailNotifier = notifications.NewMailNotifier(
app.mailConfig,
notifications.NotifierLog(app.infoLog, app.errorLog),
)
go app.mailNotifier.Start()
app.jobScheduler = jobs.NewJobScheduler(jobs.SchedulerLog(app.infoLog, app.errorLog))
app.jobScheduler.AddJob(jobs.NewCloseDecisionsJob(
app.motions,
app.mailNotifier,
jobs.CloseDecisionsLog(app.infoLog, app.errorLog),
))
app.jobScheduler.AddJob(jobs.NewRemindVoters(
app.users, app.motions, app.mailNotifier,
jobs.RemindVotersLog(app.infoLog, app.errorLog),
))
go app.jobScheduler.Schedule()
return app, nil
}
func (app *Application) Close() error {
app.jobScheduler.Quit()
app.mailNotifier.Quit()
return nil
}
func (app *Application) setupFormDecoder() {
decoder := form.NewDecoder()
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteTypeFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteType))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteChoiceFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteChoice))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
userID, err := strconv.Atoi(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user ID: %w", values[0], err)
}
u, err := app.users.ByID(context.Background(), int64(userID))
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err)
}
return u, nil
}, new(models.User))
app.formDecoder = decoder
}
func (app *Application) Routes() http.Handler {
staticDir, _ := fs.Sub(ui.Files, "static")
staticData, ok := staticDir.(fs.ReadDirFS)
if !ok {
app.errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS")
}
fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit)
router := chi.NewRouter()
router.Use(middleware.RealIP)
router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: app.infoLog}))
router.Use(middleware.Recoverer)
router.Use(handlers.SecureHeaders)
router.NotFound(func(w http.ResponseWriter, _ *http.Request) { handlers.NotFound(w) })
router.Get(
"/",
http.RedirectHandler("/motions/", http.StatusMovedPermanently).ServeHTTP,
)
router.Get(
"/favicon.ico",
http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently).ServeHTTP,
)
router.Get("/static/*", http.StripPrefix("/static", fileServer).ServeHTTP)
userMiddleware := handlers.NewUserMiddleware(app.users, app.errorLog)
flashes := handlers.NewFlashes(app.sessionManager)
handlerParams := handlers.CommonParams{
Flashes: flashes,
TemplateCache: app.templateCache,
FormDecoder: app.formDecoder,
}
motionHandler := handlers.NewMotionHandler(app.motions, app.mailNotifier, app.jobScheduler, handlerParams)
voteHandler := handlers.NewVoteHandler(app.motions, app.users, app.mailNotifier, handlerParams)
userHandler := handlers.NewUserHandler(app.users, handlerParams)
router.Group(func(r chi.Router) {
r.Use(app.sessionManager.LoadAndSave, userMiddleware.TryAuthenticate)
r.Get("/motions/", motionHandler.List)
r.Get("/motions/{tag}", motionHandler.Details)
r.Group(func(r chi.Router) {
r.Use(userMiddleware.UserCanEditVote, handlers.NoSurf)
r.Get("/newmotion/", motionHandler.NewForm)
r.Post("/newmotion/", motionHandler.New)
r.Get("/motions/{tag}/edit", motionHandler.EditForm)
r.Post("/motions/{tag}/edit", motionHandler.Edit)
r.Get("/motions/{tag}/withdraw", motionHandler.WithdrawForm)
r.Post("/motions/{tag}/withdraw", motionHandler.Withdraw)
})
r.Group(func(r chi.Router) {
r.Use(userMiddleware.UserCanVote, handlers.NoSurf)
r.Get("/vote/{tag}/{choice}", voteHandler.VoteForm)
r.Post("/vote/{tag}/{choice}", voteHandler.Vote)
r.Get("/proxy/{tag}", voteHandler.ProxyVoteForm)
r.Post("/proxy/{tag}", voteHandler.ProxyVote)
})
r.Group(func(r chi.Router) {
r.Use(userMiddleware.CanManageUsers, handlers.NoSurf)
r.Get("/users/", userHandler.List)
r.Get("/new-user/", userHandler.CreateForm)
r.Post("/new-user/", userHandler.Create)
r.Route("/users/{id}", func(r chi.Router) {
r.Get("/", userHandler.EditForm)
r.Post("/", userHandler.Edit)
r.Get("/add-mail", userHandler.AddEmailForm)
r.Post("/add-mail", userHandler.AddEmail)
r.Get("/mail/{address}/delete", userHandler.DeleteEmailForm)
r.Post("/mail/{address}/delete", userHandler.DeleteEmail)
r.Get("/delete", userHandler.DeleteForm)
r.Post("/delete", userHandler.Delete)
})
r.Get("/voters/", userHandler.ChangeVotersForm)
r.Post("/voters/", userHandler.ChangeVoters)
})
})
router.Method(http.MethodGet, "/health", handlers.NewHealthCheck(app.mailNotifier, app.motions))
return router
}

302
internal/forms/forms.go Normal file
View file

@ -0,0 +1,302 @@
/*
Copyright 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 forms
import (
"fmt"
"strings"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/validator"
)
const (
minimumTitleLength = 3
maximumTitleLength = 200
minimumContentLength = 3
maximumContentLength = 8000
minimumJustificationLen = 3
threeDays = 3
oneWeek = 7
twoWeeks = 14
threeWeeks = 28
)
type Form interface {
Validate() error
Normalize()
}
func validateUserName(v *validator.Validator, name string, field string) {
v.CheckField(validator.NotBlank(name), field, "This field cannot be blank")
}
func validateEmailAddress(v *validator.Validator, address string, field string) {
v.CheckField(validator.NotBlank(address), field, "This field cannot be blank")
v.CheckField(validator.IsEmail(address), field, "This field must be an email address")
}
func validateReasoning(v *validator.Validator, reasoning string, field string) {
v.CheckField(validator.NotBlank(reasoning), field, "This field cannot be blank")
v.CheckField(
validator.MinChars(reasoning, minimumJustificationLen),
field,
fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen),
)
}
func validateMotionTitle(v *validator.Validator, title string, field string) {
v.CheckField(
validator.NotBlank(title),
field,
"This field cannot be blank",
)
v.CheckField(
validator.MinChars(title, minimumTitleLength),
field,
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
v.CheckField(
validator.MaxChars(title, maximumTitleLength),
field,
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
}
func validateMotionContent(v *validator.Validator, content string, field string) {
v.CheckField(
validator.NotBlank(content),
field,
"This field cannot be blank",
)
v.CheckField(
validator.MinChars(content, minimumContentLength),
field,
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
v.CheckField(
validator.MaxChars(content, maximumContentLength),
field,
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
}
type EditMotionForm struct {
Title string `form:"title"`
Content string `form:"content"`
Type *models.VoteType `form:"type"`
Due int `form:"due"`
validator.Validator `form:"-"`
}
func (f *EditMotionForm) Validate() error {
validateMotionTitle(&f.Validator, f.Title, "title")
validateMotionContent(&f.Validator, f.Content, "content")
f.CheckField(validator.NotNil(f.Type), "type", "You must choose a valid vote type")
f.CheckField(validator.PermittedInt(
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
)
return nil
}
func (f *EditMotionForm) Normalize() {
f.Title = strings.TrimSpace(f.Title)
f.Content = strings.TrimSpace(f.Content)
}
type DirectVoteForm struct {
Choice *models.VoteChoice
}
func (f *DirectVoteForm) Validate() error { return nil }
func (f *DirectVoteForm) Normalize() {}
type ProxyVoteForm struct {
Voter *models.User `form:"voter"`
Choice *models.VoteChoice `form:"choice"`
Justification string `form:"justification"`
Voters []*models.User `form:"-"`
validator.Validator `form:"-"`
}
func (f *ProxyVoteForm) Validate() error {
f.CheckField(validator.NotNil(f.Voter), "voter", "Please choose a valid voter")
validateReasoning(&f.Validator, f.Justification, "justification")
f.CheckField(validator.NotNil(f.Choice), "choice", "A choice has to be made")
return nil
}
func (f *ProxyVoteForm) Normalize() {
f.Justification = strings.TrimSpace(f.Justification)
}
type EditUserForm struct {
User *models.User `form:"-"`
MailAddresses []string `form:"-"`
AllRoles []*models.Role `form:"-"`
Name string `form:"name"`
Roles []string `form:"roles"`
ReminderMail string `form:"reminder_mail"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *EditUserForm) Validate() error {
addresses, err := f.User.EmailAddresses()
if err != nil {
return fmt.Errorf("error while validating form: %w", err)
}
validateUserName(&f.Validator, f.Name, "name")
f.CheckField(
validator.NotBlank(f.ReminderMail),
"reminder_mail",
"Must choose a reminder mail address",
)
f.CheckField(
validator.PermittedString(f.ReminderMail, addresses...),
"reminder_mail",
"Reminder mail must be one of the user's email addresses",
)
allRoleNames := make([]string, len(f.AllRoles))
for i := range f.AllRoles {
allRoleNames[i] = f.AllRoles[i].Name
}
f.CheckField(
validator.PermittedStringSet(f.Roles, allRoleNames),
"roles",
fmt.Sprintf("Roles must only contain values from %s", strings.Join(allRoleNames, ", ")),
)
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *EditUserForm) Normalize() {
f.Name = strings.TrimSpace(f.Name)
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
func (f *EditUserForm) UpdateUser(edit *models.User) {
edit.Reminder.String = f.ReminderMail
edit.Name = f.Name
}
type NewUserForm struct {
Name string `form:"name"`
EmailAddress string `form:"email_address"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *NewUserForm) Validate() error {
validateUserName(&f.Validator, f.Name, "name")
validateEmailAddress(&f.Validator, f.EmailAddress, "email_address")
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *NewUserForm) Normalize() {
f.Name = strings.TrimSpace(f.Name)
f.EmailAddress = strings.TrimSpace(f.EmailAddress)
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
func (f *NewUserForm) FillUser() *models.User {
return &models.User{Name: f.Name}
}
type DeleteUserForm struct {
User *models.User `form:"user"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *DeleteUserForm) Validate() error {
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *DeleteUserForm) Normalize() {
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
type AddEmailForm struct {
EmailAddress string `form:"email_address"`
Reasoning string `form:"reasoning"`
User *models.User `form:"-"`
EmailAddresses []string `form:"-"`
validator.Validator `form:"-"`
}
func (f *AddEmailForm) Validate() error {
validateEmailAddress(&f.Validator, f.EmailAddress, "email_address")
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *AddEmailForm) Normalize() {
f.EmailAddress = strings.TrimSpace(f.EmailAddress)
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
type DeleteEmailForm struct {
EmailAddress string `form:"-"`
User *models.User `form:"-"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *DeleteEmailForm) Validate() error {
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *DeleteEmailForm) Normalize() {
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
type ChooseVoterForm struct {
Reasoning string `form:"reasoning"`
VoterIDs []int64 `form:"voters"`
Users []*models.User
validator.Validator `form:"-"`
}
func (f *ChooseVoterForm) Validate() error {
validateReasoning(&f.Validator, f.Reasoning, "reasoning")
return nil
}
func (f *ChooseVoterForm) Normalize() {
}

View file

@ -0,0 +1,71 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"context"
"encoding/gob"
"github.com/alexedwards/scs/v2"
)
type FlashVariant string
const (
flashWarning FlashVariant = "warning"
flashInfo FlashVariant = "info"
flashSuccess FlashVariant = "success"
)
type FlashMessage struct {
Variant FlashVariant
Title string
Message string
}
type FlashHandler struct {
sessionManager *scs.SessionManager
}
func NewFlashes(manager *scs.SessionManager) *FlashHandler {
gob.Register([]FlashMessage{})
return &FlashHandler{sessionManager: manager}
}
func (f *FlashHandler) addFlash(ctx context.Context, message *FlashMessage) {
flashes := f.flashes(ctx)
flashes = append(flashes, *message)
f.sessionManager.Put(ctx, "flashes", flashes)
}
func (f *FlashHandler) flashes(ctx context.Context) []FlashMessage {
flashInstance := f.sessionManager.Pop(ctx, "flashes")
if flashInstance != nil {
flashes, ok := flashInstance.([]FlashMessage)
if ok {
return flashes
}
}
return make([]FlashMessage, 0)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"database/sql"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"path"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/lestrrat-go/tcputil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "github.com/mattn/go-sqlite3"
"git.cacert.org/cacert-boardvoting/internal/notifications"
"git.cacert.org/cacert-boardvoting/internal/models"
)
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 TestHealthCheck_ServeHTTP(t *testing.T) {
port := StartTestTCPServer(t)
t.Run("check with valid DB", func(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/health", nil)
require.NoError(t, err)
testDB := prepareTestDb(t)
notifier := notifications.NewMailNotifier(&notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: port,
SMTPTimeOut: 1 * time.Second,
})
hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB})
hc.ServeHTTP(rr, r)
rs := rr.Result()
defer func(Body io.Closer) { _ = Body.Close() }(rs.Body)
assert.Equal(t, http.StatusOK, rs.StatusCode)
})
t.Run("check with broken DB", func(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/health", nil)
require.NoError(t, err)
testDir := t.TempDir()
db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite"))
require.NoError(t, err)
testDB := sqlx.NewDb(db, "sqlite3")
_ = db.Close()
notifier := notifications.NewMailNotifier(&notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: port,
SMTPTimeOut: 1 * time.Second,
})
hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB})
hc.ServeHTTP(rr, r)
rs := rr.Result()
defer func(Body io.Closer) { _ = Body.Close() }(rs.Body)
assert.Equal(t, http.StatusInternalServerError, rs.StatusCode)
})
}

View file

@ -0,0 +1,236 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/justinas/nosurf"
"git.cacert.org/cacert-boardvoting/internal/models"
)
type contextKey int
const (
ctxUser contextKey = iota
ctxAuthenticatedCert
)
func SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self' data:")
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Strict-Transport-Security", "max-age=63072000")
next.ServeHTTP(w, r)
})
}
type UserMiddleware struct {
users *models.UserModel
errorLog *log.Logger
}
func NewUserMiddleware(users *models.UserModel, errorLog *log.Logger) *UserMiddleware {
return &UserMiddleware{users: users, errorLog: errorLog}
}
func (m *UserMiddleware) AuthenticateRequest(
ctx context.Context,
r *http.Request,
) (*models.User, *x509.Certificate, error) {
if r.TLS == nil {
return nil, nil, nil
}
if len(r.TLS.PeerCertificates) < 1 {
return nil, nil, nil
}
clientCert := r.TLS.PeerCertificates[0]
allowClientAuth := false
for _, eku := range clientCert.ExtKeyUsage {
if eku == x509.ExtKeyUsageClientAuth {
allowClientAuth = true
break
}
}
if !allowClientAuth {
// presented certificate is not valid for client authentication
return nil, nil, nil
}
emails := clientCert.EmailAddresses
user, err := m.users.ByEmails(ctx, emails)
if err != nil {
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
}
return user, clientCert, nil
}
func (m *UserMiddleware) TryAuthenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, cert, err := m.AuthenticateRequest(r.Context(), r)
if err != nil {
panic(err)
}
if user == nil {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Cache-Control", "no-store")
certContext := context.WithValue(r.Context(), ctxAuthenticatedCert, cert)
userContext := context.WithValue(certContext, ctxUser, user)
next.ServeHTTP(w, r.WithContext(userContext))
})
}
func (m *UserMiddleware) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) {
user, err := getUser(r)
if err != nil {
return false, false, err
}
if user == nil {
return false, false, nil
}
roleMatched, err := user.HasRole(roles...)
if err != nil {
return false, true, fmt.Errorf("could not determin user role assignment: %w", err)
}
if !roleMatched {
roleNames := make([]string, len(roles))
for idx := range roles {
roleNames[idx] = string(roles[idx])
}
m.errorLog.Printf(
"user %s does not have any of the required role(s) %s assigned",
user.Name,
strings.Join(roleNames, ", "),
)
return false, true, nil
}
return true, true, nil
}
func (m *UserMiddleware) requireRole(next http.Handler, roles ...models.RoleName) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hasRole, hasUser, err := m.HasRole(r, roles...)
if err != nil {
panic(err)
}
if !hasUser {
ClientError(w, http.StatusUnauthorized)
return
}
if !hasRole {
ClientError(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (m *UserMiddleware) UserCanVote(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleVoter)
}
func (m *UserMiddleware) UserCanEditVote(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleVoter)
}
func (m *UserMiddleware) CanManageUsers(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleSecretary, models.RoleAdmin)
}
func NoSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
Path: "/",
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return csrfHandler
}
func getUser(r *http.Request) (*models.User, error) {
user := r.Context().Value(ctxUser)
if user == nil {
return nil, errors.New("no user in context")
}
result, ok := user.(*models.User)
if !ok {
return nil, fmt.Errorf("%v is not a user", user)
}
return result, nil
}
func getPEMClientCert(r *http.Request) (string, error) {
cert := r.Context().Value(ctxAuthenticatedCert)
authenticatedCertificate, ok := cert.(*x509.Certificate)
if !ok {
return "", errors.New("could not handle certificate as x509.Certificate")
}
clientCertPEM := bytes.NewBuffer(make([]byte, 0))
err := pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw})
if err != nil {
return "", fmt.Errorf("error encoding client certificate: %w", err)
}
return clientCertPEM.String(), nil
}

View file

@ -0,0 +1,176 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
"log"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.cacert.org/cacert-boardvoting/internal"
"git.cacert.org/cacert-boardvoting/internal/models"
)
func prepareTestDb(t *testing.T) *sqlx.DB {
t.Helper()
testDir := t.TempDir()
db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite"))
require.NoError(t, err)
dbx := sqlx.NewDb(db, "sqlite3")
return dbx
}
func Test_secureHeaders(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("OK"))
})
SecureHeaders(next).ServeHTTP(rr, r)
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
assert.Equal(t, "default-src 'self'; font-src 'self' data:", rs.Header.Get("Content-Security-Policy"))
assert.Equal(t, "origin-when-cross-origin", rs.Header.Get("Referrer-Policy"))
assert.Equal(t, "nosniff", rs.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "deny", rs.Header.Get("X-Frame-Options"))
assert.Equal(t, "0", rs.Header.Get("X-XSS-Protection"))
assert.Equal(t, "max-age=63072000", rs.Header.Get("Strict-Transport-Security"))
}
func TestApplication_tryAuthenticate(t *testing.T) {
db := prepareTestDb(t)
err := internal.InitializeDb(db.DB, log.New(os.Stdout, "", log.LstdFlags))
require.NoError(t, err)
users := &models.UserModel{DB: db}
_, err = users.Create(
context.Background(),
&models.CreateUserParams{
Admin: &models.User{
Name: "Admin",
Reminder: sql.NullString{String: "admin@example.org", Valid: true},
},
Name: "Test User",
Reminder: "test@example.org",
Emails: []string{"test@example.org"},
Reasoning: "Test data",
},
)
var nextCtx context.Context
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
nextCtx = r.Context()
})
require.NoError(t, err)
mw := UserMiddleware{
users: &models.UserModel{DB: db},
}
t.Run("without TLS", func(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
assert.Equal(t, http.StatusOK, rs.StatusCode)
assert.Nil(t, nextCtx.Value(ctxUser))
})
t.Run("with TLS no certificate", func(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{}}
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
assert.Equal(t, http.StatusOK, rs.StatusCode)
assert.Nil(t, nextCtx.Value(ctxUser))
})
t.Run("with TLS matching user", func(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{
EmailAddresses: []string{"test@example.org"},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}}}
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
assert.Equal(t, http.StatusOK, rs.StatusCode)
user := nextCtx.Value(ctxUser)
assert.NotNil(t, user)
userInstance, ok := user.(*models.User)
assert.True(t, ok)
assert.Equal(t, userInstance.Name, "Test User")
})
}

View file

@ -0,0 +1,126 @@
/*
Copyright CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
"github.com/Masterminds/sprig/v3"
"git.cacert.org/cacert-boardvoting/internal/forms"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/ui"
)
func checkRole(v *models.User, roles ...models.RoleName) (bool, error) {
if v == nil {
return false, nil
}
hasRole, err := v.HasRole(roles...)
if err != nil {
return false, fmt.Errorf("could not determine user roles: %w", err)
}
return hasRole, nil
}
type TemplateData struct {
PrevPage string
NextPage string
Motion *models.Motion
Motions []*models.Motion
User *models.User
Users []*models.User
Request *http.Request
Flashes []FlashMessage
Form forms.Form
ActiveNav topLevelNavItem
ActiveSubNav subLevelNavItem
CSRFToken string
}
type TemplateCache struct {
cache map[string]*template.Template
}
func NewTemplateCache() (*TemplateCache, error) {
cache := map[string]*template.Template{}
pages, err := fs.Glob(ui.Files, "html/pages/*.html")
if err != nil {
return nil, fmt.Errorf("could not find page templates: %w", err)
}
funcMaps := sprig.FuncMap()
funcMaps["nl2br"] = func(text string) template.HTML {
// #nosec G203 input is sanitized
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
}
funcMaps["canManageUsers"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleSecretary, models.RoleAdmin)
}
funcMaps["canVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
for _, page := range pages {
name := filepath.Base(page)
ts, err := template.New("").Funcs(funcMaps).ParseFS(
ui.Files,
"html/base.html",
"html/partials/*.html",
page,
)
if err != nil {
return nil, fmt.Errorf("could not parse base template: %w", err)
}
cache[name] = ts
}
return &TemplateCache{cache: cache}, nil
}
func (c *TemplateCache) render(w http.ResponseWriter, status int, page string, data *TemplateData) {
ts, ok := c.cache[page]
if !ok {
panic(fmt.Sprintf("the template %s does not exist", page))
}
buf := new(bytes.Buffer)
err := ts.ExecuteTemplate(buf, "base", data)
if err != nil {
panic(err)
}
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}

View file

@ -0,0 +1,139 @@
/*
Copyright 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 jobs
import (
"context"
"log"
"os"
"time"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
type CloseDecisionsJob struct {
timer *time.Timer
infoLog *log.Logger
errorLog *log.Logger
decisions *models.MotionModel
notifier *notifications.MailNotifier
}
func (c *CloseDecisionsJob) Identifier() JobIdentifier {
return JobIDCloseDecisions
}
func (c *CloseDecisionsJob) Schedule() {
var (
nextDue *time.Time
err error
)
ctx := context.Background()
nextDue, err = c.decisions.NextPendingDue(ctx, time.Now().UTC())
if err != nil {
c.errorLog.Printf("could not get next pending due date")
c.Stop()
return
}
if nextDue == nil {
c.infoLog.Printf("no next planned execution of CloseDecisionsJob")
c.Stop()
return
}
c.infoLog.Printf("scheduling CloseDecisionsJob for %s", nextDue)
when := time.Until(nextDue.Add(time.Second))
if c.timer == nil {
c.timer = time.AfterFunc(when, c.Run)
return
}
c.timer.Reset(when)
}
func (c *CloseDecisionsJob) Run() {
c.infoLog.Printf("running CloseDecisionsJob")
defer func(c *CloseDecisionsJob) { c.Schedule() }(c)
c.RunExpired()
}
func (c *CloseDecisionsJob) RunExpired() {
results, err := c.decisions.CloseDecisions(context.Background())
if err != nil {
c.errorLog.Printf("closing decisions failed: %v", err)
}
for _, res := range results {
c.infoLog.Printf(
"decision %s closed with result %s: reasoning '%s'",
res.Tag,
res.Status,
res.Reasoning,
)
c.notifier.Notify(&notifications.ClosedDecisionNotification{Decision: res})
}
}
func (c *CloseDecisionsJob) Stop() {
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
type CloseDecisionsOption func(job *CloseDecisionsJob)
func NewCloseDecisionsJob(
decisions *models.MotionModel,
mailNotifier *notifications.MailNotifier,
opts ...CloseDecisionsOption,
) Job {
j := &CloseDecisionsJob{
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
decisions: decisions,
notifier: mailNotifier,
}
for _, o := range opts {
o(j)
}
j.RunExpired()
return j
}
func CloseDecisionsLog(infoLog, errorLog *log.Logger) CloseDecisionsOption {
return func(j *CloseDecisionsJob) {
j.infoLog = infoLog
j.errorLog = errorLog
}
}

19
internal/jobs/doc.go Normal file
View file

@ -0,0 +1,19 @@
/*
Copyright 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 jobs is providing implementations for background jobs.
package jobs

34
internal/jobs/jobs.go Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright 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 jobs
const hoursInDay = 24
type JobIdentifier int
const (
JobIDCloseDecisions JobIdentifier = iota
JobIDRemindVoters
)
type Job interface {
Schedule()
Run()
Stop()
Identifier() JobIdentifier
}

View file

@ -0,0 +1,161 @@
/*
Copyright 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 jobs
import (
"context"
"log"
"os"
"time"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
type RemindVotersJob struct {
infoLog, errorLog *log.Logger
timer *time.Timer
voters *models.UserModel
decisions *models.MotionModel
notifier *notifications.MailNotifier
}
func (r *RemindVotersJob) Identifier() JobIdentifier {
return JobIDRemindVoters
}
func (r *RemindVotersJob) Schedule() {
const reminderDays = 3
now := time.Now().UTC()
year, month, day := now.Date()
nextPotentialRun := time.Date(year, month, day+1, 0, 0, 0, 0, time.UTC)
relevantDue := nextPotentialRun.Add(reminderDays * hoursInDay * time.Hour)
due, err := r.decisions.NextPendingDue(context.Background(), relevantDue)
if err != nil {
r.errorLog.Printf("could not fetch next due date: %v", err)
}
if due == nil {
r.infoLog.Printf("no due motions after relevant due date %s, not scheduling ReminderJob", relevantDue)
return
}
remindNext := due.Add(-reminderDays * hoursInDay * time.Hour).UTC()
year, month, day = remindNext.Date()
potentialRun := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
if potentialRun.Before(time.Now().UTC()) {
r.infoLog.Printf("potential reminder time %s is in the past, not scheduling ReminderJob", potentialRun)
return
}
r.infoLog.Printf("scheduling RemindVotersJob for %s", potentialRun)
when := time.Until(potentialRun)
if r.timer != nil {
r.timer.Reset(when)
return
}
r.timer = time.AfterFunc(when, r.Run)
}
func (r *RemindVotersJob) Run() {
r.infoLog.Print("running RemindVotersJob")
defer func(r *RemindVotersJob) { r.Schedule() }(r)
var (
voters []*models.User
decisions []*models.Motion
err error
)
ctx := context.Background()
voters, err = r.voters.ReminderVoters(ctx)
if err != nil {
r.errorLog.Printf("problem getting voters: %v", err)
return
}
for _, voter := range voters {
v := voter
decisions, err = r.decisions.UnvotedForVoter(ctx, v)
if err != nil {
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
return
}
if len(decisions) > 0 {
r.notifier.Notify(&notifications.RemindVoterNotification{Voter: voter, Decisions: decisions})
}
}
}
func (r *RemindVotersJob) Stop() {
if r.timer != nil {
r.timer.Stop()
r.timer = nil
}
}
type RemindVotersOption func(job *RemindVotersJob)
func NewRemindVoters(
voters *models.UserModel,
decisions *models.MotionModel,
notifier *notifications.MailNotifier,
opts ...RemindVotersOption,
) Job {
j := &RemindVotersJob{
voters: voters,
decisions: decisions,
notifier: notifier,
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
}
for _, o := range opts {
o(j)
}
return j
}
func RemindVotersLog(infoLog, errorLog *log.Logger) RemindVotersOption {
return func(r *RemindVotersJob) {
r.infoLog = infoLog
r.errorLog = errorLog
}
}

View file

@ -0,0 +1,93 @@
/*
Copyright 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 jobs
import (
"log"
"os"
)
type JobScheduler struct {
infoLogger *log.Logger
errorLogger *log.Logger
jobs map[JobIdentifier]Job
rescheduleChannel chan JobIdentifier
quitChannel chan struct{}
}
type SchedulerOption func(scheduler *JobScheduler)
func NewJobScheduler(opts ...SchedulerOption) *JobScheduler {
rescheduleChannel := make(chan JobIdentifier, 1)
jobScheduler := &JobScheduler{
infoLogger: log.New(os.Stdout, "", 0),
errorLogger: log.New(os.Stderr, "", 0),
jobs: make(map[JobIdentifier]Job, 2),
rescheduleChannel: rescheduleChannel,
quitChannel: make(chan struct{}),
}
for _, o := range opts {
o(jobScheduler)
}
return jobScheduler
}
func SchedulerLog(infoLog, errorLog *log.Logger) SchedulerOption {
return func(s *JobScheduler) {
s.infoLogger = infoLog
s.errorLogger = errorLog
}
}
func (js *JobScheduler) Schedule() {
for _, job := range js.jobs {
job.Schedule()
}
for {
select {
case jobID := <-js.rescheduleChannel:
js.jobs[jobID].Schedule()
case <-js.quitChannel:
for _, job := range js.jobs {
job.Stop()
}
js.infoLogger.Print("stop job scheduler")
return
}
}
}
func (js *JobScheduler) AddJob(job Job) {
js.jobs[job.Identifier()] = job
}
func (js *JobScheduler) Quit() {
js.quitChannel <- struct{}{}
}
func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) {
for i := range jobIDs {
js.rescheduleChannel <- jobIDs[i]
}
}

23
internal/mailtemplates.go Normal file
View file

@ -0,0 +1,23 @@
/*
Copyright 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 internal
import "embed"
//go:embed "mailtemplates"
var MailTemplates embed.FS

View file

@ -0,0 +1,20 @@
Dear Board,
The motion with the identifier {{ .Data.Tag }} has been closed.
The reasoning for this result is: {{ .Data.Reasoning }}
{{ with .Data }}Motion:
{{ .Title}}
{{ .Content}}
Vote type: {{ .Type}}{{end}}
{{ with .Data.Sums }} Ayes: {{ .Ayes }}
Nayes: {{ .Nayes }}
Abstentions: {{ .Abstains }}
Percentage: {{ .Percent }}%{{ end }}
Kind regards,
the voting system.

View file

@ -0,0 +1,22 @@
Dear Board,
{{ .Data.Name }} has made the following motion:
{{ .Data.Title }}
{{ wrap 76 .Data.Content }}
Vote type: {{ .Data.Type }}
Voting will close {{ .Data.Due }}
To vote please choose:
Aye: {{ .BaseURL }}{{ .Data.VoteURL }}/aye
Naye: {{ .BaseURL }}{{ .Data.VoteURL }}/naye
Abstain: {{ .BaseURL }}{{ .Data.VoteURL }}/abstain
To see all your pending votes: {{ .BaseURL }}{{ .Data.UnvotedURL }}
Kind regards,
the voting system

View file

@ -0,0 +1,10 @@
Dear Board,
{{ .Data.Name }} has just voted {{ .Data.Choice }} on motion {{ .Data.Tag }}.
Motion:
{{ .Data.Title }}
{{ .Data.Content }}
Kind regards,
the vote system

View file

@ -0,0 +1,13 @@
Dear Board,
{{ .Data.Name }} has just registered a proxy vote of {{ .Data.Choice }} for {{ .Data.Voter }} on motion {{ .Data.Tag }}.
The justification for this was:
{{ .Data.Justification }}
Motion:
{{ .Data.Title }}
{{ .Data.Content }}
Kind regards,
the vote system

View file

@ -0,0 +1,15 @@
{{ $baseurl := .BaseURL }}
Dear {{ .Name }},
You have not voted in the following CAcert Board vote(s)/motion(s):
{{ range .Decisions -}}
{{ .VoteType }} {{ .Tag }} {{ .Title }}
Due: {{ .Due }}
{{ $baseurl }}/motions/{{ .Tag }}
{{ end }}
To view all your outstanding motions: {{ $baseurl }}/motions/?unvoted=1
Kind regards,
the vote system

View file

@ -0,0 +1,26 @@
Dear Board,
{{ .Data.Name }} has modified motion {{ .Data.Tag }} to the following:
{{ .Data.Title }}
{{ wrap 76 .Data.Content }}
Vote type: {{ .Data.Type }}
Voting will close {{ .Data.Due }}
To vote please choose:
Aye: {{ .BaseURL }}{{ .Data.VoteURL }}/aye
Naye: {{ .BaseURL }}{{ .Data.VoteURL }}/naye
Abstain: {{ .BaseURL }}{{ .Data.VoteURL }}/abstain
Please be aware, that if you have voted already your vote is still
registered and valid. If this modification has an impact on how you wish to
vote, you are responsible for voting again.
To see all your pending votes: {{ .BaseURL }}{{ .Data.UnvotedURL }}
Kind regards,
the voting system

View file

@ -0,0 +1,10 @@
Dear Board,
{{ .Data.Name }} has withdrawn the motion {{ .Data.Tag }} that was as follows:
{{ .Data.Title }}
{{ wrap 76 .Data.Content }}
Kind regards,
the voting system

78
internal/migrations.go Normal file
View file

@ -0,0 +1,78 @@
/*
Copyright 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 internal
import (
"database/sql"
"embed"
"errors"
"fmt"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/johejo/golang-migrate-extra/source/iofs"
)
//go:embed "migrations"
var Migrations embed.FS
type migrateLogger struct {
log *log.Logger
verbose bool
}
func (m migrateLogger) Printf(format string, v ...interface{}) {
m.log.Printf(format, v...)
}
func (m migrateLogger) Verbose() bool {
return m.verbose
}
func InitializeDb(db *sql.DB, log *log.Logger) error {
source, err := iofs.New(Migrations, "migrations")
if err != nil {
return fmt.Errorf("could not create migration source: %w", err)
}
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("could not create migration driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
if err != nil {
return fmt.Errorf("could not create migration instance: %w", err)
}
m.Log = &migrateLogger{log, true}
err = m.Up()
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("running database migration failed: %w", err)
}
log.Print("no database migrations required")
} else {
log.Print("applied database migrations")
}
return nil
}

View file

@ -0,0 +1,5 @@
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE votes;
DROP TABLE decisions;
DROP TABLE emails;
DROP TABLE voters;

View file

@ -0,0 +1,5 @@
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS votes (decision INT4, voter INT4, vote INT4, voted DATETIME, notes text default '');
CREATE TABLE IF NOT EXISTS emails (voter INT4, address VARCHAR(255));
CREATE TABLE IF NOT EXISTS voters (id INTEGER PRIMARY KEY, name VARCHAR(255), enabled INTEGER default 0, reminder VARCHAR(255));
CREATE TABLE IF NOT EXISTS decisions (id INTEGER PRIMARY KEY, proposed DATETIME, proponent INTEGER, title VARCHAR(255), content TEXT, quorum INTEGER, majority INTEGER, status INTEGER, due DATETIME, modified DATETIME, tag varchar(255), votetype INT4 DEFAULT 0 NOT NULL);

View file

@ -0,0 +1,3 @@
-- SQL section 'Down' is executed when this migration is rolled back
-- There is no useful backward migration

View file

@ -0,0 +1 @@
-- SQL in section 'Up' is executed when this migration is applied -- Peter Yuill has a duplicate entry in the voters table he has ids 17 and 31 UPDATE votes SET voter=17 WHERE voter=31; UPDATE decisions SET proponent=17 WHERE proponent=31; DELETE FROM emails WHERE voter=31; DELETE FROM voters WHERE id=31;

View file

@ -0,0 +1,83 @@
-- SQL section 'Down' is executed when this migration is rolled back
CREATE TABLE votes_orig (
decision INT4,
voter INT4,
vote INT4,
voted DATETIME,
notes TEXT DEFAULT ''
);
INSERT INTO votes_orig (decision, voter, vote, voted, notes)
SELECT
decision,
voter,
vote,
voted,
notes
FROM votes;
DROP TABLE votes;
ALTER TABLE votes_orig
RENAME TO votes;
CREATE TABLE decisions_orig (
id INTEGER PRIMARY KEY,
proposed DATETIME,
proponent INTEGER,
title VARCHAR(255),
content TEXT,
quorum INTEGER,
majority INTEGER,
status INTEGER,
due DATETIME,
modified DATETIME,
tag VARCHAR(255),
votetype INT4 DEFAULT 0 NOT NULL
);
INSERT INTO decisions_orig (id, proposed, proponent, title, content, status, due, modified, tag, votetype)
SELECT
id,
proposed,
proponent,
title,
content,
status,
due,
modified,
tag,
votetype
FROM
decisions;
DROP INDEX decisions_proposed_idx;
DROP TABLE decisions;
ALTER TABLE decisions_orig
RENAME TO decisions;
CREATE TABLE emails_orig (
voter INT4,
address VARCHAR(255)
);
INSERT INTO emails_orig (voter, address)
SELECT
voter,
address
FROM emails;
DROP TABLE emails;
ALTER TABLE emails_orig
RENAME TO emails;
CREATE TABLE voters_orig (
id INTEGER PRIMARY KEY,
name VARCHAR(255),
enabled INTEGER DEFAULT 0,
reminder VARCHAR(255)
);
INSERT INTO voters_orig (id, name, enabled, reminder)
SELECT
id,
name,
enabled,
reminder
FROM voters;
DROP TABLE voters;
ALTER TABLE voters_orig
RENAME TO voters;

View file

@ -0,0 +1,83 @@
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE voters_new (
id INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL,
reminder VARCHAR(255)
);
INSERT INTO voters_new (id, name, enabled, reminder)
SELECT
id,
name,
enabled,
reminder
FROM voters;
DROP TABLE voters;
ALTER TABLE voters_new
RENAME TO voters;
CREATE TABLE emails_new (
voter INTEGER NOT NULL REFERENCES voters (id),
address VARCHAR(255) UNIQUE NOT NULL
);
INSERT INTO emails_new (voter, address)
SELECT
voter,
address
FROM emails;
DROP TABLE emails;
ALTER TABLE emails_new
RENAME TO emails;
CREATE TABLE decisions_new (
id INTEGER PRIMARY KEY,
proposed DATETIME NOT NULL,
proponent INTEGER NOT NULL REFERENCES voters (id),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
status INTEGER NOT NULL CHECK (status IN (-2, -1, 0, 1)),
due DATETIME NOT NULL,
modified DATETIME NOT NULL,
tag VARCHAR(255) UNIQUE NOT NULL,
votetype INTEGER DEFAULT 0 NOT NULL CHECK (votetype IN (0, 1))
);
INSERT INTO decisions_new (
id, proposed, proponent, title, content, status, due, modified, tag, votetype
)
SELECT
id,
proposed,
proponent,
title,
content,
status,
due,
modified,
tag,
votetype
FROM decisions;
DROP TABLE decisions;
ALTER TABLE decisions_new
RENAME TO decisions;
CREATE INDEX decisions_proposed_idx
ON decisions (proposed);
CREATE TABLE votes_new (
decision INTEGER REFERENCES decisions (id),
voter INTEGER REFERENCES voters (id),
vote INTEGER NOT NULL CHECK (vote IN (-1, 0, 1)),
voted DATETIME NOT NULL,
notes TEXT NOT NULL DEFAULT '',
PRIMARY KEY (decision, voter)
);
INSERT INTO votes_new (decision, voter, vote, voted, notes)
SELECT
decision,
voter,
vote,
voted,
notes
FROM votes;
DROP TABLE votes;
ALTER TABLE votes_new
RENAME TO votes;

View file

@ -0,0 +1,2 @@
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE user_roles;

View file

@ -0,0 +1,13 @@
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE user_roles
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
voter_id VARCHAR(255) NOT NULL REFERENCES voters (id),
role VARCHAR(8) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (voter_id, role)
);
INSERT INTO user_roles (voter_id, role)
SELECT id, 'VOTER'
FROM voters
WHERE enabled = true;

View file

@ -0,0 +1 @@
-- no useful backward migration

View file

@ -0,0 +1,2 @@
-- remove tables of other migration systems
DROP TABLE IF EXISTS gorp_migrations;

View file

@ -0,0 +1,3 @@
-- drop sessions table for server side session storage
DROP INDEX session_expiry_idx;
DROP TABLE sessions;

View file

@ -0,0 +1,8 @@
-- add sessions table for server side session storage
CREATE TABLE sessions
(
token char(43) PRIMARY KEY,
data BLOB NOT NULL,
expiry TIMESTAMP NOT NULL
);
CREATE INDEX session_expiry_idx ON sessions (expiry);

View file

@ -0,0 +1,16 @@
-- drop unique constraint for motion by specific voter from votes
CREATE TABLE votes_new
(
decision INTEGER NOT NULL,
voter INTEGER NOT NULL,
vote INTEGER NOT NULL,
voted DATETIME NOT NULL,
notes TEXT NOT NULL DEFAULT ''
);
INSERT INTO votes_new (decision, voter, vote, voted, notes)
SELECT decision, voter, vote, voted, notes
FROM votes;
DROP TABLE votes;
ALTER TABLE votes_new RENAME TO votes;

View file

@ -0,0 +1,24 @@
-- add constraints on votes table
CREATE TABLE votes_new
(
decision INTEGER NOT NULL REFERENCES decisions (id),
voter INTEGER NOT NULL REFERENCES voters (id),
vote INTEGER NOT NULL,
voted DATETIME NOT NULL,
notes TEXT NOT NULL DEFAULT '',
PRIMARY KEY (decision, voter)
);
INSERT INTO votes_new (decision, voter, vote, voted, notes)
SELECT decision,
voter,
vote,
voted,
notes
FROM votes
GROUP BY decision, voter
HAVING MAX(voted) = voted;
ALTER TABLE votes
RENAME TO votes_orig_with_duplicates;
ALTER TABLE votes_new
RENAME TO votes;

View file

@ -0,0 +1,15 @@
-- drop constraints from votes table
CREATE TABLE emails_new
(
voter INT4,
address VARCHAR(255)
);
INSERT INTO emails_new (voter, address)
SELECT voter, address
FROM emails;
DROP TABLE emails;
ALTER TABLE emails_new
RENAME TO emails;
DROP TABLE emails_backup;

View file

@ -0,0 +1,18 @@
-- add constraints on votes table
CREATE TABLE emails_new
(
id INTEGER PRIMARY KEY,
voter INTEGER NOT NULL REFERENCES voters (id),
address VARCHAR(255) NOT NULL UNIQUE,
reminder bool NOT NULL DEFAULT FALSE
);
INSERT INTO emails_new (voter, address, reminder)
SELECT emails.voter,
emails.address,
EXISTS(SELECT * FROM voters WHERE voters.reminder = emails.address AND voters.id = emails.voter)
FROM emails;
ALTER TABLE emails
RENAME TO emails_backup;
ALTER TABLE emails_new
RENAME TO emails;

View file

@ -0,0 +1,3 @@
-- add an audit table to track changes to users
DROP INDEX audit_change_idx;
DROP TABLE audit;

View file

@ -0,0 +1,18 @@
-- add an audit table to track changes to users
CREATE TABLE audit
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- copied authenticated user name to keep the information when the admin is locked/deleted later
user_name VARCHAR(255) NOT NULL,
-- copied authenticated user email address to keep the information when the admin is locked/deleted later
user_address VARCHAR(255) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- an enum (in code) value to specify the action. Stored as text to be flexible for future changes
change TEXT NOT NULL,
-- reasoning for the change specified by the user
reasoning TEXT NOT NULL,
-- additional information about the change in an application specific json format
details TEXT
);
CREATE INDEX audit_change_idx ON audit (change);

131
internal/models/audit.go Normal file
View file

@ -0,0 +1,131 @@
/*
Copyright 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 models
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
type AuditChange string
const (
AuditCreateUser AuditChange = "CREATE_USER"
AuditDeleteUser AuditChange = "DELETE_USER"
AuditEditUser AuditChange = "EDIT_USER"
AuditAddEmail AuditChange = "ADD_EMAIL"
AuditDeleteEmail AuditChange = "DELETE_EMAIL"
AuditChangeVoters AuditChange = "CHANGE_VOTERS"
)
type Audit struct {
ID int64 `db:"id"`
UserName string `db:"user_name"`
UserAddress string `db:"user_address"`
Created time.Time `db:"created"`
Change *AuditChange `db:"change"`
Reasoning string `db:"reasoning"`
Details string `db:"details"`
}
func AuditLog(ctx context.Context, tx *sqlx.Tx, user *User, change AuditChange, reasoning string, details any) error {
jsonDetails, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("could not transform details to JSON: %w", err)
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO audit (user_name, user_address, created, change, reasoning, details) VALUES (?, ?, ?, ?, ?, ?)`,
user.Name, user.Reminder, time.Now().UTC(), change, reasoning, string(jsonDetails),
)
if err != nil {
return errCouldNotExecuteQuery(err)
}
return nil
}
func emailChangeInfo(before, after []string) any {
return struct {
Before []string `json:"before"`
After []string `json:"after"`
}{Before: before, After: after}
}
func voterChangeInfo(before []string, after []string) any {
return struct {
Before []string `json:"before"`
After []string `json:"after"`
}{Before: before, After: after}
}
func userChangeInfo(before, after *User) any {
beforeRoleNames, beforeAddresses := before.rolesAndAddresses()
afterRoleNames, afterAddresses := after.rolesAndAddresses()
type userInfo struct {
Name string `json:"name"`
Roles []string `json:"roles"`
Addresses []string `json:"addresses"`
Reminder string `json:"reminder"`
}
details := struct {
Before userInfo `json:"before"`
After userInfo `json:"after"`
}{userInfo{
Name: before.Name,
Roles: beforeRoleNames,
Addresses: beforeAddresses,
Reminder: before.Reminder.String,
}, userInfo{
Name: after.Name,
Roles: afterRoleNames,
Addresses: afterAddresses,
Reminder: after.Reminder.String,
}}
return details
}
func userCreateInfo(u *User) any {
roleNames, addresses := u.rolesAndAddresses()
type userInfo struct {
Name string `json:"name"`
Roles []string `json:"roles"`
Addresses []string `json:"addresses"`
Reminder string `json:"reminder"`
}
details := struct {
Created userInfo `json:"created"`
}{Created: userInfo{
Name: u.Name,
Roles: roleNames,
Addresses: addresses,
Reminder: u.Reminder.String,
}}
return details
}

69
internal/models/models.go Normal file
View file

@ -0,0 +1,69 @@
/*
Copyright 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 models defines data models and database access types.
package models
import (
"fmt"
"time"
)
func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) {
const (
sqlite3TsStringFormat = "2006-01-02 15:04:05.999999999-07:00"
sqlite3ShortTsStringFormat = "2006-01-02 15:04:05"
)
var (
result time.Time
err error
)
if result, err = time.Parse(sqlite3TsStringFormat, timeStamp); err == nil {
result = result.UTC()
return &result, nil
}
if result, err = time.ParseInLocation(sqlite3ShortTsStringFormat, timeStamp, time.UTC); err == nil {
result = result.UTC()
return &result, nil
}
return nil, fmt.Errorf("could not parse timestamp: %w", err)
}
func errCouldNotExecuteQuery(err error) error {
return fmt.Errorf("could not execute query: %w", err)
}
func errCouldNotFetchRow(err error) error {
return fmt.Errorf("could not fetch row: %w", err)
}
func errCouldNotScanResult(err error) error {
return fmt.Errorf("could not scan result: %w", err)
}
func errCouldNotStartTransaction(err error) error {
return fmt.Errorf("could not start transaction: %w", err)
}
func errCouldNotCommitTransaction(err error) error {
return fmt.Errorf("could not commit transaction: %w", err)
}
func errCouldNotCreateInQuery(err error) error {
return fmt.Errorf("could not create query with IN clause: %w", err)
}

991
internal/models/motions.go Normal file
View file

@ -0,0 +1,991 @@
/*
Copyright 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 models
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
type VoteType struct {
label string
id uint8
}
var (
VoteTypeMotion = &VoteType{label: "motion", id: 0}
VoteTypeVeto = &VoteType{label: "veto", id: 1}
)
func (v *VoteType) String() string {
return v.label
}
func VoteTypeFromString(label string) (*VoteType, error) {
for _, vt := range []*VoteType{VoteTypeMotion, VoteTypeVeto} {
if strings.EqualFold(vt.label, label) {
return vt, nil
}
}
return nil, fmt.Errorf("unknown vote type %s", label)
}
func VoteTypeFromInt(id int64) (*VoteType, error) {
for _, vt := range []*VoteType{VoteTypeMotion, VoteTypeVeto} {
if int64(vt.id) == id {
return vt, nil
}
}
return nil, fmt.Errorf("unknown vote type id %d", id)
}
func (v *VoteType) Scan(src any) error {
value, ok := src.(int64)
if !ok {
return fmt.Errorf("could not cast %v of %T to uint8", src, src)
}
vt, err := VoteTypeFromInt(value)
if err != nil {
return err
}
*v = *vt
return nil
}
func (v *VoteType) Value() (driver.Value, error) {
return int64(v.id), nil
}
func (v *VoteType) QuorumAndMajority() (int, float32) {
const (
majorityDefault = 0.99
majorityMotion = 0.50
quorumDefault = 1
quorumMotion = 3
)
if v == VoteTypeMotion {
return quorumMotion, majorityMotion
}
return quorumDefault, majorityDefault
}
type VoteStatus struct {
Label string
ID int8
}
var (
voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
)
func VoteStatusFromInt(id int64) (*VoteStatus, error) {
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} {
if int64(vs.ID) == id {
return vs, nil
}
}
return nil, fmt.Errorf("unknown vote status id %d", id)
}
func (v *VoteStatus) String() string {
return v.Label
}
func (v *VoteStatus) Scan(src any) error {
value, ok := src.(int64)
if !ok {
return fmt.Errorf("could not cast %v of %T to uint8", src, src)
}
vs, err := VoteStatusFromInt(value)
if err != nil {
return err
}
*v = *vs
return nil
}
func (v *VoteStatus) Value() (driver.Value, error) {
return int64(v.ID), nil
}
type VoteChoice struct {
Label string
ID int8
}
var (
VoteAye = &VoteChoice{Label: "aye", ID: 1}
VoteNaye = &VoteChoice{Label: "naye", ID: -1}
VoteAbstain = &VoteChoice{Label: "abstain", ID: 0}
)
func VoteChoiceFromString(label string) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if strings.EqualFold(vc.Label, label) {
return vc, nil
}
}
return nil, fmt.Errorf("unknown vote choice %s", label)
}
func VoteChoiceFromInt(id int64) (*VoteChoice, error) {
for _, vc := range []*VoteChoice{VoteAye, VoteNaye, VoteAbstain} {
if int64(vc.ID) == id {
return vc, nil
}
}
return nil, fmt.Errorf("unknown vote type id %d", id)
}
func (v *VoteChoice) String() string {
return v.Label
}
func (v *VoteChoice) Scan(src any) error {
value, ok := src.(int64)
if !ok {
return fmt.Errorf("could not cast %v of %T to uint8", src, src)
}
vc, err := VoteChoiceFromInt(value)
if err != nil {
return err
}
*v = *vc
return nil
}
func (v *VoteChoice) Value() (driver.Value, error) {
return int64(v.ID), nil
}
func (v *VoteChoice) Equal(other *VoteChoice) bool {
return v.ID == other.ID
}
type VoteSums struct {
Ayes, Nayes, 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 Motion struct {
ID int64 `db:"id"`
Proposed time.Time `db:"proposed"`
Proponent int64 `db:"proponent"`
Proposer string `db:"proposer"`
Title string `db:"title"`
Content string `db:"content"`
Status *VoteStatus `db:"status"`
Due time.Time `db:"due"`
Modified time.Time `db:"modified"`
Tag string `db:"tag"`
Type *VoteType `db:"votetype"`
Sums *VoteSums `db:"-"`
Votes []*Vote `db:"-"`
Reasoning string `db:"-"`
}
type MotionModel struct {
DB *sqlx.DB
}
// Create a new decision.
func (m *MotionModel) Create(
ctx context.Context,
proponent *User,
voteType *VoteType,
title, content string,
proposed, due time.Time,
) (int64, error) {
d := &Motion{
Proposed: proposed.UTC(),
Proponent: proponent.ID,
Title: title,
Content: content,
Due: due.UTC(),
Type: voteType,
Status: voteStatusPending,
}
result, err := m.DB.NamedExecContext(
ctx,
`INSERT INTO decisions
(proposed, proponent, title, content, votetype, status, due, modified, tag)
VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :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')
))`,
d,
)
if err != nil {
return 0, fmt.Errorf("creating motion failed: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("could not get inserted decision id: %w", err)
}
return id, nil
}
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
rows, err := tx.NamedQuery(`
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`, struct{ Now time.Time }{Now: time.Now().UTC()})
if err != nil {
return nil, fmt.Errorf("fetching closable decisions failed: %w", err)
}
defer func() { _ = rows.Close() }()
decisions := make([]*Motion, 0)
for rows.Next() {
decision := &Motion{}
if err = rows.StructScan(decision); err != nil {
return nil, errCouldNotScanResult(err)
}
if rows.Err() != nil {
return nil, fmt.Errorf("row error: %w", err)
}
decisions = append(decisions, decision)
}
results := make([]*Motion, 0, len(decisions))
var decisionResult *Motion
for _, decision := range decisions {
if decisionResult, err = closeDecision(ctx, tx, decision); err != nil {
return nil, fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
}
results = append(results, decisionResult)
}
if err = tx.Commit(); err != nil {
return nil, errCouldNotCommitTransaction(err)
}
return results, nil
}
func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
quorum, majority := d.Type.QuorumAndMajority()
var (
voteSums *VoteSums
err error
reasoning string
)
// TODO: implement prefetching in CloseDecisions
if voteSums, err = sumsForDecision(ctx, tx, d); err != nil {
return nil, fmt.Errorf("getting vote sums failed: %w", err)
}
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
result, err := tx.NamedExecContext(
ctx,
`UPDATE decisions SET status=:status, modified=CURRENT_TIMESTAMP WHERE id=:id`,
d,
)
if err != nil {
return nil, errCouldNotExecuteQuery(err)
}
affectedRows, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("could not get affected rows count: %w", err)
}
if affectedRows != 1 {
return nil, fmt.Errorf("unexpected number of rows %d instead of 1", affectedRows)
}
d.Sums = voteSums
d.Reasoning = reasoning
return d, nil
}
func (m *MotionModel) UnvotedForVoter(ctx context.Context, voter *User) ([]*Motion, error) {
// TODO: implement more efficient variant that fetches unvoted votes for a slice of voters
rows, err := m.DB.QueryxContext(
ctx,
`SELECT decisions.*
FROM decisions
WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
time.Now().UTC(),
voteStatusPending,
voter.ID)
if err != nil {
return nil, errCouldNotExecuteQuery(err)
}
defer func() { _ = rows.Close() }()
result := make([]*Motion, 0)
for rows.Next() {
if err := rows.Err(); err != nil {
return nil, errCouldNotFetchRow(err)
}
var motion Motion
if err := rows.StructScan(&motion); err != nil {
return nil, errCouldNotScanResult(err)
}
result = append(result, &motion)
}
return result, nil
}
func sumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
voteRows, err := tx.QueryxContext(
ctx,
`SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
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.Err(); err != nil {
return nil, fmt.Errorf("could not fetch vote sums for motion %s: %w", d.Tag, err)
}
if err = voteRows.Scan(&vote, &count); err != nil {
return nil, fmt.Errorf("could not parse row for vote sums of motion %s: %w", d.Tag, err)
}
switch vote.ID {
case VoteAye.ID:
sums.Ayes = count
case VoteNaye.ID:
sums.Nayes = count
case VoteAbstain.ID:
sums.Abstains = count
default:
return nil, fmt.Errorf("unknown vote type '%+v'", vote)
}
}
return sums, nil
}
func (m *MotionModel) NextPendingDue(ctx context.Context, relativeTo time.Time) (*time.Time, error) {
row := m.DB.QueryRowContext(
ctx,
`SELECT due FROM decisions WHERE status=0 AND due >= ? ORDER BY due LIMIT 1`,
relativeTo.UTC(),
)
if row == nil {
return nil, errors.New("no row returned")
}
if err := row.Err(); err != nil {
return nil, fmt.Errorf("could not retrieve row for next pending decision: %w", err)
}
var due time.Time
if err := row.Scan(&due); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("parsing result failed: %w", err)
}
return &due, nil
}
type MotionListOptions struct {
Limit int
UnvotedOnly bool
Before, After *time.Time
VoterID int64
}
func (m *MotionModel) TimestampRange(ctx context.Context, options *MotionListOptions) (*time.Time, *time.Time, error) {
var row *sqlx.Row
if options.UnvotedOnly {
row = m.DB.QueryRowxContext(
ctx,
`SELECT MIN(proposed), MAX(proposed)
FROM decisions
WHERE due >= ?
AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
time.Now().UTC(),
options.VoterID,
)
} else {
row = m.DB.QueryRowxContext(
ctx,
`SELECT MIN(proposed), MAX(proposed) FROM decisions`,
)
}
if err := row.Err(); err != nil {
return nil, nil, fmt.Errorf("could not query for motion timestamps: %w", err)
}
var (
first, last sql.NullString
firstTs, lastTs *time.Time
err error
)
if err := row.Scan(&first, &last); err != nil {
return nil, nil, fmt.Errorf("could not scan timestamps: %w", err)
}
if !first.Valid || !last.Valid {
return nil, nil, nil
}
if firstTs, err = parseSqlite3TimeStamp(first.String); err != nil {
return nil, nil, err
}
if lastTs, err = parseSqlite3TimeStamp(last.String); err != nil {
return nil, nil, err
}
return firstTs, lastTs, nil
}
func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
var (
rows *sqlx.Rows
err error
)
switch {
case options.Before != nil:
rows, err = m.rowsBefore(ctx, options)
case options.After != nil:
rows, err = m.rowsAfter(ctx, options)
default:
rows, err = m.rowsFirst(ctx, options)
}
if err != nil {
return nil, err
}
defer func(rows *sqlx.Rows) {
_ = rows.Close()
}(rows)
motions := make([]*Motion, 0, options.Limit)
for rows.Next() {
var decision Motion
if err = rows.Err(); err != nil {
return nil, errCouldNotFetchRow(err)
}
if err = rows.StructScan(&decision); err != nil {
return nil, errCouldNotScanResult(err)
}
motions = append(motions, &decision)
}
if len(motions) > 0 {
err = m.FillVoteSums(ctx, motions)
if err != nil {
return nil, err
}
}
return motions, nil
}
func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) error {
decisionIDs := make([]int64, len(decisions))
decisionMap := make(map[int64]*Motion, len(decisions))
for idx, decision := range decisions {
decision.Sums = &VoteSums{}
decisionIDs[idx] = decision.ID
decisionMap[decision.ID] = decision
}
query, args, err := sqlx.In(
`SELECT v.decision, v.vote, COUNT(*)
FROM votes v
WHERE v.decision IN (?)
GROUP BY v.decision, v.vote`,
decisionIDs,
)
if err != nil {
return fmt.Errorf("could not create IN query: %w", err)
}
rows, err := m.DB.QueryContext(ctx, query, args...)
if err != nil {
return errCouldNotExecuteQuery(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
if err = rows.Err(); err != nil {
return errCouldNotFetchRow(err)
}
var (
decisionID int64
vote *VoteChoice
count int
)
err = rows.Scan(&decisionID, &vote, &count)
if err != nil {
return errCouldNotScanResult(err)
}
switch {
case vote.Equal(VoteAye):
decisionMap[decisionID].Sums.Ayes = count
case vote.Equal(VoteNaye):
decisionMap[decisionID].Sums.Nayes = count
case vote.Equal(VoteAbstain):
decisionMap[decisionID].Sums.Abstains = count
}
}
return nil
}
func (m *MotionModel) rowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
// TODO: implement variant for options.UnvotedOnly
rows, err := m.DB.QueryxContext(
ctx,
`SELECT decisions.id,
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.proposed < $1
ORDER BY proposed DESC
LIMIT $2`,
options.Before,
options.Limit,
)
if err != nil {
return nil, fmt.Errorf("could not query motions before %s: %w", options.Before, err)
}
return rows, nil
}
func (m *MotionModel) rowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
// TODO: implement variant for options.UnvotedOnly
rows, err := m.DB.QueryxContext(
ctx,
`WITH display_decision AS (SELECT decisions.id,
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.proposed > $1
ORDER BY proposed
LIMIT $2)
SELECT *
FROM display_decision
ORDER BY proposed DESC`,
options.After,
options.Limit,
)
if err != nil {
return nil, fmt.Errorf("could not query motions after %s: %w", options.After, err)
}
return rows, nil
}
func (m *MotionModel) rowsFirst(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
var (
rows *sqlx.Rows
err error
)
if options.UnvotedOnly {
rows, err = m.DB.QueryxContext(
ctx,
`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 status=? AND due >= ? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)
ORDER BY decisions.proposed DESC
LIMIT ?`,
voteStatusPending,
time.Now().UTC(),
options.VoterID,
options.Limit,
)
} else {
rows, err = m.DB.QueryxContext(
ctx,
`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 ?`,
options.Limit,
)
}
if err != nil {
return nil, fmt.Errorf("could not query motions: %w", err)
}
return rows, nil
}
func (m *MotionModel) ByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
row := m.DB.QueryRowxContext(
ctx,
`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 = ?`,
tag,
)
if err := row.Err(); err != nil {
return nil, fmt.Errorf("could not query motion: %w", err)
}
var result Motion
if err := row.StructScan(&result); err != nil {
return nil, fmt.Errorf("could not fill motion from query result: %w", err)
}
if err := m.FillVoteSums(ctx, []*Motion{&result}); err != nil {
return nil, fmt.Errorf("could not get vote sums: %w", err)
}
if result.ID != 0 && withVotes {
if err := m.FillVotes(ctx, &result); err != nil {
return nil, fmt.Errorf("could not get votes for %s: %w", result.Tag, err)
}
}
return &result, nil
}
func (m *MotionModel) FillVotes(ctx context.Context, md *Motion) error {
rows, err := m.DB.QueryxContext(ctx,
`SELECT voters.name, votes.vote
FROM voters
JOIN votes ON votes.voter = voters.id
WHERE votes.decision = ?
ORDER BY voters.name`,
md.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("could not fetch rows: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
if err := rows.Err(); err != nil {
return fmt.Errorf("could not get row: %w", err)
}
var vote Vote
if err := rows.StructScan(&vote); err != nil {
return errCouldNotScanResult(err)
}
md.Votes = append(md.Votes, &vote)
}
return nil
}
func (m *MotionModel) ByID(ctx context.Context, id int64) (*Motion, error) {
row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id)
if err := row.Err(); err != nil {
return nil, fmt.Errorf("could not fetch tag for id %d: %w", id, err)
}
var motion Motion
if err := row.StructScan(&motion); err != nil {
return nil, fmt.Errorf("could not get tag from row: %w", err)
}
return &motion, nil
}
func (m *MotionModel) Update(
ctx context.Context,
id int64,
updateFn func(*Motion),
) error {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
row := tx.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id)
if err := row.Err(); err != nil {
return fmt.Errorf("could not select motion: %w", err)
}
var motion Motion
if err := row.StructScan(&motion); err != nil {
return errCouldNotScanResult(err)
}
updateFn(&motion)
motion.Modified = time.Now().UTC()
_, err = tx.NamedExecContext(
ctx,
`UPDATE decisions
SET title=:title,
content=:content,
votetype=:votetype,
due=:due,
modified=:modified,
status=:status
WHERE id = :id`,
motion,
)
if err != nil {
return fmt.Errorf("could not update decision: %w", err)
}
if err := tx.Commit(); err != nil {
return errCouldNotCommitTransaction(err)
}
return nil
}
type Vote struct {
UserID int64 `db:"voter"`
MotionID int64 `db:"decision"`
Vote *VoteChoice `db:"vote"`
Voted time.Time `db:"voted"`
Notes string `db:"notes"`
Name string `db:"name"`
}
func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID)
if err := row.Err(); err != nil {
return errCouldNotExecuteQuery(err)
}
vote := Vote{UserID: userID, MotionID: motionID}
if err := row.StructScan(&vote); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("could not scan vote structure: %w", err)
}
performVoteFn(&vote)
if _, err := tx.NamedExecContext(
ctx,
`INSERT INTO votes (decision, voter, vote, voted, notes)
VALUES (:decision, :voter, :vote, :voted, :notes)
ON CONFLICT (decision, voter)
DO UPDATE SET vote=:vote,
voted=:voted,
notes=:notes
WHERE decision = :decision
AND voter = :voter`,
vote,
); err != nil {
return fmt.Errorf("could not insert or update vote: %w", err)
}
if err := tx.Commit(); err != nil {
return errCouldNotCommitTransaction(err)
}
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

@ -0,0 +1,111 @@
/*
Copyright 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 models_test
import (
"context"
"database/sql"
"log"
"os"
"path"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.cacert.org/cacert-boardvoting/internal"
"git.cacert.org/cacert-boardvoting/internal/models"
)
func prepareTestDb(t *testing.T) *sqlx.DB {
t.Helper()
testDir := t.TempDir()
db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite"))
require.NoError(t, err)
dbx := sqlx.NewDb(db, "sqlite3")
logger := log.New(os.Stdout, "", log.LstdFlags)
err = internal.InitializeDb(dbx.DB, logger)
require.NoError(t, err)
return dbx
}
func TestDecisionModel_Create(t *testing.T) {
dbx := prepareTestDb(t)
dm := models.MotionModel{DB: dbx}
v := &models.User{
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
Name: "test voter",
Reminder: sql.NullString{String: "test+voter@example.com", Valid: true},
}
id, err := dm.Create(
context.Background(),
v,
models.VoteTypeMotion,
"test motion",
"I move that we should test more",
time.Now(),
time.Now().AddDate(0, 0, 7),
)
assert.NoError(t, err)
assert.NotEmpty(t, id)
}
func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
dbx := prepareTestDb(t)
dm := models.MotionModel{DB: dbx}
var (
nextDue *time.Time
err error
)
nextDue, err = dm.NextPendingDue(context.Background(), time.Now().UTC())
assert.NoError(t, err)
assert.Empty(t, nextDue)
v := &models.User{
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
Name: "test voter",
Reminder: sql.NullString{String: "test+voter@example.com", Valid: true},
}
due := time.Now().Add(10 * time.Minute)
ctx := context.Background()
_, err = dm.Create(ctx, v, models.VoteTypeMotion, "test motion", "I move that we should test more", time.Now(), due)
require.NoError(t, err)
nextDue, err = dm.NextPendingDue(ctx, time.Now().UTC())
assert.NoError(t, err)
assert.NotEmpty(t, nextDue)
assert.Equal(t, due.UTC(), *nextDue)
}

1027
internal/models/users.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,388 @@
/*
Copyright 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 notifications
import (
"bytes"
"fmt"
"log"
"net"
"os"
"path"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
"gopkg.in/mail.v2"
"git.cacert.org/cacert-boardvoting/internal"
"git.cacert.org/cacert-boardvoting/internal/models"
)
type MailConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"`
Domain string `yaml:"message_id_domain"`
NotificationSenderAddress string `yaml:"notification_sender_address"`
NoticeMailAddress string `yaml:"notice_mail_address"`
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
BaseURL string `yaml:"base_url"`
}
type recipientData struct {
field, address, name string
}
type NotificationContent struct {
template string
data interface{}
subject string
headers map[string][]string
recipients []recipientData
}
type NotificationMail interface {
GetNotificationContent(*MailConfig) *NotificationContent
}
type MailNotifier struct {
notifyChannel chan NotificationMail
senderAddress string
dialer *mail.Dialer
quitChannel chan struct{}
infoLog, errorLog *log.Logger
mailConfig *MailConfig
}
type Option func(*MailNotifier)
func NewMailNotifier(config *MailConfig, opts ...Option) *MailNotifier {
n := &MailNotifier{
notifyChannel: make(chan NotificationMail, 1),
senderAddress: config.NotificationSenderAddress,
dialer: mail.NewDialer(config.SMTPHost, config.SMTPPort, "", ""),
quitChannel: make(chan struct{}),
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
mailConfig: config,
}
for _, o := range opts {
o(n)
}
return n
}
func NotifierLog(infoLog, errorLog *log.Logger) Option {
return func(n *MailNotifier) {
n.infoLog = infoLog
n.errorLog = errorLog
}
}
func (mn *MailNotifier) Start() {
mn.infoLog.Print("Launching mail notifier")
for {
select {
case notification := <-mn.notifyChannel:
content := notification.GetNotificationContent(mn.mailConfig)
mailText, err := content.buildMail(mn.mailConfig.BaseURL)
if err != nil {
mn.errorLog.Printf("building mail failed: %v", err)
continue
}
m := mail.NewMessage()
m.SetHeaders(content.headers)
m.SetAddressHeader("From", mn.senderAddress, "CAcert board voting system")
for _, recipient := range content.recipients {
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
}
m.SetHeader("Subject", content.subject)
m.SetBody("text/plain", mailText.String())
if err = mn.dialer.DialAndSend(m); err != nil {
mn.errorLog.Printf("sending mail failed: %v", err)
}
case <-mn.quitChannel:
mn.infoLog.Print("ending mail notifier")
return
}
}
}
func (mn *MailNotifier) Quit() {
mn.quitChannel <- struct{}{}
}
func (mn *MailNotifier) Notify(w NotificationMail) {
mn.notifyChannel <- w
}
func (mn *MailNotifier) Ping() error {
conn, err := net.DialTimeout(
"tcp",
fmt.Sprintf("%s:%d", mn.mailConfig.SMTPHost, mn.mailConfig.SMTPPort),
mn.mailConfig.SMTPTimeOut,
)
if err != nil {
return fmt.Errorf("could not connect to SMTP server: %w", err)
}
defer func(conn net.Conn) {
_ = conn.Close()
}(conn)
return nil
}
func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
// TODO: implement a template cache for mail templates too
b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
if err != nil {
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err)
}
t, err := template.New(n.template).Funcs(sprig.GenericFuncMap()).Parse(string(b))
if err != nil {
return nil, fmt.Errorf("could not parse mail template %s: %w", n.template, err)
}
data := struct {
Data any
BaseURL string
}{Data: n.data, BaseURL: baseURL}
mailText := bytes.NewBuffer(make([]byte, 0))
if err = t.Execute(mailText, data); err != nil {
return nil, fmt.Errorf(
"failed to execute template %s with context %v: %w", n.template, n.data, err)
}
return mailText, nil
}
func defaultRecipient(mc *MailConfig) recipientData {
return recipientData{
field: "To",
address: mc.NoticeMailAddress,
name: "CAcert board mailing list",
}
}
func voteNoticeRecipient(mc *MailConfig) recipientData {
return recipientData{
field: "To",
address: mc.VoteNoticeMailAddress,
name: "CAcert board votes mailing list",
}
}
func motionReplyHeaders(m *models.Motion, mc *MailConfig) map[string][]string {
return map[string][]string{
"References": {fmt.Sprintf("<%s@%s>", m.Tag, mc.Domain)},
"In-Reply-To": {fmt.Sprintf("<%s@%s>", m.Tag, mc.Domain)},
}
}
type RemindVoterNotification struct {
Voter *models.User
Decisions []*models.Motion
}
func (r RemindVoterNotification) GetNotificationContent(*MailConfig) *NotificationContent {
recipientAddress := make([]recipientData, 0)
if r.Voter.Reminder.Valid {
recipientAddress = append(recipientAddress, recipientData{
field: "To",
address: r.Voter.Reminder.String,
name: r.Voter.Name,
})
}
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: recipientAddress,
}
}
type ClosedDecisionNotification struct {
Decision *models.Motion
}
func (c *ClosedDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "closed_motion_mail.txt",
data: struct {
*models.Motion
}{Motion: c.Decision},
subject: fmt.Sprintf("Re: %s - %s - finalized", c.Decision.Tag, c.Decision.Title),
headers: motionReplyHeaders(c.Decision, mc),
recipients: []recipientData{defaultRecipient(mc)},
}
}
type NewDecisionNotification struct {
Decision *models.Motion
Proposer *models.User
}
func (n NewDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{
template: "create_motion_mail.txt",
data: struct {
*models.Motion
Name string
VoteURL string
UnvotedURL string
}{
Motion: n.Decision,
Name: n.Proposer.Name,
VoteURL: voteURL,
UnvotedURL: unvotedURL,
},
subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title),
headers: n.getHeaders(mc),
recipients: []recipientData{defaultRecipient(mc)},
}
}
func (n NewDecisionNotification) getHeaders(mc *MailConfig) map[string][]string {
return map[string][]string{
"Message-ID": {fmt.Sprintf("<%s@%s>", n.Decision.Tag, mc.Domain)},
}
}
type UpdateDecisionNotification struct {
Decision *models.Motion
User *models.User
}
func (u UpdateDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
return &NotificationContent{
template: "update_motion_mail.txt",
data: struct {
*models.Motion
Name string
VoteURL string
UnvotedURL string
}{
Motion: u.Decision,
Name: u.User.Name,
VoteURL: voteURL,
UnvotedURL: unvotedURL,
},
subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title),
headers: motionReplyHeaders(u.Decision, mc),
recipients: []recipientData{defaultRecipient(mc)},
}
}
type DirectVoteNotification struct {
Decision *models.Motion
User *models.User
Choice *models.VoteChoice
}
func (d DirectVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "direct_vote_mail.txt",
data: struct {
*models.Motion
Name string
Choice *models.VoteChoice
}{
Motion: d.Decision,
Name: d.User.Name,
Choice: d.Choice,
},
subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title),
headers: motionReplyHeaders(d.Decision, mc),
recipients: []recipientData{voteNoticeRecipient(mc)},
}
}
type ProxyVoteNotification struct {
Decision *models.Motion
User *models.User
Voter *models.User
Choice *models.VoteChoice
Justification string
}
func (p ProxyVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "proxy_vote_mail.txt",
data: struct {
*models.Motion
Name string
Voter string
Choice *models.VoteChoice
Justification string
}{
Motion: p.Decision,
Name: p.User.Name,
Voter: p.Voter.Name,
Choice: p.Choice,
Justification: p.Justification,
},
subject: fmt.Sprintf("Re: %s - %s", p.Decision.Tag, p.Decision.Title),
headers: motionReplyHeaders(p.Decision, mc),
recipients: []recipientData{voteNoticeRecipient(mc)},
}
}
type WithDrawMotionNotification struct {
Motion *models.Motion
Voter *models.User
}
func (w WithDrawMotionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "withdraw_motion_mail.txt",
data: struct {
*models.Motion
Name string
}{Motion: w.Motion, Name: w.Voter.Name},
subject: fmt.Sprintf("Re: %s - %s", w.Motion.Tag, w.Motion.Title),
headers: motionReplyHeaders(w.Motion, mc),
recipients: []recipientData{defaultRecipient(mc)},
}
}

View file

@ -0,0 +1,123 @@
/*
Copyright 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 validator
import (
"net"
"net/mail"
"reflect"
"strings"
"unicode/utf8"
)
type Validator struct {
FieldErrors map[string]string
}
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0
}
func (v *Validator) AddFieldError(key, message string) {
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
func NotNil(value any) bool {
val := reflect.ValueOf(value)
return !val.IsNil()
}
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(strings.TrimSpace(value)) <= n
}
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(strings.TrimSpace(value)) >= n
}
func PermittedInt(value int, permittedValues ...int) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
func PermittedString(value string, permittedValues ...string) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
func PermittedStringSet(value []string, permittedValues []string) bool {
if len(value) == 0 {
return true
}
valueMap := make(map[string]struct{}, len(permittedValues))
for _, v := range permittedValues {
valueMap[v] = struct{}{}
}
for j := range value {
if _, ok := valueMap[value[j]]; !ok {
return false
}
}
return true
}
func IsEmail(value string) bool {
addr, err := mail.ParseAddress(value)
if err != nil {
return false
}
parts := strings.SplitN(addr.Address, "@", 2)
mxs, err := net.LookupMX(parts[1])
if err != nil || len(mxs) < 1 {
return false
}
return true
}

View file

@ -1,198 +0,0 @@
<?php
if ($_SERVER['HTTPS'] != 'on') {
header("HTTP/1.0 302 Redirect");
header("Location: https://".$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']);
exit();
}
require_once("database.php");
$db = new DB();
if (!($user = $db->auth())) {
header("HTTP/1.0 302 Redirect");
header("Location: denied.php");
exit();
}
$db->getStatement("stats")->execute();
$stats = $db->getStatement("stats")->fetch();
?>
<html>
<head>
<title>CAcert Board Decisions</title>
<meta http-equiv="Content-Type" content="text/html; charset='UTF-8'" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<?php
if ($_REQUEST['action'] == "store") {
if (is_numeric($_REQUEST['motion'])) {
$stmt = $db->getStatement("update decision");
$stmt->bindParam(":id",$_POST['motion']);
$stmt->bindParam(":proponent",$user['id']);
$stmt->bindParam(":title",$_POST['title']);
$stmt->bindParam(":content",$_POST['content']);
$stmt->bindParam(":due",$_POST['due']);
$stmt->bindParam(":votetype",$_POST['votetype']);
if ($stmt->execute()) {
?>
<b>The motion has been proposed!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<br/>
<br/>
<?php
$decision = $db->getStatement("get decision")->execute(array($_POST['motion']))?$db->getStatement("get decision")->fetch():array();
$name = $user['name'];
$tag = $decision['tag'];
$title = $decision['title'];
$content =$decision['content'];
$due = $decision['due']." UTC";
$votetype = !$decision['votetype'] ? 'motion' : 'veto';
$baseurl = "https://".$_SERVER['HTTP_HOST'].":".$_SERVER['SERVER_PORT'].preg_replace('/motion\.php/','',$_SERVER['REQUEST_URI']);
$voteurl = $baseurl."vote.php?motion=".$decision['id'];
$unvoted = $baseurl."motions.php?unvoted=1";
$body = <<<BODY
Dear Board,
$name has modified motion $tag to the following:
$title
$content
Vote type: $votetype
To vote please choose:
Aye: $voteurl&vote=1
Naye: $voteurl&vote=-1
Abstain: $voteurl&vote=0
Please be aware, that if you have voted already your vote is still registered and valid.
If this modification has an impact on how you wish to vote, you are responsible for voting
again.
To see all your outstanding votes : $unvoted
Kind regards,
the voting system
BODY;
$db->notify("Re: $tag - $title - modified",$body,$tag);
} else {
?>
<b>The motion has NOT been proposed!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i><br/>
<br/>
<br/>
<?php
}
} else {
$stmt = $db->getStatement("create decision");
$stmt->bindParam(":proponent",$user['id']);
$stmt->bindParam(":title",$_POST['title']);
$stmt->bindParam(":content",$_POST['content']);
$stmt->bindParam(":votetype",$_POST['votetype']);
$stmt->bindParam(":due",$_POST['due']);
if ($stmt->execute()) {
?>
<b>The motion has been proposed!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<br/>
<br/>
<?php
$decision = $db->getStatement("get new decision")->execute()?$db->getStatement("get new decision")->fetch():array();
$name = $user['name'];
$tag = $decision['tag'];
$title = $decision['title'];
$content =$decision['content'];
$due = $decision['due']." UTC";
$votetype = !$decision['votetype'] ? 'motion' : 'veto';
$baseurl = "https://".$_SERVER['HTTP_HOST'].":".$_SERVER['SERVER_PORT'].preg_replace('/motion\.php/','',$_SERVER['REQUEST_URI']);
$voteurl = $baseurl."vote.php?motion=".$decision['id'];
$unvoted = $baseurl."motions.php?unvoted=1";
$body = <<<BODY
Dear Board,
$name has made the following motion:
$title
$content
Vote type: $votetype
Voting will close $due.
To vote please choose:
Aye: $voteurl&vote=1
Naye: $voteurl&vote=-1
Abstain: $voteurl&vote=0
To see all your outstanding votes : $unvoted
Kind regards,
the voting system
BODY;
$db->notify("$tag - $title",$body,$tag,TRUE);
} else {
?>
<b>The motion has NOT been proposed!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i><br/>
<br/>
<br/>
<?php
}
}
}
if (is_numeric($_REQUEST['motion'])) {
$stmt = $db->getStatement("get decision");
if ($stmt->execute(array($_REQUEST['motion']))) {
$motion = $stmt->fetch();
}
if (!is_numeric($motion['id'])) {
$motion = array();
foreach (array("title","content") as $column) {
$motion[$column] = "";
}
$motion["proposer"] = $user['name'];
$motion["votetype"] = 0; // defaults to motion
}
} else {
$motion = array();
foreach (array("title","content") as $column) {
$motion[$column] = "";
}
$motion["proposer"] = $user['name'];
$motion["votetype"] = 0; // defaults to motion
}
?>
<form <?php if (is_numeric($_REQUEST['motion'])) { echo(" action=\"?\""); } ?> method="POST">
<input type="hidden" name="action" value="store" />
<?php
if (is_numeric($_REQUEST['motion'])) {
?><input type="hidden" name="motion" value="<?php echo($_REQUEST["motion"]); ?>" /><?php
}
?>
<table>
<tr><td>ID:</td><td><?php echo htmlentities($motion['tag']); ?></td></tr>
<tr><td>Proponent:</td><td><?php echo htmlentities($motion['proposer']); ?></td></tr>
<tr><td>Proposed date/time:</td><td><?php echo htmlentities($motion['proposed'] ? $motion['proposed']." UTC" : '(auto filled to current date/time)'); ?></td></tr>
<tr><td>Title:</td><td><input name="title" value="<?php echo htmlentities($motion['title'])?>"></td></tr>
<tr><td>Text:</td><td><textarea name="content"><?php echo htmlspecialchars($motion['content'])?></textarea></td></tr>
<tr><td>Vote type:</td><td><select name="votetype">
<option value="0" <?php if(!$motion['votetype']) { echo(" selected=\"selected\""); } ?>>Motion</option>
<option value="1" <?php if($motion['votetype']) { echo(" selected=\"selected\""); } ?>>Veto</option>
</select></td></tr>
<tr><td rowspan="2">Due:</td><td><?php echo($motion['due'] ? $motion['due'].' UTC' : '(autofilled from option below)')?></td></tr>
<tr><td><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></td></tr>
<tr><td>&nbsp;</td><td><input type="submit" value="Propose" /></td></tr>
</table>
</form>
<br/>
<a href="motions.php">Back to motions</a>
</body>
</html>

View file

@ -1,167 +0,0 @@
<?php
require_once("database.php");
$db = new DB();
$page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1;
$user = $db->auth();
if ($_REQUEST['withdrawl'] && $_REQUEST['confirm'] && $_REQUEST['id']) {
if (!$user) {
header("HTTP/1.0 302 Redirect");
header("Location: denied.php");
exit();
}
$stmt = $db->getStatement("get decision");
$stmt->bindParam(":decision",$_REQUEST['id']);
if ($stmt->execute() && ($decision=$stmt->fetch())) {
$name = $user['name'];
$tag = $decision['tag'];
$title = $decision['title'];
$content = $decision['content'];
$body = <<<BODY
Dear Board,
$name has withdrawn the motion $tag that was as follows:
$title
$content
Kind regards,
the voting system
BODY;
$db->notify("Re: $tag - $title - withdrawn",$body,$tag);
}
$stmt = $db->getStatement("close decision");
$status = -2;
$stmt->bindParam(":status",$status);
$stmt->bindParam(":decision",$_REQUEST['id']);
$stmt->execute();
}
?>
<html>
<head>
<title>CAcert Board Decisions</title>
<meta http-equiv="Content-Type" content="text/html; charset='UTF-8'" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<?php
if ($user) echo '<a href="?unvoted=1">Show my outstanding votes</a><br/>';
?>
<table class="list">
<tr>
<th>Status</th>
<th>Motion</th>
<th>Actions</th>
</tr>
<?php
if ($_REQUEST['motion']) {
$stmt = $db->getStatement("list decision");
$stmt->execute(array($_REQUEST['motion']));
} else {
if ($user && $_REQUEST['unvoted']) {
$stmt = $db->getStatement("list my unvoted decisions");
$stmt->bindParam(":id",$user['id']);
} else {
$stmt = $db->getStatement("list decisions");
}
$stmt->bindParam(":page",$page);
$stmt->execute();
}
$items = 0;
$id = -1;
while ($row = $stmt->fetch()) {
$items++;
$id = $row['id'];
?><tr>
<td class="<?php switch($row['status']) { case 0: echo "pending"; break; case 1: echo "approved"; break; case -1: echo "declined"; break; case -2: echo "withdrawn"; break; }?>">
<?php
switch($row['status']) {
case 0: echo "Pending<br/><i>".$row['due']." UTC</i>"; break;
case 1: echo "Approved<br/><i>".$row['modified']." UTC</i>"; break;
case -1: echo "Declined<br/><i>".$row['modified']." UTC</i>"; break;
case -2: echo "Withdrawn<br/><i>".$row['modified']." UTC</i>"; break;
}
?>
</td>
<td>
<i><a href="motions.php?motion=<?php echo $row['tag'].'">'.$row['tag']; ?></a></i><br/>
<b><?php echo htmlspecialchars($row['title']); ?></b><br/>
<pre><?php echo wordwrap(htmlspecialchars($row['content'])); ?></pre>
<br/>
<i>Due: <?php echo($row['due']); ?> UTC</i><br/>
<i>Proposed: <?php echo($row['proposer']); ?> (<?php echo($row['proposed']); ?> UTC)</i><br/>
<i>Vote type: <?php echo(!$row['votetype']?'motion':'veto'); ?></i><br/>
<i>Aye|Naye|Abstain: <?php echo($row['ayes']); ?>|<?php echo($row['nayes']); ?>|<?php echo($row['abstains']); ?></i><br/>
<?php
if ($row['status'] ==0 || $_REQUEST['showvotes']) {
$state = array('Naye','Abstain','Aye');
$vstmt = $db->getStatement("list votes");
$vstmt->execute(array($row['id']));
echo "<i>Votes:</i><br/>";
while ($vrow = $vstmt->fetch()) {
echo "<i>".$vrow['name'].": ".$state[$vrow['vote']+1]."</i><br/>";
}
} else {
echo '<i><a href="motions.php?motion='.$row['tag'].'&showvotes=1">Show Votes</a></i><br/>';
}
?>
</td>
<td class="actions">
<?php
if ($row['status'] == 0 && $user ) {
?>
<ul>
<li><a href="vote.php?motion=<?php echo($row['id']); ?>&amp;vote=1">Aye</a></li>
<li><a href="vote.php?motion=<?php echo($row['id']); ?>&amp;vote=0">Abstain</a></li>
<li><a href="vote.php?motion=<?php echo($row['id']); ?>&amp;vote=-1">Naye</a></li>
<li><a href="proxy.php?motion=<?php echo($row['id']); ?>">Proxy Vote</a></li>
<li><a href="motion.php?motion=<?php echo($row['id']); ?>">Modify</a></li>
<li><a href="motions.php?motion=<?php echo($row['tag']); ?>&amp;withdrawl=1">Withdrawl</a></li>
</ul>
<?php
} else {
?>
&nbsp;
<?php
}
?>
</td>
</tr><?php
}
?>
<tr>
<td colspan="2" class="navigation">
<?php if ($page>1) { ?><a href="?page=<?php echo($page-1); ?>">&lt;</a><?php } else { ?>&nbsp;<?php } ?>
&nbsp;
<?php if ($items>9) { ?><a href="?page=<?php echo($page+1); ?>">&gt;</a><?php } else { ?>&nbsp;<?php } ?>
</td>
<td class="actions">
<?php if ($user) echo('<ul><li><a href="motion.php">New Motion</a></li></ul>'); ?>
</td>
</tr>
<?php
if ($_REQUEST['withdrawl']) {
?>
<tr>
<td colspan="3">
<?php
if ($_REQUEST['confirm'] && $_REQUEST['id']) {
?>
<a href="motions.php">Motion Withdrawn</a>
<?php
} else {
?>
<form action="?withdrawl=1&amp;confirm=1&amp;id=<?php echo $id;?>" method="post">
<input type="submit" value="Withdraw">
</form>
<?php
}
?>
</td>
</tr>
<?php
}
?>
</table>
</body>
</html>

12698
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

10
package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "cacert-boardvoting",
"version": "1.0.0",
"description": "CAcert Board Voting system theme",
"author": "",
"license": "Apache-2.0",
"dependencies": {
"fomantic-ui": "^2.8.8"
}
}

154
proxy.php
View file

@ -1,154 +0,0 @@
<?php
if ($_SERVER['HTTPS'] != 'on') {
header("HTTP/1.0 302 Redirect");
header("Location: https://".$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']);
exit();
}
require_once("database.php");
$db = new DB();
if (!($user = $db->auth())) {
header("HTTP/1.0 302 Redirect");
header("Location: denied.php");
exit();
}
?>
<html>
<head>
<title>CAcert Board Decisions</title>
<meta http-equiv="Content-Type" content="text/html; charset='UTF-8'" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<?php
if (!is_numeric($_REQUEST['motion'])) {
?>
<b>This is not a valid motion!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<?php
} else {
$stmt = $db->getStatement("get decision");
$stmt->bindParam(":decision",$_REQUEST['motion']);
if ($stmt->execute() && ($decision=$stmt->fetch()) && ($decision['status'] == 0)) {
if (is_numeric($_POST['voter']) && is_numeric($_POST['vote']) && is_numeric($_REQUEST['motion']) && ($_POST['justification'] != "")) {
$stmt = $db->getStatement("del vote");
$stmt->bindParam(":voter",$_REQUEST['voter']);
$stmt->bindParam(":decision",$_REQUEST['motion']);
if ($stmt->execute()) {
$stmt = $db->getStatement("do vote");
$stmt->bindParam(":voter",$_REQUEST['voter']);
$stmt->bindParam(":decision",$_REQUEST['motion']);
$stmt->bindParam(":vote",$_REQUEST['vote']);
$notes = "Proxy-Vote by ".$user['name']."\n\n".$_REQUEST['justification']."\n\n".$_SERVER['SSL_CLIENT_CERT'];
$stmt->bindParam(":notes",$notes);
if ($stmt->execute()) {
?>
<b>The vote has been registered.</b><br/>
<a href="motions.php">Back to motions</a>
<?php
$stmt = $db->getStatement("get voter by id");
$stmt->bindParam(":id",$_REQUEST['voter']);
if ($stmt->execute() && ($voter=$stmt->fetch())) {
$voter = $voter['name'];
} else {
$voter = "Voter: ".$_REQUEST['voter'];
}
$name = $user['name'];
$justification = $_REQUEST['justification'];
$vote = '';
switch($_REQUEST['vote']) {
case 1 : $vote='Aye'; break;
case -1: $vote='Naye'; break;
default: $vote='Abstain'; break;
}
$tag = $decision['tag'];
$title = $decision['title'];
$content = $decision['content'];
$due = $decision['due']." UTC";
$body = <<<BODY
Dear Board,
$name has just registered a proxy vote of $vote for $voter on motion $tag.
The justification for this was:
$justification
Motion:
$title
$content
Kind regards,
the vote system
BODY;
$db->vote_notify("Re: $tag - $title",$body,$tag);
} else {
?>
<b>The vote has NOT been registered.</b><br/>
<a href="motions.php">Back to motions</a>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i>
<?php
}
} else {
?>
<b>The vote has NOT been registered.</b><br/>
<a href="motions.php">Back to motions</a>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i>
<?php
}
} else {
$stmt = $db->getStatement("get voters");
if ($stmt->execute() && ($voters = $stmt->fetchAll())) {
?>
<form method="POST" action="?motion=<?php echo($_REQUEST['motion']); ?>">
<table>
<tr>
<th>Voter</th><th>Vote</th>
</tr>
<tr>
<td><select name="voter"><?php
foreach ($voters as $voter) {
?>
<option value="<?php echo($voter['id']); ?>"<?php if ($voter['id'] == $_POST['voter']) { echo(" selected=\"selected\""); } ?>><?php echo($voter['name']); ?></option>
<?php
}
?></select></td>
<td><select name="vote">
<option value="1"<?php if (1 == $_POST['voter']) { echo(" selected=\"selected\""); } ?>>Aye</option>
<option value="0"<?php if (0 == $_POST['voter']) { echo(" selected=\"selected\""); } ?>>Abstain</option>
<option value="-1"<?php if (-1 == $_POST['voter']) { echo(" selected=\"selected\""); } ?>>Naye</option>
</select></td>
</tr>
<tr>
<th colspan="2">Justification:</th>
</tr>
<tr>
<td colspan="2"><textarea name="justification"><?php echo($_POST['justification']); ?></textarea></td>
</tr>
<tr>
<td colspan="2"><input type="submit" value="Proxy Vote" /></td>
</tr>
</table>
</form>
<?php
} else {
?>
<b>Could not retrieve voters!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i>
<?php
}
}
?>
<?php
} else {
?>
<b>This is not a valid motion!</b><br/>
<a href="motions.php">Back to motions</a><br/>
<i><?php echo join("<br/>\n",$stmt->errorInfo()); ?></i>
<?php
}
}
?>
</body>
</html>

View file

@ -1,43 +0,0 @@
#!/usr/bin/php
<?
require_once("database.php");
$db = new DB();
$id = 0;
$page = 1;
$voters = $db->getStatement('get reminder voters');
$voters->execute();
$outstanding = $db->getStatement('list my unvoted decisions');
$outstanding->bindParam(':id',$id);
$outstanding->bindParam(':page',$page);
while ($v = $voters->fetch()) {
$id = $v['id'];
$outstanding->execute();
$msg ='';
while ($row=$outstanding->fetch()) {
$msg .= ($row['votetype'] ? 'vote ' : 'motion ') . $row['tag'] . ' ' . $row['title'] . "\nDue: " . $row['due'] . "\nhttps://community.cacert.org/board/motions.php?motion=" . $row['tag'] . "\n\n";
}
if ($msg) {
// form email
$name = $v['name'];
$body = <<<BODY
Dear $name,
You have not voted in the following CAcert Board vote(s)/motion(s):
$msg
To view all your outstanding motions: https://community.cacert.org/board/motions.php?unvoted=1
Kind regards,
the vote system
BODY;
$db->remind_notify($v['email'],"Outstanding CAcert board votes",$body);
}
}
?>

22
semantic.json Normal file
View file

@ -0,0 +1,22 @@
{
"base": "ui/semantic/",
"paths": {
"source": {
"config": "src/theme.config",
"definitions": "src/definitions/",
"site": "src/site/",
"themes": "src/themes/"
},
"output": {
"packaged": "../static/",
"uncompressed": "dist/components/",
"compressed": "dist/components/",
"themes": "../static/themes/"
},
"clean": "dist/"
},
"permission": false,
"autoInstall": false,
"rtl": false,
"version": "2.8.8"
}

View file

@ -1,31 +0,0 @@
html, body, th, td {
font-family: Verdana, Arial, Sans-Serif;
font-size:10px;
}
table, tr, td, th {
vertical-align:top;
border:1px solid black;
border-collapse: collapse;
}
td.navigation {
text-align:center;
}
td.approved {
color:green;
}
td.declined {
color:red;
}
td.withdrawn {
color:red;
}
td.pending {
color:blue;
}
textarea {
width:400px;
height:150px;
}
input {
width:400px;
}

24
ui/efs.go Normal file
View file

@ -0,0 +1,24 @@
/*
Copyright 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 ui provides files for the CAcert board voting system user interface.
package ui
import "embed"
//go:embed "html" "static"
var Files embed.FS

53
ui/html/base.html Normal file
View file

@ -0,0 +1,53 @@
{{ define "base" -}}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ template "title" . }} - CAcert Board Voting System</title>
<link rel="stylesheet" type="text/css" href="/static/lato-fonts.css">
<link rel="stylesheet" type="text/css" href="/static/semantic.min.css">
<link rel="icon" href="/static/images/favicon.ico">
</head>
<body id="cacert-board-voting">
<header 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">CAcert Board Voting System</h1>
{{ with .User }}
<div class="ui label">
<i class="id card outline icon"></i>
Authenticated as {{ .Name }} &lt;{{ .Reminder.String }}&gt;
</div>
{{ end }}
</div>
</div>
</header>
{{ template "nav" . }}
<main class="ui container">
{{ with .Flashes }}
<div class="basic segment">
{{ range . }}
<div class="ui {{ .Variant }} message">
<i class="close icon"></i>
<div class="header">{{ .Title }}</div>
<p>{{ .Message }}</p>
</div>
{{ end }}
</div>
{{ end }}
{{ template "main" . }}
</main>
<footer class="ui vertical footer segment">
<div class="ui container">
<span class="ui small text">© 2017-2023 CAcert Inc.</span>
</div>
</footer>
</body>
<script src="/static/jquery.min.js"></script>
<script src="/static/semantic.min.js"></script>
<script src="/static/handlers.js"></script>
</html>
{{ end }}

View file

@ -0,0 +1,40 @@
{{ define "title" }}Add email address for {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
<form action="/users/{{ .Form.User.ID }}/add-mail" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="ui message">
<div class="header">
Add email address for {{ .Form.User.Name }}
</div>
<p>The following addresses are registered for {{ .Form.User.Name }}:</p>
<div class="ui list">
{{ range .Form.EmailAddresses }}
<div class="item">
<i class="mail icon"></i>
<div class="content">{{ . }}</div>
</div>
{{ end }}
</div>
</div>
<div class="required field{{ if .Form.FieldErrors.email_address }} error{{ end }}">
<label for="email_address">Email address:</label>
<input id="email_address" name="email_address" type="text" value="{{ .Form.EmailAddress }}">
{{ if .Form.FieldErrors.email_address }}
<span class="ui small error text">{{ .Form.FieldErrors.email_address }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit"><i class="add icon"></i> Add email address
</button>
<a href="/users/{{ .Form.User.ID }}/" class="ui button">Cancel</a>
</div>
</form>
{{ end }}

View file

@ -0,0 +1,34 @@
{{ define "title" }}Choose voters{{ end }}
{{ define "main" }}
{{ $voterIDs := .Form.VoterIDs }}
<div class="ui info message">
<div class="header">Edit voter list</div>
<p>Use the lists below to add or remove voters.</p>
</div>
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<form action="/voters/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="hidden" aria-label="voters" for="voters">Voters</label>
<div class="field">
<select id="voters" name="voters" class="ui fluid search dropdown" multiple aria-multiselectable="true">
{{ range .Form.Users }}
{{ $userID := .ID }}
<option value="{{ .ID }}" {{ range $voterIDs }}{{ if eq $userID . }} selected{{ end }}{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="edit icon"></i>
Change voters
</button>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,70 @@
{{ define "title" }}New motion{{ end }}
{{ define "main" }}
<form action="/newmotion/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
(generated on submit)
</div>
<div class="field">
<label>Proponent:</label>
{{ .User.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
(auto filled to current date/time)
</div>
</div>
<div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}">
<label for="title">Title:</label>
<input id="title" name="title" type="text" value="{{ .Form.Title }}">
{{ if .Form.FieldErrors.title }}
<span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}">
<label for="content">Text:</label>
<textarea id="content" name="content">{{ .Form.Content }}</textarea>
{{ if .Form.FieldErrors.content }}
<span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
{{ end }}
</div>
<div class="two fields">
<div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}">
<label for="type">Vote type:</label>
{{ $voteType := toString .Form.Type }}
<select id="type" name="type">
<option value="motion"
{{ if eq "motion" $voteType }}selected{{ end }}>
Motion
</option>
<option value="veto"
{{ if eq "veto" $voteType }}selected{{ end }}>
Veto
</option>
</select>
{{ if .Form.FieldErrors.type }}
<span class="ui small error text">{{ .Form.FieldErrors.type}}</span>
{{ end}}
</div>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}">
<label for="due">Due: (autofilled from chosen
option)</label>
<select id="due" name="due">
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>
{{ end }}
</div>
</div>
<button class="ui button" type="submit">Propose</button>
</div>
</form>
{{ end }}

View file

@ -0,0 +1,34 @@
{{ define "title" }}Add user{{ end }}
{{ define "main" }}
<form action="/new-user/" method="post">
<input type="hidden" name="csrf_token" , value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.name }} error{{ end }}">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ .Form.Name }}">
{{ if .Form.FieldErrors.name }}
<span class="ui small error text">{{ .Form.FieldErrors.name }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.email_address }} error{{ end}}">
<label for="email_address">Email address</label>
<input id="email_address" name="email_address" type="email" value="{{ .Form.EmailAddress }}">
{{ if .Form.FieldErrors.email_address }}
<span class="ui small error text">{{ .Form.FieldErrors.email_address }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="magic icon"></i> Add user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
{{ end }}

View file

@ -0,0 +1,29 @@
{{ define "title" }}Delete email address {{ .Form.EmailAddress }} of User {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
<div class="ui form segment">
<div class="ui negative message">
<div class="header">
Delete email address?
</div>
<p>Do you want to delete email address <strong>{{ .Form.EmailAddress }}</strong> of user
<strong>{{ .Form.User.Name }}</strong>?</p>
</div>
<form action="/users/{{ .Form.User.ID }}/mail/{{ .Form.EmailAddress }}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the deletion</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui negative labeled icon button" type="submit">
<i class="trash icon"></i> Delete user
</button>
<a href="/users/{{ .Form.User.ID }}/" class="ui button">Cancel</a>
</div>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,30 @@
{{ define "title" }}Delete User {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
<div class="ui negative message">
<div class="header">
Delete user?
</div>
<p>Do you want to delete user <strong>{{ .Form.User.Name }}</strong>?</p>
</div>
<form action="/users/{{ .Form.User.ID }}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the deletion</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui negative labeled icon button" type="submit">
<i class="trash icon"></i> Delete user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,24 @@
{{ define "title" }}Vote on Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
<div class="ui raised segment">
{{ template "motion_display" .Motion }}
<form action="/vote/{{ .Motion.Tag }}/{{ .Form.Choice }}" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ with .Form }}
<div class="ui form">
{{ if eq "aye" .Choice.Label }}
<button class="ui right labeled green icon button" type="submit">
<i class="check circle icon"></i> Vote {{ .Choice }}</button>
{{ else if eq "naye" .Choice.Label }}
<button class="ui right labeled red icon button" type="submit">
<i class="minus circle icon"></i> Vote {{ .Choice }}</button>
{{ else }}
<button class="ui right labeled grey icon button" type="submit">
<i class="circle icon"></i> Vote {{ .Choice }}</button>
{{ end }}
</div>
{{ end }}
</form>
</div>
{{ end }}

View file

@ -0,0 +1,70 @@
{{ define "title" }}Edit motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
<form action="/motions/{{ .Motion.Tag }}/edit" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
{{ .Motion.Tag }}
</div>
<div class="field">
<label>Proponent:</label>
{{ .User.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
{{ .Motion.Proposed }}
</div>
</div>
<div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}">
<label for="title">Title:</label>
<input id="title" name="title" type="text" value="{{ .Form.Title }}">
{{ if .Form.FieldErrors.title }}
<span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}">
<label for="content">Text:</label>
<textarea id="content" name="content">{{ .Form.Content }}</textarea>
{{ if .Form.FieldErrors.content }}
<span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
{{ end }}
</div>
<div class="two fields">
<div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}">
<label for="type">Vote type:</label>
{{ $voteType := toString .Form.Type }}
<select id="type" name="type">
<option value="motion"
{{ if eq "motion" $voteType }}selected{{ end }}>
Motion
</option>
<option value="veto"
{{ if eq "veto" $voteType }}selected{{ end }}>
Veto
</option>
</select>
{{ if .Form.FieldErrors.type }}
<span class="ui small error text">{{ .Form.FieldErrors.type}}</span>
{{ end}}
</div>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}">
<label for="due">Due: (autofilled from chosen
option)</label>
<select id="due" name="due">
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>
{{ end }}
</div>
</div>
<button class="ui button" type="submit">Modify</button>
</div>
</form>
{{ end }}

View file

@ -0,0 +1,79 @@
{{ define "title" }}Edit User {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
<form action="/users/{{ .Form.User.ID }}/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.name }} error{{ end }}">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ .Form.Name }}">
{{ if .Form.FieldErrors.name }}
<span class="ui small error text">{{ .Form.FieldErrors.name }}</span>
{{ end }}
</div>
<div class="grouped fields{{ if .Form.FieldErrors.reminder_mail }} error{{ end }}">
<label>Reminder email address</label>
<table class="ui very basic collapsing table">
<tbody>
{{ range .Form.MailAddresses }}
<tr>
<td>
<div class="field">
<div class="ui radio checkbox">
<input id="mail-{{ . }}" type="radio" name="reminder_mail"
value="{{ . }}"{{ if eq $form.ReminderMail . }} checked{{ end }}>
<label for="mail-{{ . }}">{{ . }}</label>
</div>
</div>
</td>
<td>
{{ if not (eq $form.ReminderMail .) }} <a
class="ui small negative icon button"
href="/users/{{ $form.User.ID }}/mail/{{ . }}/delete"><i
class="icon delete" title="Delete email address {{ . }}"></i></a>{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ if .Form.FieldErrors.reminder_mail }}
<span class="ui small error text">{{ .Form.FieldErrors.reminder_mail }}</span>
{{ end }}
<a class="ui small positive icon button" href="/users/{{ $form.User.ID }}/add-mail"><i
class="add icon"></i> Add new email address</a>
</div>
<div class="inline fields{{ if .Form.FieldErrors.roles }} error{{ end }}">
<label>Roles</label>
{{ range .Form.AllRoles }}
{{ $currentRole := . }}
<div class="field">
<div class="ui checkbox">
<input id="role-{{ . }}" aria-labelledby="role-label-{{ . }}" type="checkbox"
name="roles"
value="{{ .Name }}"{{ range $form.Roles }}{{ if eq $currentRole.Name . }} checked{{ end }}{{ end }}>
<label for="role-{{ . }}" id="role-label-{{ . }}">{{ . }}</label>
</div>
</div>
{{ end }}
{{ if .Form.FieldErrors.roles }}
<span class="ui small error text">{{ .Form.FieldErrors.roles }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="edit icon"></i> Edit user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
</div>
{{ end }}

11
ui/html/pages/motion.html Normal file
View file

@ -0,0 +1,11 @@
{{ define "title" }}Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
{{ $voter := .User }}
{{ with .Motion }}
<div class="ui raised segment">
{{ template "motion_display" . }}
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
{{ end }}

View file

@ -0,0 +1,25 @@
{{ define "title" }}Board Decisions{{ end }}
{{ define "main" }}
{{ $page := . }}
{{ $user := .User }}
{{ if .Motions }}
{{ template "pagination" $page }}
{{ range .Motions }}
<div class="ui raised segment">
{{ template "motion_display" . }}
{{ if canVote $user }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
{{ template "pagination" $page }}
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="inbox icon"></i>
<div class="content">
<div class="header">{{ if eq .ActiveSubNav "unvoted-motions" }}No unvoted motions available.{{ else }}No motions available.{{ end }}</div>
</div>
</div>
</div>
{{ end }}
{{ end }}

View file

@ -0,0 +1,66 @@
{{ define "title" }}Proxy Vote on Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
{{ template "motion_display" .Motion }}
<form action="/proxy/{{ .Motion.Tag }}" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.user }} error{{ end }}">
<label for="voter">Voter</label>
<select id="voter" name="voter">
{{ range .Form.Voters }}
{{ if ne .ID $user.ID }}
<option value="{{ .ID }}"
{{ if $form.Voter }}{{ if eq .ID $form.Voter.ID }} selected{{ end }}{{ end }}>{{ .Name }}</option>
{{ end }}
{{ end }}
</select>
{{ if .Form.FieldErrors.user }}
<span class="ui small error text">{{ .Form.FieldErrors.user }}</span>
{{ end }}
</div>
<div class="inline fields required{{ if .Form.FieldErrors.choice }} error{{ end }}">
<label>Choice</label>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-aye" name="choice" id="choice-aye"
value="aye"{{ with .Form.Choice }}{{ if eq "aye" .Label }} checked{{ end }}{{ end }}>
<label id="label-aye" for="choice-aye">Aye</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-naye" name="choice" id="choice-naye"
value="naye"{{ with .Form.Choice }}{{ if eq "naye" .Label }} checked{{ end }}{{ end }}>
<label id="label-naye" for="choice-naye">Naye</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" aria-labelledby="label-abstain" name="choice" id="choice-abstain"
value="abstain"{{ with .Form.Choice }}{{ if eq "abstain" .Label }} checked{{ end }}{{ end }}>
<label id="label-abstain" for="choice-abstain">Abstain</label>
</div>
</div>
{{ if .Form.FieldErrors.choice }}
<div>
<span class="ui small error text">{{ .Form.FieldErrors.choice }}</span>
</div>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.justification }} error{{ end }}">
<label for="justification">Justification</label>
<textarea id="justification" name="justification" rows="2">{{ .Form.Justification }}</textarea>
{{ if .Form.FieldErrors.justification }}
<span class="ui small error text">{{ .Form.FieldErrors.justification }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit"><i class="users icon"></i> Proxy Vote
</button>
</div>
</form>
</div>
{{ end }}

44
ui/html/pages/users.html Normal file
View file

@ -0,0 +1,44 @@
{{ define "title"}}User Management{{ end }}
{{ define "main"}}
{{ if .Users }}
<table class="ui selectable basic table">
<thead>
<tr>
<th class="six wide">Name</th>
<th class="four wide">Roles</th>
<th class="six wide">Actions</th>
</tr>
</thead>
<tbody>
{{ $user := .User }}
{{ range .Users }}
<tr {{ if eq $user.ID .ID }}class="disabled"{{ end }}>
<td>{{ .Name }}</td>
<td>{{ .Roles | join ", " }}</td>
<td>
{{ if not (eq $user.ID .ID) }}
<a href="/users/{{ .ID }}/" class="ui labeled primary icon button"><i class="edit icon"></i> Edit</a>
{{ if .CanDelete }}
<a href="/users/{{ .ID }}/delete" class="ui labeled negative icon button" title="{{ .Name }} never participated in a motion and may be deleted">
<i class="delete icon"></i> Delete</a>
{{ end }}
{{ else }}
Cannot modify your own user. Ask another administrator or secretary.
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="users icon"></i>
<div class="content">
<div class="header">No users found.</div>
</div>
</div>
</div>
{{ end }}
{{ end }}

View file

@ -0,0 +1,20 @@
{{ define "title" }}Withdraw Motion {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
<div class="ui form segment">
{{ template "motion_display" .Motion }}
<form action="/motions/{{ .Motion.Tag }}/withdraw" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui negative message">
<div class="header">
Withdraw motion?
</div>
<p>Do you want to withdraw motion <strong>{{ .Motion.Tag }}: {{ .Motion.Title }}</strong>?</p>
</div>
<button class="ui negative labeled icon button" type="submit">
<i class="trash icon"></i> Withdraw
</button>
<a href="/motions/" class="ui button">Cancel</a>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,16 @@
{{ define "motion_actions" }}
{{ if eq .Status.Label "pending" }}
<a class="ui compact labeled green icon button" href="/vote/{{ .Tag }}/aye">
<i class="check circle icon"></i> Aye</a>
<a class="ui compact labeled red icon button" href="/vote/{{ .Tag }}/naye">
<i class="minus circle icon"></i> Naye</a>
<a class="ui compact labeled grey icon button" href="/vote/{{ .Tag }}/abstain">
<i class="question circle icon"></i> Abstain</a>
<a class="ui compact labeled icon button" href="/proxy/{{ .Tag }}">
<i class="users icon"></i> Proxy Vote</a>
<a class="ui compact labeled icon button" href="/motions/{{ .Tag }}/edit">
<i class="edit icon"></i> Modify</a>
<a class="ui compact labeled icon button" href="/motions/{{ .Tag }}/withdraw">
<i class="trash icon"></i> Withdraw</a>
{{ end }}
{{ end }}

View file

@ -0,0 +1,51 @@
{{ define "motion_display" }}
<span class="ui {{ template "motion_status_class" .Status }} ribbon label">{{ .Status|toString|title }}</span>
<div class="ui label"><i class="ui icon calendar alternate outline"></i> {{ dateInZone "2006-01-02 15:04:05 UTC" .Modified "UTC" }}</div>
<h3 class="ui header"><a href="/motions/{{ .Tag }}" title="Details for motion {{ .Tag }}: {{ .Title }}">{{ .Tag }}: {{ .Title }}</a></h3>
<p>{{ wrap 76 .Content | nl2br }}</p>
<table class="ui small definition table">
<tbody>
<tr>
<td>Due</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Due "UTC" }}</td>
</tr>
<tr>
<td>Proposed</td>
<td>{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }} by {{ .Proposer }}</td>
</tr>
<tr>
<td>Vote type:</td>
<td>{{ .Type|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">{{.Sums.Ayes}}</div>
</div>
<div class="ui basic label red">
<i class="minus circle icon"></i>Naye
<div class="detail">{{.Sums.Nayes}}</div>
</div>
<div class="ui basic label grey">
<i class="question circle icon"></i>Abstain
<div class="detail">{{.Sums.Abstains}}</div>
</div>
</div>
{{ if .Votes }}
<div class="list">
{{ range .Votes }}
<div class="item">{{ .Name }}: {{ .Vote }}</div>
{{ end }}
</div>
<a href="/motions/{{ .Tag }}">Hide Votes</a>
{{ else if or (ne 0 .Sums.Ayes) (ne 0 .Sums.Nayes) (ne 0 .Sums.Abstains) }}
<a href="/motions/{{ .Tag }}?showvotes=1">Show Votes</a>
{{ end }}
</td>
</tr>
</tbody>
</table>
{{ end }}

View file

@ -0,0 +1,7 @@
{{ define "motion_status_class" -}}
{{- if eq .Label "pending" }}blue
{{- else if eq .Label "approved" }}green
{{- else if eq .Label "declined" }}red
{{- else if eq .Label "withdrawn" }}grey
{{- end }}
{{- end }}

45
ui/html/partials/nav.html Normal file
View file

@ -0,0 +1,45 @@
{{ define "nav" }}
{{ $user := .User }}
{{ if $user }}
{{ if canManageUsers $user }}
<nav class="ui top attached tabular menu">
<a class="{{ if eq .ActiveNav "motions" }}active {{ end }}item" href="/motions/">Motions</a>
<a class="{{ if eq .ActiveNav "users" }}active {{ end }}item" href="/users/">User management</a>
</nav>
{{ end }}
{{ end }}
{{ if eq .ActiveNav "motions"}}
<nav class="ui secondary pointing menu">
<a href="/motions/" class="{{ if eq .ActiveSubNav "all-motions" }}active {{ end }}item"
title="Show all motions">All
motions</a>
{{ if $user }}
{{ if canVote $user }}
<a href="/motions/?unvoted=1" class="{{ if eq .ActiveSubNav "unvoted-motions" }}active {{ end}}item"
title="My unvoted motions">My unvoted motions</a>
{{ end }}
{{ if canStartVote $user }}
<div class="right item">
<a class="ui primary labeled icon button" href="/newmotion/">
<i class="magic icon"></i>
New motion</a>
</div>
{{ end }}
{{ end }}
</nav>
{{ end }}
{{ if eq .ActiveNav "users"}}
{{ if $user }}
{{ if canManageUsers $user }}
<nav class="ui secondary pointing menu">
<a href="/users/" class="{{ if eq .ActiveSubNav "users" }}active {{ end }}item">Manage users</a>
<a href="/voters/" class="{{ if eq .ActiveSubNav "manage-voters" }}active {{ end }}item">Manage voters</a>
<div class="right item">
<a class="ui primary labeled icon button" href="/new-user/">
<i class="magic icon"></i> Add User</a>
</div>
</nav>
{{ end }}
{{ end }}
{{ end }}
{{ end }}

View file

@ -0,0 +1,20 @@
{{ define "pagination" }}
{{ if or .PrevPage .NextPage }}
<div class="ui labeled icon menu">
{{ if .PrevPage -}}
<a class="item"
href="?after={{ .PrevPage }}{{ if eq .ActiveSubNav "unvoted-motions" }}&unvoted=1{{ end }}"
title="newer motions">
<i class="left arrow icon"></i> newer
</a>
{{- end }}
{{ if .NextPage -}}
<a class="right item"
href="?before={{ .NextPage }}{{ if eq .ActiveSubNav "unvoted-motions" }}&unvoted=1{{ end }}"
title="older motions">
<i class="right arrow icon"></i> older
</a>
{{- end }}
</div>
{{ end }}
{{ end }}

35
ui/semantic/gulpfile.js Normal file
View file

@ -0,0 +1,35 @@
/*******************************
* Set-up
*******************************/
var
gulp = require('gulp'),
// read user config to know what task to load
config = require('./tasks/config/user')
;
/*******************************
* Tasks
*******************************/
require('./tasks/collections/build')(gulp);
require('./tasks/collections/various')(gulp);
require('./tasks/collections/install')(gulp);
gulp.task('default', gulp.series('watch'));
/*--------------
Docs
---------------*/
require('./tasks/collections/docs')(gulp);
/*--------------
RTL
---------------*/
if (config.rtl) {
require('./tasks/collections/rtl')(gulp);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more