Compare commits
172 commits
Author | SHA1 | Date | |
---|---|---|---|
e327e5e2ee | |||
39393eb612 | |||
a8f16192a8 | |||
11582d3590 | |||
3d16034c44 | |||
20d324f5cb | |||
4276594f8d | |||
4a8307e16a | |||
12796486d2 | |||
f27f2bf801 | |||
d0052ff3dc | |||
c9d3f2a20a | |||
fd287e4f55 | |||
e9af4bfd86 | |||
bb06fb95a2 | |||
070bac6314 | |||
41965ca076 | |||
c2eef9cf7c | |||
3dc3160945 | |||
dbf774b727 | |||
17867421eb | |||
57fd5364fc | |||
c1c9ed5dec | |||
c37bfb3b9a | |||
ff58bf721c | |||
be77e5f05d | |||
f966cbd62f | |||
39bd724381 | |||
de4c6faef6 | |||
99a2cde144 | |||
be14a37b4d | |||
a5475ec16e | |||
5efc57d2c3 | |||
db52f88e25 | |||
71fc599a10 | |||
c625b6dc9e | |||
7f0b52c5b5 | |||
368bd8eefb | |||
28ddbd2ce6 | |||
c12aaf4d89 | |||
0c02daf29a | |||
164495c818 | |||
b8b6899cf3 | |||
2b98712aa8 | |||
335ce16547 | |||
c3d0733e27 | |||
257a777e03 | |||
d5d7525a31 | |||
1695ce0168 | |||
47092bfa9b | |||
f8fbf00c4d | |||
a1a66b7245 | |||
aa3a1b0cc7 | |||
e1af6876c1 | |||
47af34f1cd | |||
0ad88fe5f4 | |||
44a6180a09 | |||
2ddc013c84 | |||
c0a73494c3 | |||
4ce321dc36 | |||
c4c64d0202 | |||
65cce5b723 | |||
ff93acb65c | |||
ec7623a51a | |||
2b8beadb77 | |||
49295d2caa | |||
3346cb5dba | |||
68d6f4bcdc | |||
01b95f2253 | |||
933f21a43c | |||
ec7d2fe324 | |||
a5c1a64a3c | |||
8d0d968441 | |||
d7a742d97d | |||
d22f31e823 | |||
87b87f7de3 | |||
7dbef080b1 | |||
3a25296b37 | |||
0c2fbf9d54 | |||
623bdf6d56 | |||
63c748bb1d | |||
a9290b9717 | |||
c3adfd9e8b | |||
975f3c0837 | |||
4d87e35ec2 | |||
806d706d4f | |||
32f271ca7a | |||
96797ec4ef | |||
fdc5c5cc61 | |||
23d586e99d | |||
70cc0942ca | |||
03827874cf | |||
594df29dc1 | |||
bf67dfbc10 | |||
e6fb26e5ef | |||
b0aa52fc24 | |||
03247b420d | |||
58898b29a7 | |||
6c9bf09f1a | |||
ea9641cfb1 | |||
1f32b6d25b | |||
96089d49df | |||
fc9d0042c0 | |||
6ff78cde48 | |||
41a8261552 | |||
56741a1089 | |||
c55617edc0 | |||
29a7a1c90c | |||
eaced9af06 | |||
4266620eff | |||
a69e017ead | |||
2d7f0cc0e3 | |||
870e3ab1d2 | |||
2a6debbf33 | |||
317aa7a91a | |||
0aa9ef7b5d | |||
93f5305d8e | |||
fd2f3a6e5d | |||
5977eb5a7a | |||
33f75bdf1d | |||
8cf5ad44a4 | |||
369c9dab16 | |||
431fba6120 | |||
e8720798fb | |||
4f013ebf3f | |||
5d68bae54f | |||
94dcb5bd75 | |||
4dd5e09820 | |||
aea93c328e | |||
e5d0b98514 | |||
a30a29a4e6 | |||
8943fafeca | |||
ace63025ea | |||
4afeb6ddfc | |||
06e0a52737 | |||
3fb815f6f1 | |||
ebb15fc538 | |||
9398d90a38 | |||
9b07f3e538 | |||
c62801fcb7 | |||
91e6d9ad29 | |||
58aed9abd1 | |||
bf511ae4db | |||
aff6bf1fff | |||
14ed5a5020 | |||
c48bd9e356 | |||
1c989fdfa3 | |||
2a38b6bcad | |||
4d23b6a48f | |||
eec8620e49 | |||
5a449926f4 | |||
fd0a8ed972 | |||
c6b1435875 | |||
dad5d58158 | |||
8d0e0eeb1b | |||
12dd0717ad | |||
8d1f18e16d | |||
2cac50ee86 | |||
b6ad5d8ad3 | |||
dcdd5f715f | |||
2de96dc13d | |||
0ce9ad6dcc | |||
bc194e8943 | |||
cc0f5c0b7b | |||
bcfbad42b6 | |||
471daf12ea | |||
57e3d53245 | |||
e0be1a6aa5 | |||
6fe515ea52 | |||
f4360b98c8 | |||
74987ce184 | |||
37c6f2efe6 |
612 changed files with 233521 additions and 921 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
76
.golangci.yml
Normal 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
55
.goreleaser.yml
Normal 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
|
33
.htaccess
33
.htaccess
|
@ -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
60
Jenkinsfile
vendored
Normal 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
202
LICENSE
Normal 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
27
Makefile
Normal 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
211
README.md
Normal 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/
|
12
cacert-boardvoting.service
Normal file
12
cacert-boardvoting.service
Normal 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
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/php
|
||||
<?
|
||||
require_once("database.php");
|
||||
$db = new DB();
|
||||
|
||||
$db->closeVotes();
|
||||
|
||||
?>
|
|
@ -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
86
cmd/boardvoting/config.go
Normal 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: ¬ifications.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
208
cmd/boardvoting/main.go
Normal 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
13
config.yaml.example
Normal 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
|
160
database.php
160
database.php
|
@ -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;
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -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
22
debian/copyright
vendored
Normal 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`
|
12
denied.php
12
denied.php
|
@ -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
46
go.mod
Normal 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
681
go.sum
Normal 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=
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
header("HTTP/1.0 301 Redirect");
|
||||
header("Location: motions.php");
|
||||
exit();
|
||||
?>
|
243
internal/app/app.go
Normal file
243
internal/app/app.go
Normal 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
302
internal/forms/forms.go
Normal 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() {
|
||||
}
|
71
internal/handlers/flashmessage.go
Normal file
71
internal/handlers/flashmessage.go
Normal 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)
|
||||
}
|
1415
internal/handlers/handlers.go
Normal file
1415
internal/handlers/handlers.go
Normal file
File diff suppressed because it is too large
Load diff
134
internal/handlers/handlers_test.go
Normal file
134
internal/handlers/handlers_test.go
Normal 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(¬ifications.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(¬ifications.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)
|
||||
})
|
||||
}
|
236
internal/handlers/middleware.go
Normal file
236
internal/handlers/middleware.go
Normal 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
|
||||
}
|
176
internal/handlers/middleware_test.go
Normal file
176
internal/handlers/middleware_test.go
Normal 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")
|
||||
})
|
||||
}
|
126
internal/handlers/templatecache.go
Normal file
126
internal/handlers/templatecache.go
Normal 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)
|
||||
}
|
139
internal/jobs/closedecisions.go
Normal file
139
internal/jobs/closedecisions.go
Normal 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(¬ifications.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
19
internal/jobs/doc.go
Normal 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
34
internal/jobs/jobs.go
Normal 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
|
||||
}
|
161
internal/jobs/remindvoters.go
Normal file
161
internal/jobs/remindvoters.go
Normal 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(¬ifications.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
|
||||
}
|
||||
}
|
93
internal/jobs/scheduler.go
Normal file
93
internal/jobs/scheduler.go
Normal 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
23
internal/mailtemplates.go
Normal 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
|
20
internal/mailtemplates/closed_motion_mail.txt
Normal file
20
internal/mailtemplates/closed_motion_mail.txt
Normal 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.
|
22
internal/mailtemplates/create_motion_mail.txt
Normal file
22
internal/mailtemplates/create_motion_mail.txt
Normal 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
|
10
internal/mailtemplates/direct_vote_mail.txt
Normal file
10
internal/mailtemplates/direct_vote_mail.txt
Normal 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
|
13
internal/mailtemplates/proxy_vote_mail.txt
Normal file
13
internal/mailtemplates/proxy_vote_mail.txt
Normal 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
|
15
internal/mailtemplates/remind_voter_mail.txt
Normal file
15
internal/mailtemplates/remind_voter_mail.txt
Normal 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
|
26
internal/mailtemplates/update_motion_mail.txt
Normal file
26
internal/mailtemplates/update_motion_mail.txt
Normal 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
|
10
internal/mailtemplates/withdraw_motion_mail.txt
Normal file
10
internal/mailtemplates/withdraw_motion_mail.txt
Normal 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
78
internal/migrations.go
Normal 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
|
||||
}
|
5
internal/migrations/2017042101_0.1.0_original.down.sql
Normal file
5
internal/migrations/2017042101_0.1.0_original.down.sql
Normal 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;
|
5
internal/migrations/2017042101_0.1.0_original.up.sql
Normal file
5
internal/migrations/2017042101_0.1.0_original.up.sql
Normal 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);
|
3
internal/migrations/2017042102_fix_duplicates.down.sql
Normal file
3
internal/migrations/2017042102_fix_duplicates.down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
-- There is no useful backward migration
|
1
internal/migrations/2017042102_fix_duplicates.up.sql
Normal file
1
internal/migrations/2017042102_fix_duplicates.up.sql
Normal 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;
|
83
internal/migrations/2017042103_add_constraints.down.sql
Normal file
83
internal/migrations/2017042103_add_constraints.down.sql
Normal 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;
|
83
internal/migrations/2017042103_add_constraints.up.sql
Normal file
83
internal/migrations/2017042103_add_constraints.up.sql
Normal 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;
|
2
internal/migrations/2020041401_add_roles_table.down.sql
Normal file
2
internal/migrations/2020041401_add_roles_table.down.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
DROP TABLE user_roles;
|
13
internal/migrations/2020041401_add_roles_table.up.sql
Normal file
13
internal/migrations/2020041401_add_roles_table.up.sql
Normal 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;
|
|
@ -0,0 +1 @@
|
|||
-- no useful backward migration
|
|
@ -0,0 +1,2 @@
|
|||
-- remove tables of other migration systems
|
||||
DROP TABLE IF EXISTS gorp_migrations;
|
|
@ -0,0 +1,3 @@
|
|||
-- drop sessions table for server side session storage
|
||||
DROP INDEX session_expiry_idx;
|
||||
DROP TABLE sessions;
|
|
@ -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);
|
16
internal/migrations/2022052701_add_vote_constrains.down.sql
Normal file
16
internal/migrations/2022052701_add_vote_constrains.down.sql
Normal 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;
|
24
internal/migrations/2022052701_add_vote_constrains.up.sql
Normal file
24
internal/migrations/2022052701_add_vote_constrains.up.sql
Normal 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;
|
|
@ -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;
|
18
internal/migrations/2022052702_add_emails_constrains.up.sql
Normal file
18
internal/migrations/2022052702_add_emails_constrains.up.sql
Normal 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;
|
3
internal/migrations/2022060101_add_audit_table.down.sql
Normal file
3
internal/migrations/2022060101_add_audit_table.down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
-- add an audit table to track changes to users
|
||||
DROP INDEX audit_change_idx;
|
||||
DROP TABLE audit;
|
18
internal/migrations/2022060101_add_audit_table.up.sql
Normal file
18
internal/migrations/2022060101_add_audit_table.up.sql
Normal 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
131
internal/models/audit.go
Normal 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
69
internal/models/models.go
Normal 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
991
internal/models/motions.go
Normal 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
|
||||
})
|
||||
}
|
111
internal/models/motions_test.go
Normal file
111
internal/models/motions_test.go
Normal 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
1027
internal/models/users.go
Normal file
File diff suppressed because it is too large
Load diff
388
internal/notifications/mailnotifier.go
Normal file
388
internal/notifications/mailnotifier.go
Normal 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)},
|
||||
}
|
||||
}
|
123
internal/validator/validator.go
Normal file
123
internal/validator/validator.go
Normal 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
|
||||
}
|
198
motion.php
198
motion.php
|
@ -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> </td><td><input type="submit" value="Propose" /></td></tr>
|
||||
</table>
|
||||
</form>
|
||||
<br/>
|
||||
<a href="motions.php">Back to motions</a>
|
||||
</body>
|
||||
</html>
|
167
motions.php
167
motions.php
|
@ -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']); ?>&vote=1">Aye</a></li>
|
||||
<li><a href="vote.php?motion=<?php echo($row['id']); ?>&vote=0">Abstain</a></li>
|
||||
<li><a href="vote.php?motion=<?php echo($row['id']); ?>&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']); ?>&withdrawl=1">Withdrawl</a></li>
|
||||
</ul>
|
||||
<?php
|
||||
} else {
|
||||
?>
|
||||
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr><?php
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="2" class="navigation">
|
||||
<?php if ($page>1) { ?><a href="?page=<?php echo($page-1); ?>"><</a><?php } else { ?> <?php } ?>
|
||||
|
||||
<?php if ($items>9) { ?><a href="?page=<?php echo($page+1); ?>">></a><?php } else { ?> <?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&confirm=1&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
12698
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
10
package.json
Normal file
10
package.json
Normal 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
154
proxy.php
|
@ -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>
|
43
remind.php
43
remind.php
|
@ -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
22
semantic.json
Normal 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"
|
||||
}
|
31
styles.css
31
styles.css
|
@ -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
24
ui/efs.go
Normal 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
53
ui/html/base.html
Normal 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 }} <{{ .Reminder.String }}>
|
||||
</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 }}
|
40
ui/html/pages/add_email.html
Normal file
40
ui/html/pages/add_email.html
Normal 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 }}
|
34
ui/html/pages/choose_voters.html
Normal file
34
ui/html/pages/choose_voters.html
Normal 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 }}
|
70
ui/html/pages/create_motion.html
Normal file
70
ui/html/pages/create_motion.html
Normal 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 }}
|
34
ui/html/pages/create_user.html
Normal file
34
ui/html/pages/create_user.html
Normal 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 }}
|
29
ui/html/pages/delete_email.html
Normal file
29
ui/html/pages/delete_email.html
Normal 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 }}
|
30
ui/html/pages/delete_user.html
Normal file
30
ui/html/pages/delete_user.html
Normal 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 }}
|
24
ui/html/pages/direct_vote.html
Normal file
24
ui/html/pages/direct_vote.html
Normal 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 }}
|
70
ui/html/pages/edit_motion.html
Normal file
70
ui/html/pages/edit_motion.html
Normal 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 }}
|
79
ui/html/pages/edit_user.html
Normal file
79
ui/html/pages/edit_user.html
Normal 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
11
ui/html/pages/motion.html
Normal 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 }}
|
25
ui/html/pages/motions.html
Normal file
25
ui/html/pages/motions.html
Normal 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 }}
|
66
ui/html/pages/proxy_vote.html
Normal file
66
ui/html/pages/proxy_vote.html
Normal 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
44
ui/html/pages/users.html
Normal 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 }}
|
20
ui/html/pages/withdraw_motion.html
Normal file
20
ui/html/pages/withdraw_motion.html
Normal 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 }}
|
16
ui/html/partials/motion_actions.html
Normal file
16
ui/html/partials/motion_actions.html
Normal 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 }}
|
51
ui/html/partials/motion_display.html
Normal file
51
ui/html/partials/motion_display.html
Normal 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 }}
|
7
ui/html/partials/motion_status_class.html
Normal file
7
ui/html/partials/motion_status_class.html
Normal 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
45
ui/html/partials/nav.html
Normal 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 }}
|
20
ui/html/partials/pagination.html
Normal file
20
ui/html/partials/pagination.html
Normal 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
35
ui/semantic/gulpfile.js
Normal 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);
|
||||
}
|
1177
ui/semantic/src/definitions/behaviors/api.js
Normal file
1177
ui/semantic/src/definitions/behaviors/api.js
Normal file
File diff suppressed because it is too large
Load diff
2071
ui/semantic/src/definitions/behaviors/form.js
Normal file
2071
ui/semantic/src/definitions/behaviors/form.js
Normal file
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
Loading…
Reference in a new issue