Breaking: Add support for multiple database backends (#32)

This commit is contained in:
Knut Ahlers 2022-10-23 00:08:02 +02:00 committed by GitHub
parent b589e4137d
commit c0075db1f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1115 additions and 952 deletions

View file

@ -23,7 +23,8 @@ Usage of twitch-bot:
--log-level string Log level (debug, info, warn, error, fatal) (default "info")
--plugin-dir string Where to find and load plugins (default "/usr/lib/twitch-bot")
--rate-limit duration How often to send a message (default: 20/30s=1500ms, if your bot is mod everywhere: 100/30s=300ms, different for known/verified bots) (default 1.5s)
--storage-database string Database file to store data in (default "./storage.db")
--storage-conn-string string Connection string for the database (default "./storage.db")
--storage-conn-type string One of: mysql, postgres, sqlite (default "sqlite")
--storage-encryption-pass string Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)
--twitch-client string Client ID to act as
--twitch-client-secret string Secret for the Client ID
@ -39,6 +40,58 @@ Supported sub-commands are:
help Prints this help message
```
### Database Connection Strings
Currently these databases are supported and need their corresponding connection strings:
#### MySQL
```
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]
Recommended parameters:
?charset=utf8mb4&parseTime=True&loc=Local
```
- Create your database as follows:
```sql
CREATE DATABASE twbot_tezrian CHARACTER SET utf8mb4;
```
- Start your bot:
```console
# twitch-bot \
--storage-conn-type mysql \
--storage-conn-string 'tezrian:mypass@tcp(mariadb:3306)/twbot_tezrian?charset=utf8mb4&parseTime=True&loc=Local' \
...
```
See [driver documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for more details on parameters.
#### Postgres
```
host=localhost port=5432 dbname=mydb connect_timeout=10
```
See [Postgres documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) for more details in paramters.
#### SQLite
```
storage.db
```
Just pass the filename you want to use.
- Start your bot:
```console
# twitch-bot \
--storage-conn-type sqlite \
--storage-conn-string 'storage.db' \
...
```
## Upgrade from `v2.x` to `v3.x`
With the release of `v3.0.0` the bot changed a lot introducing a new storage format. As that storage backend is not compatible with the `v2.x` storage you need to migrate it manually before starting a `v3.x` bot version the first time.

36
go.mod
View file

@ -8,29 +8,35 @@ require (
github.com/Luzifer/korvike/functions v0.6.1
github.com/Luzifer/rconfig/v2 v2.4.0
github.com/Masterminds/sprig/v3 v3.2.2
github.com/glebarez/go-sqlite v1.18.1
github.com/glebarez/sqlite v1.5.0
github.com/go-irc/irc v2.1.0+incompatible
github.com/gofrs/uuid v4.2.0+incompatible
github.com/gofrs/uuid/v3 v3.1.2
github.com/gorilla/mux v1.7.4
github.com/gorilla/websocket v1.4.2
github.com/itchyny/gojq v0.12.9
github.com/jmoiron/sqlx v1.3.5
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/pkg/errors v0.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.8.0
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
gopkg.in/src-d/go-git.v4 v4.13.1
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.4.5
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755
)
require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/glebarez/go-sqlite v1.19.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@ -45,7 +51,17 @@ require (
github.com/huandu/xstrings v1.3.1 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/itchyny/timefmt-go v0.1.4 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.13.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/pgx/v4 v4.17.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
@ -53,6 +69,7 @@ require (
github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sergi/go-diff v1.0.0 // indirect
@ -61,16 +78,17 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/src-d/gcfg v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.16.19 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.1.1 // indirect
modernc.org/sqlite v1.18.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.19.0 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.19.1 // indirect
)

191
go.sum
View file

@ -49,6 +49,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko=
github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
@ -60,6 +62,7 @@ github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH
github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -86,8 +89,10 @@ github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/glebarez/go-sqlite v1.18.1 h1:w0xtxKWktqYsUsXg//SQK+l1IcpKb3rGOQHmMptvL2U=
github.com/glebarez/go-sqlite v1.18.1/go.mod h1:ydXIGq2M4OzF4YyNhH129SPp7jWoVvgkEgb6pldmS0s=
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
@ -95,11 +100,13 @@ github.com/go-irc/irc v2.1.0+incompatible h1:pg7pMVq5OYQbqTxceByD/EN8VIsba7DtKn4
github.com/go-irc/irc v2.1.0+incompatible/go.mod h1:jJILTRy8s/qOvusiKifAEfhQMVwft1ZwQaVJnnzmyX4=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8=
github.com/go-ldap/ldap/v3 v3.1.10/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@ -108,6 +115,7 @@ github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr6
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY=
@ -141,6 +149,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -212,13 +221,63 @@ github.com/itchyny/gojq v0.12.9 h1:biKpbKwMxVYhCU1d6mR7qMr3f0Hn9F5k5YykCVb3gmM=
github.com/itchyny/gojq v0.12.9/go.mod h1:T4Ip7AETUXeGpD+436m+UEl3m3tokRgajd5pRfsR5oE=
github.com/itchyny/timefmt-go v0.1.4 h1:hFEfWVdwsEi+CY8xY2FtgWHGQaBaC3JeHd+cve0ynVM=
github.com/itchyny/timefmt-go v0.1.4/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
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 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
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.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
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/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
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.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
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/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
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/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
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/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@ -229,6 +288,7 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
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/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@ -240,22 +300,25 @@ 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/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
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.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
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-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
@ -334,11 +397,17 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6O
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
@ -360,12 +429,15 @@ github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jW
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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb h1:G0Rrif8QdbAz7Xy53H4Xumy6TuyKHom8pu8z/jdLwwM=
@ -373,24 +445,44 @@ github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb/go.mod h1:398xiA
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
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.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/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-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/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-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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=
@ -411,8 +503,10 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
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=
@ -436,6 +530,7 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -451,20 +546,24 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
@ -475,13 +574,20 @@ golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGm
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-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/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-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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=
@ -521,8 +627,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
@ -550,34 +658,49 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755 h1:7AdrbfcvKnzejfqP5g37fdSZOXH/JvaPIzBIHTOqXKk=
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/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=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo=
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
modernc.org/libc v1.19.0 h1:bXyVhGQg6KIClTr8FMVIDPl7jtbcs7aS5WP7vLDaxPs=
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

View file

@ -24,7 +24,7 @@ var (
//nolint:funlen // This function is a few lines too long but only contains definitions
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
if err := db.Migrate("counter", database.NewEmbedFSMigrator(schema, "schema")); err != nil {
if err := db.DB().AutoMigrate(&counter{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
@ -134,7 +134,7 @@ func Register(args plugins.RegistrationArguments) error {
})
args.RegisterTemplateFunction("counterValue", plugins.GenericTemplateFunctionGetter(func(name string, _ ...string) (int64, error) {
return getCounterValue(name)
return GetCounterValue(db, name)
}))
return nil
@ -160,7 +160,7 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
}
return false, errors.Wrap(
updateCounter(counterName, counterValue, true),
UpdateCounter(db, counterName, counterValue, true),
"set counter",
)
}
@ -179,7 +179,7 @@ func (a ActorCounter) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, ev
}
return false, errors.Wrap(
updateCounter(counterName, counterStep, false),
UpdateCounter(db, counterName, counterStep, false),
"update counter",
)
}
@ -201,7 +201,7 @@ func routeActorCounterGetValue(w http.ResponseWriter, r *http.Request) {
template = "%d"
}
cv, err := getCounterValue(mux.Vars(r)["name"])
cv, err := GetCounterValue(db, mux.Vars(r)["name"])
if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return
@ -223,7 +223,7 @@ func routeActorCounterSetValue(w http.ResponseWriter, r *http.Request) {
return
}
if err = updateCounter(mux.Vars(r)["name"], value, absolute); err != nil {
if err = UpdateCounter(db, mux.Vars(r)["name"], value, absolute); err != nil {
http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError)
return
}

View file

@ -1,30 +1,29 @@
package counter
import (
"database/sql"
"embed"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/Luzifer/twitch-bot/pkg/database"
)
//go:embed schema/**
var schema embed.FS
type (
counter struct {
Name string `gorm:"primaryKey"`
Value int64
}
)
func getCounterValue(counter string) (int64, error) {
row := db.DB().QueryRow(
`SELECT value
FROM counters
WHERE name = $1`,
counter,
)
func GetCounterValue(db database.Connector, counterName string) (int64, error) {
var c counter
var cv int64
err := row.Scan(&cv)
err := db.DB().First(&c, "name = ?", counterName).Error
switch {
case err == nil:
return cv, nil
return c.Value, nil
case errors.Is(err, sql.ErrNoRows):
case errors.Is(err, gorm.ErrRecordNotFound):
return 0, nil
default:
@ -32,9 +31,9 @@ func getCounterValue(counter string) (int64, error) {
}
}
func updateCounter(counter string, value int64, absolute bool) error {
func UpdateCounter(db database.Connector, counterName string, value int64, absolute bool) error {
if !absolute {
cv, err := getCounterValue(counter)
cv, err := GetCounterValue(db, counterName)
if err != nil {
return errors.Wrap(err, "getting previous value")
}
@ -42,14 +41,11 @@ func updateCounter(counter string, value int64, absolute bool) error {
value += cv
}
_, err := db.DB().Exec(
`INSERT INTO counters
(name, value)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET value = excluded.value;`,
counter, value,
return errors.Wrap(
db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(counter{Name: counterName, Value: value}).Error,
"storing counter value",
)
return errors.Wrap(err, "storing counter value")
}

View file

@ -0,0 +1,30 @@
package counter
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func TestCounterStoreLoop(t *testing.T) {
dbc := database.GetTestDatabase(t)
dbc.DB().AutoMigrate(&counter{})
counterName := "mytestcounter"
v, err := GetCounterValue(dbc, counterName)
assert.NoError(t, err, "reading non-existent counter")
assert.Equal(t, int64(0), v, "expecting 0 counter value on non-existent counter")
err = UpdateCounter(dbc, counterName, 5, true)
assert.NoError(t, err, "inserting counter")
err = UpdateCounter(dbc, counterName, 1, false)
assert.NoError(t, err, "updating counter")
v, err = GetCounterValue(dbc, counterName)
assert.NoError(t, err, "reading existent counter")
assert.Equal(t, int64(6), v, "expecting counter value on existing counter")
}

View file

@ -1,4 +0,0 @@
CREATE TABLE counters (
name STRING NOT NULL PRIMARY KEY,
value INTEGER
);

View file

@ -30,7 +30,7 @@ var (
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
if err := db.Migrate("punish", database.NewEmbedFSMigrator(schema, "schema")); err != nil {
if err := db.DB().AutoMigrate(&punishLevel{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
@ -153,7 +153,7 @@ func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
return false, errors.Wrap(err, "preparing user")
}
lvl, err := getPunishment(plugins.DeriveChannel(m, eventData), user, uuid)
lvl, err := getPunishment(db, plugins.DeriveChannel(m, eventData), user, uuid)
if err != nil {
return false, errors.Wrap(err, "getting stored punishment")
}
@ -199,11 +199,11 @@ func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve
}
lvl.Cooldown = cooldown
lvl.Executed = time.Now()
lvl.Executed = time.Now().UTC()
lvl.LastLevel = nLvl
return false, errors.Wrap(
setPunishment(plugins.DeriveChannel(m, eventData), user, uuid, lvl),
setPunishment(db, plugins.DeriveChannel(m, eventData), user, uuid, lvl),
"storing punishment level",
)
}
@ -236,7 +236,7 @@ func (a actorResetPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
}
return false, errors.Wrap(
deletePunishment(plugins.DeriveChannel(m, eventData), user, uuid),
deletePunishment(db, plugins.DeriveChannel(m, eventData), user, uuid),
"resetting punishment level",
)
}

View file

@ -1,50 +1,45 @@
package punish
import (
"database/sql"
"embed"
"strings"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/Luzifer/twitch-bot/pkg/database"
)
//go:embed schema/**
var schema embed.FS
type (
punishLevel struct {
Key string `gorm:"primaryKey"`
func calculateCurrentPunishments() error {
rows, err := db.DB().Query(
`SELECT key, last_level, executed, cooldown
FROM punish_levels;`,
)
if err != nil {
LastLevel int
Executed time.Time
Cooldown time.Duration
}
)
func calculateCurrentPunishments(db database.Connector) (err error) {
var ps []punishLevel
if err = db.DB().Find(&ps).Error; err != nil {
return errors.Wrap(err, "querying punish_levels")
}
for rows.Next() {
if err = rows.Err(); err != nil {
return errors.Wrap(err, "advancing rows")
}
for _, p := range ps {
var (
key string
lastLevel, executed, cooldown int64
actUpdate bool
lvl = &levelConfig{
LastLevel: p.LastLevel,
Executed: p.Executed,
Cooldown: p.Cooldown,
}
)
if err = rows.Scan(&key, &lastLevel, &executed, &cooldown); err != nil {
return errors.Wrap(err, "advancing rows")
}
lvl := &levelConfig{
LastLevel: int(lastLevel),
Cooldown: time.Duration(cooldown),
Executed: time.Unix(executed, 0),
}
for {
cooldownTime := lvl.Executed.Add(lvl.Cooldown)
if cooldownTime.After(time.Now()) {
if cooldownTime.After(time.Now().UTC()) {
break
}
@ -55,61 +50,53 @@ func calculateCurrentPunishments() error {
// Level 0 is the first punishment level, so only remove if it drops below 0
if lvl.LastLevel < 0 {
if err = deletePunishmentForKey(key); err != nil {
if err = deletePunishmentForKey(db, p.Key); err != nil {
return errors.Wrap(err, "cleaning up expired punishment")
}
continue
}
if actUpdate {
if err = setPunishmentForKey(key, lvl); err != nil {
if err = setPunishmentForKey(db, p.Key, lvl); err != nil {
return errors.Wrap(err, "updating punishment")
}
}
}
return errors.Wrap(rows.Err(), "finishing rows processing")
return nil
}
func deletePunishment(channel, user, uuid string) error {
return deletePunishmentForKey(getDBKey(channel, user, uuid))
func deletePunishment(db database.Connector, channel, user, uuid string) error {
return deletePunishmentForKey(db, getDBKey(channel, user, uuid))
}
func deletePunishmentForKey(key string) error {
_, err := db.DB().Exec(
`DELETE FROM punish_levels
WHERE key = $1;`,
key,
func deletePunishmentForKey(db database.Connector, key string) error {
return errors.Wrap(
db.DB().Delete(&punishLevel{}, "key = ?", key).Error,
"deleting punishment info",
)
return errors.Wrap(err, "deleting punishment info")
}
func getPunishment(channel, user, uuid string) (*levelConfig, error) {
if err := calculateCurrentPunishments(); err != nil {
func getPunishment(db database.Connector, channel, user, uuid string) (*levelConfig, error) {
if err := calculateCurrentPunishments(db); err != nil {
return nil, errors.Wrap(err, "updating punishment states")
}
row := db.DB().QueryRow(
`SELECT last_level, executed, cooldown
FROM punish_levels
WHERE key = $1;`,
getDBKey(channel, user, uuid),
var (
lc = &levelConfig{LastLevel: -1}
p punishLevel
)
lc := &levelConfig{LastLevel: -1}
var lastLevel, executed, cooldown int64
err := row.Scan(&lastLevel, &executed, &cooldown)
err := db.DB().First(&p, "key = ?", getDBKey(channel, user, uuid)).Error
switch {
case err == nil:
lc.LastLevel = int(lastLevel)
lc.Cooldown = time.Duration(cooldown)
lc.Executed = time.Unix(executed, 0)
return &levelConfig{
LastLevel: p.LastLevel,
Executed: p.Executed,
Cooldown: p.Cooldown,
}, nil
return lc, nil
case errors.Is(err, sql.ErrNoRows):
case errors.Is(err, gorm.ErrRecordNotFound):
return lc, nil
default:
@ -117,24 +104,27 @@ func getPunishment(channel, user, uuid string) (*levelConfig, error) {
}
}
func setPunishment(channel, user, uuid string, lc *levelConfig) error {
return setPunishmentForKey(getDBKey(channel, user, uuid), lc)
func setPunishment(db database.Connector, channel, user, uuid string, lc *levelConfig) error {
return setPunishmentForKey(db, getDBKey(channel, user, uuid), lc)
}
func setPunishmentForKey(key string, lc *levelConfig) error {
_, err := db.DB().Exec(
`INSERT INTO punish_levels
(key, last_level, executed, cooldown)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO UPDATE
SET last_level = excluded.last_level,
executed = excluded.executed,
cooldown = excluded.cooldown;`,
key,
lc.LastLevel, lc.Executed.UTC().Unix(), int64(lc.Cooldown),
)
func setPunishmentForKey(db database.Connector, key string, lc *levelConfig) error {
if lc == nil {
return errors.New("nil levelConfig given")
}
return errors.Wrap(err, "updating punishment info")
return errors.Wrap(
db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
UpdateAll: true,
}).Create(punishLevel{
Key: key,
LastLevel: lc.LastLevel,
Executed: lc.Executed,
Cooldown: lc.Cooldown,
}).Error,
"updating punishment info",
)
}
func getDBKey(channel, user, uuid string) string {

View file

@ -0,0 +1,55 @@
package punish
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func TestPunishmentRoundtrip(t *testing.T) {
dbc := database.GetTestDatabase(t)
require.NoError(t, dbc.DB().AutoMigrate(&punishLevel{}))
var (
channel = "#test"
user = "test"
uuid = "1befb33d-be89-4724-8ae1-0d465eb58007"
)
pl, err := getPunishment(dbc, channel, user, uuid)
assert.NoError(t, err, "query non-existent punishment")
assert.Equal(t, -1, pl.LastLevel, "check default level")
assert.Zero(t, pl.Executed, "check default time")
assert.Zero(t, pl.Cooldown, "check default cooldown")
err = setPunishment(dbc, channel, user, uuid, &levelConfig{
Cooldown: 500 * time.Millisecond,
Executed: time.Now().UTC(),
LastLevel: 1,
})
assert.NoError(t, err, "setting punishment")
pl, err = getPunishment(dbc, channel, user, uuid)
assert.NoError(t, err, "query existent punishment")
assert.Equal(t, 1, pl.LastLevel, "check level without cooldown")
time.Sleep(500 * time.Millisecond) // Wait for one cooldown to happen
pl, err = getPunishment(dbc, channel, user, uuid)
assert.NoError(t, err, "query existent punishment")
assert.Equal(t, 0, pl.LastLevel, "check level after one cooldown")
assert.NotZero(t, pl.Executed, "check non-zero-time after one cooldown")
assert.Equal(t, 500*time.Millisecond, pl.Cooldown, "check non-zero-cooldown after one cooldown")
time.Sleep(500 * time.Millisecond) // Wait for one cooldown to happen
pl, err = getPunishment(dbc, channel, user, uuid)
assert.NoError(t, err, "query existent punishment")
assert.Equal(t, -1, pl.LastLevel, "check level after two cooldown")
assert.Zero(t, pl.Executed, "check zero-time after two cooldown")
assert.Zero(t, pl.Cooldown, "check zero-cooldown after two cooldown")
}

View file

@ -1,6 +0,0 @@
CREATE TABLE punish_levels (
key STRING NOT NULL PRIMARY KEY,
last_level INTEGER,
executed INTEGER, -- time.Time
cooldown INTEGER -- time.Duration
);

View file

@ -26,7 +26,7 @@ var (
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
if err := db.Migrate(actorName, database.NewEmbedFSMigrator(schema, "schema")); err != nil {
if err := db.DB().AutoMigrate(&quote{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
@ -83,7 +83,7 @@ func Register(args plugins.RegistrationArguments) error {
args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, r *plugins.Rule, fields *plugins.FieldCollection) interface{} {
return func() (int, error) {
return getMaxQuoteIdx(plugins.DeriveChannel(m, nil))
return GetMaxQuoteIdx(db, plugins.DeriveChannel(m, nil))
}
})
@ -122,18 +122,18 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData
}
return false, errors.Wrap(
addQuote(plugins.DeriveChannel(m, eventData), quote),
AddQuote(db, plugins.DeriveChannel(m, eventData), quote),
"adding quote",
)
case "del":
return false, errors.Wrap(
delQuote(plugins.DeriveChannel(m, eventData), index),
DelQuote(db, plugins.DeriveChannel(m, eventData), index),
"storing quote database",
)
case "get":
idx, quote, err := getQuote(plugins.DeriveChannel(m, eventData), index)
idx, quote, err := GetQuote(db, plugins.DeriveChannel(m, eventData), index)
if err != nil {
return false, errors.Wrap(err, "getting quote")
}

View file

@ -1,120 +1,99 @@
package quotedb
import (
"database/sql"
"embed"
"math/rand"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/Luzifer/twitch-bot/pkg/database"
)
//go:embed schema/**
var schema embed.FS
type (
quote struct {
Channel string `gorm:"not null;uniqueIndex:ensure_sort_idx;size:32"`
CreatedAt int64 `gorm:"uniqueIndex:ensure_sort_idx"`
Quote string
}
)
func addQuote(channel, quote string) error {
_, err := db.DB().Exec(
`INSERT INTO quotedb
(channel, created_at, quote)
VALUES ($1, $2, $3);`,
channel, time.Now().UnixNano(), quote,
func AddQuote(db database.Connector, channel, quoteStr string) error {
return errors.Wrap(
db.DB().Create(quote{
Channel: channel,
CreatedAt: time.Now().UnixNano(),
Quote: quoteStr,
}).Error,
"adding quote to database",
)
return errors.Wrap(err, "adding quote to database")
}
func delQuote(channel string, quote int) error {
_, createdAt, _, err := getQuoteRaw(channel, quote)
func DelQuote(db database.Connector, channel string, quoteIdx int) error {
_, createdAt, _, err := GetQuoteRaw(db, channel, quoteIdx)
if err != nil {
return errors.Wrap(err, "fetching specified quote")
}
_, err = db.DB().Exec(
`DELETE FROM quotedb
WHERE channel = $1 AND created_at = $2;`,
channel, createdAt,
return errors.Wrap(
db.DB().Delete(&quote{}, "channel = ? AND created_at = ?", channel, createdAt).Error,
"deleting quote",
)
return errors.Wrap(err, "deleting quote")
}
func getChannelQuotes(channel string) ([]string, error) {
rows, err := db.DB().Query(
`SELECT quote
FROM quotedb
WHERE channel = $1
ORDER BY created_at ASC`,
channel,
)
if err != nil {
func GetChannelQuotes(db database.Connector, channel string) ([]string, error) {
var qs []quote
if err := db.DB().Where("channel = ?", channel).Order("created_at").Find(&qs).Error; err != nil {
return nil, errors.Wrap(err, "querying quotes")
}
var quotes []string
for rows.Next() {
if err = rows.Err(); err != nil {
return nil, errors.Wrap(err, "advancing row read")
}
var quote string
if err = rows.Scan(&quote); err != nil {
return nil, errors.Wrap(err, "scanning row")
}
quotes = append(quotes, quote)
for _, q := range qs {
quotes = append(quotes, q.Quote)
}
return quotes, errors.Wrap(rows.Err(), "advancing row read")
return quotes, nil
}
func getMaxQuoteIdx(channel string) (int, error) {
row := db.DB().QueryRow(
`SELECT COUNT(1) as quoteCount
FROM quotedb
WHERE channel = $1;`,
channel,
)
func GetMaxQuoteIdx(db database.Connector, channel string) (int, error) {
var count int64
if err := db.DB().
Model(&quote{}).
Where("channel = ?", channel).
Count(&count).
Error; err != nil {
return 0, errors.Wrap(err, "getting quote count")
}
var count int
err := row.Scan(&count)
return count, errors.Wrap(err, "getting quote count")
return int(count), nil
}
func getQuote(channel string, quote int) (int, string, error) {
quoteIdx, _, quoteText, err := getQuoteRaw(channel, quote)
func GetQuote(db database.Connector, channel string, quote int) (int, string, error) {
quoteIdx, _, quoteText, err := GetQuoteRaw(db, channel, quote)
return quoteIdx, quoteText, err
}
func getQuoteRaw(channel string, quote int) (int, int64, string, error) {
if quote == 0 {
max, err := getMaxQuoteIdx(channel)
func GetQuoteRaw(db database.Connector, channel string, quoteIdx int) (int, int64, string, error) {
if quoteIdx == 0 {
max, err := GetMaxQuoteIdx(db, channel)
if err != nil {
return 0, 0, "", errors.Wrap(err, "getting max quote idx")
}
quote = rand.Intn(max) + 1 // #nosec G404 // no need for cryptographic safety
quoteIdx = rand.Intn(max) + 1 // #nosec G404 // no need for cryptographic safety
}
row := db.DB().QueryRow(
`SELECT created_at, quote
FROM quotedb
WHERE channel = $1
ORDER BY created_at ASC
LIMIT 1 OFFSET $2`,
channel, quote-1,
)
var q quote
err := db.DB().
Where("channel = ?", channel).
Limit(1).
Offset(quoteIdx - 1).
First(&q).Error
var (
createdAt int64
quoteText string
)
err := row.Scan(&createdAt, &quoteText)
switch {
case err == nil:
return quote, createdAt, quoteText, nil
return quoteIdx, q.CreatedAt, q.Quote, nil
case errors.Is(err, sql.ErrNoRows):
case errors.Is(err, gorm.ErrRecordNotFound):
return 0, 0, "", nil
default:
@ -122,52 +101,43 @@ func getQuoteRaw(channel string, quote int) (int, int64, string, error) {
}
}
func setQuotes(channel string, quotes []string) error {
tx, err := db.DB().Begin()
if err != nil {
return errors.Wrap(err, "creating transaction")
}
func SetQuotes(db database.Connector, channel string, quotes []string) error {
return errors.Wrap(
db.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel = ?", channel).Delete(&quote{}).Error; err != nil {
return errors.Wrap(err, "deleting quotes for channel")
}
if _, err = tx.Exec(
`DELETE FROM quotedb
WHERE channel = $1;`,
channel,
); err != nil {
defer tx.Rollback()
return errors.Wrap(err, "deleting quotes for channel")
}
t := time.Now()
for _, quoteStr := range quotes {
if err := tx.Create(quote{
Channel: channel,
CreatedAt: t.UnixNano(),
Quote: quoteStr,
}).Error; err != nil {
return errors.Wrap(err, "adding quote")
}
t := time.Now()
for _, quote := range quotes {
if _, err = tx.Exec(
`INSERT INTO quotedb
(channel, created_at, quote)
VALUES ($1, $2, $3);`,
channel, t.UnixNano(), quote,
); err != nil {
defer tx.Rollback()
return errors.Wrap(err, "adding quote for channel")
}
t = t.Add(time.Nanosecond) // Increase by one ns to adhere to unique index
}
t = t.Add(time.Nanosecond) // Increase by one ns to adhere to unique index
}
return errors.Wrap(tx.Commit(), "committing change")
return nil
}),
"replacing quotes",
)
}
func updateQuote(channel string, idx int, quote string) error {
_, createdAt, _, err := getQuoteRaw(channel, idx)
func UpdateQuote(db database.Connector, channel string, idx int, quoteStr string) error {
_, createdAt, _, err := GetQuoteRaw(db, channel, idx)
if err != nil {
return errors.Wrap(err, "fetching specified quote")
}
_, err = db.DB().Exec(
`UPDATE quotedb
SET quote = $3
WHERE channel = $1
AND created_at = $2;`,
channel, createdAt, quote,
return errors.Wrap(
db.DB().
Where("channel = ? AND created_at = ?", channel, createdAt).
Update("quote", quoteStr).
Error,
"updating quote",
)
return errors.Wrap(err, "updating quote")
}

View file

@ -0,0 +1,61 @@
package quotedb
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func TestQuotesRoundtrip(t *testing.T) {
dbc := database.GetTestDatabase(t)
require.NoError(t, dbc.DB().AutoMigrate(&quote{}))
var (
channel = "#test"
quotes = []string{
"I'm a quote",
"I might have been said...",
"Testing rocks",
"Lets add some more",
"...or not",
}
)
cq, err := GetChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying empty database")
assert.Zero(t, cq, "expecting no quotes")
for i, q := range quotes {
assert.NoError(t, AddQuote(dbc, channel, q), "adding quote %d", i)
}
cq, err = GetChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database")
assert.Equal(t, quotes, cq, "checkin order and presence of quotes")
assert.NoError(t, DelQuote(dbc, channel, 1), "removing one quote")
assert.NoError(t, DelQuote(dbc, channel, 1), "removing one quote")
cq, err = GetChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database")
assert.Len(t, cq, len(quotes)-2, "expecting quotes in db")
assert.NoError(t, SetQuotes(dbc, channel, quotes), "replacing quotes")
cq, err = GetChannelQuotes(dbc, channel)
assert.NoError(t, err, "querying database")
assert.Equal(t, quotes, cq, "checkin order and presence of quotes")
idx, q, err := GetQuote(dbc, channel, 0)
assert.NoError(t, err, "getting random quote")
assert.NotZero(t, idx, "index must not be zero")
assert.NotZero(t, q, "quote must not be zero")
idx, q, err = GetQuote(dbc, channel, 3)
assert.NoError(t, err, "getting specific quote")
assert.Equal(t, 3, idx, "index must be 3")
assert.Equal(t, quotes[2], q, "quote must not the third")
}

View file

@ -133,7 +133,7 @@ func handleAddQuotes(w http.ResponseWriter, r *http.Request) {
}
for _, q := range quotes {
if err := addQuote(channel, q); err != nil {
if err := AddQuote(db, channel, q); err != nil {
http.Error(w, errors.Wrap(err, "adding quote").Error(), http.StatusInternalServerError)
return
}
@ -154,7 +154,7 @@ func handleDeleteQuote(w http.ResponseWriter, r *http.Request) {
return
}
if err = delQuote(channel, idx); err != nil {
if err = DelQuote(db, channel, idx); err != nil {
http.Error(w, errors.Wrap(err, "deleting quote").Error(), http.StatusInternalServerError)
return
}
@ -171,7 +171,7 @@ func handleListQuotes(w http.ResponseWriter, r *http.Request) {
channel := "#" + strings.TrimLeft(mux.Vars(r)["channel"], "#")
quotes, err := getChannelQuotes(channel)
quotes, err := GetChannelQuotes(db, channel)
if err != nil {
http.Error(w, errors.Wrap(err, "getting quotes").Error(), http.StatusInternalServerError)
return
@ -192,7 +192,7 @@ func handleReplaceQuotes(w http.ResponseWriter, r *http.Request) {
return
}
if err := setQuotes(channel, quotes); err != nil {
if err := SetQuotes(db, channel, quotes); err != nil {
http.Error(w, errors.Wrap(err, "replacing quotes").Error(), http.StatusInternalServerError)
return
}
@ -228,7 +228,7 @@ func handleUpdateQuote(w http.ResponseWriter, r *http.Request) {
return
}
if err = updateQuote(channel, idx, quotes[0]); err != nil {
if err = UpdateQuote(db, channel, idx, quotes[0]); err != nil {
http.Error(w, errors.Wrap(err, "updating quote").Error(), http.StatusInternalServerError)
return
}

View file

@ -1,7 +0,0 @@
CREATE TABLE quotedb (
channel STRING NOT NULL,
created_at INTEGER,
quote STRING NOT NULL,
UNIQUE(channel, created_at)
);

View file

@ -22,7 +22,7 @@ var (
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
if err := db.Migrate("setvariable", database.NewEmbedFSMigrator(schema, "schema")); err != nil {
if err := db.DB().AutoMigrate(&variable{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
@ -107,7 +107,7 @@ func Register(args plugins.RegistrationArguments) error {
})
args.RegisterTemplateFunction("variable", plugins.GenericTemplateFunctionGetter(func(name string, defVal ...string) (string, error) {
value, err := getVariable(name)
value, err := GetVariable(db, name)
if err != nil {
return "", errors.Wrap(err, "getting variable")
}
@ -131,7 +131,7 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
if attrs.MustBool("clear", ptrBoolFalse) {
return false, errors.Wrap(
removeVariable(varName),
RemoveVariable(db, varName),
"removing variable",
)
}
@ -142,7 +142,7 @@ func (a ActorSetVariable) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule
}
return false, errors.Wrap(
setVariable(varName, value),
SetVariable(db, varName, value),
"setting variable",
)
}
@ -159,7 +159,7 @@ func (a ActorSetVariable) Validate(attrs *plugins.FieldCollection) (err error) {
}
func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
vc, err := getVariable(mux.Vars(r)["name"])
vc, err := GetVariable(db, mux.Vars(r)["name"])
if err != nil {
http.Error(w, errors.Wrap(err, "getting value").Error(), http.StatusInternalServerError)
return
@ -170,7 +170,7 @@ func routeActorSetVarGetValue(w http.ResponseWriter, r *http.Request) {
}
func routeActorSetVarSetValue(w http.ResponseWriter, r *http.Request) {
if err := setVariable(mux.Vars(r)["name"], r.FormValue("value")); err != nil {
if err := SetVariable(db, mux.Vars(r)["name"], r.FormValue("value")); err != nil {
http.Error(w, errors.Wrap(err, "updating value").Error(), http.StatusInternalServerError)
return
}

View file

@ -1,30 +1,28 @@
package variables
import (
"database/sql"
"embed"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/Luzifer/twitch-bot/pkg/database"
)
//go:embed schema/**
var schema embed.FS
type (
variable struct {
Name string `gorm:"primaryKey"`
Value string
}
)
func getVariable(key string) (string, error) {
row := db.DB().QueryRow(
`SELECT value
FROM variables
WHERE name = $1`,
key,
)
var vc string
err := row.Scan(&vc)
func GetVariable(db database.Connector, key string) (string, error) {
var v variable
err := db.DB().First(&v, "name = ?", key).Error
switch {
case err == nil:
return vc, nil
return v.Value, nil
case errors.Is(err, sql.ErrNoRows):
case errors.Is(err, gorm.ErrRecordNotFound):
return "", nil // Compatibility to old behavior
default:
@ -32,25 +30,19 @@ func getVariable(key string) (string, error) {
}
}
func setVariable(key, value string) error {
_, err := db.DB().Exec(
`INSERT INTO variables
(name, value)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET value = excluded.value;`,
key, value,
func SetVariable(db database.Connector, key, value string) error {
return errors.Wrap(
db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(variable{Name: key, Value: value}).Error,
"updating value in database",
)
return errors.Wrap(err, "updating value in database")
}
func removeVariable(key string) error {
_, err := db.DB().Exec(
`DELETE FROM variables
WHERE name = $1;`,
key,
func RemoveVariable(db database.Connector, key string) error {
return errors.Wrap(
db.DB().Delete(&variable{}, "name = ?", key).Error,
"deleting value in database",
)
return errors.Wrap(err, "deleting value in database")
}

View file

@ -0,0 +1,36 @@
package variables
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func TestVariableRoundtrip(t *testing.T) {
dbc := database.GetTestDatabase(t)
require.NoError(t, dbc.DB().AutoMigrate(&variable{}), "applying migration")
var (
name = "myvar"
testValue = "ee5e4be5-f292-48aa-a177-cb9fd6f4e171"
)
v, err := GetVariable(dbc, name)
assert.NoError(t, err, "getting unset variable")
assert.Zero(t, v, "checking zero state on unset variable")
assert.NoError(t, SetVariable(dbc, name, testValue), "setting variable")
v, err = GetVariable(dbc, name)
assert.NoError(t, err, "getting set variable")
assert.NotZero(t, v, "checking non-zero state on set variable")
assert.NoError(t, RemoveVariable(dbc, name), "removing variable")
v, err = GetVariable(dbc, name)
assert.NoError(t, err, "getting removed variable")
assert.Zero(t, v, "checking zero state on removed variable")
}

View file

@ -1,4 +0,0 @@
CREATE TABLE variables (
name STRING NOT NULL PRIMARY KEY,
value STRING
);

View file

@ -2,73 +2,63 @@ package overlays
import (
"bytes"
"embed"
"encoding/json"
"strings"
"time"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/pkg/database"
"github.com/Luzifer/twitch-bot/plugins"
)
//go:embed schema/**
var schema embed.FS
type (
overlaysEvent struct {
Channel string `gorm:"not null;index:overlays_events_sort_idx"`
CreatedAt time.Time `gorm:"index:overlays_events_sort_idx"`
EventType string
Fields string
}
)
func addEvent(channel string, evt socketMessage) error {
func AddChannelEvent(db database.Connector, channel string, evt SocketMessage) error {
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(evt.Fields); err != nil {
return errors.Wrap(err, "encoding fields")
}
_, err := db.DB().Exec(
`INSERT INTO overlays_events
(channel, created_at, event_type, fields)
VALUES ($1, $2, $3, $4);`,
channel, evt.Time.UnixNano(), evt.Type, strings.TrimSpace(buf.String()),
return errors.Wrap(
db.DB().Create(overlaysEvent{
Channel: channel,
CreatedAt: evt.Time.UTC(),
EventType: evt.Type,
Fields: strings.TrimSpace(buf.String()),
}).Error,
"storing event to database",
)
return errors.Wrap(err, "storing event to database")
}
func getChannelEvents(channel string) ([]socketMessage, error) {
rows, err := db.DB().Query(
`SELECT created_at, event_type, fields
FROM overlays_events
WHERE channel = $1
ORDER BY created_at;`,
channel,
)
if err != nil {
func GetChannelEvents(db database.Connector, channel string) ([]SocketMessage, error) {
var evts []overlaysEvent
if err := db.DB().Where("channel = ?", channel).Order("created_at").Find(&evts).Error; err != nil {
return nil, errors.Wrap(err, "querying channel events")
}
var out []socketMessage
for rows.Next() {
if err = rows.Err(); err != nil {
return nil, errors.Wrap(err, "advancing row read")
}
var (
createdAt int64
eventType, rawFields string
)
if err = rows.Scan(&createdAt, &eventType, &rawFields); err != nil {
return nil, errors.Wrap(err, "scanning row")
}
var out []SocketMessage
for _, e := range evts {
fields := new(plugins.FieldCollection)
if err = json.NewDecoder(strings.NewReader(rawFields)).Decode(fields); err != nil {
if err := json.NewDecoder(strings.NewReader(e.Fields)).Decode(fields); err != nil {
return nil, errors.Wrap(err, "decoding fields")
}
out = append(out, socketMessage{
out = append(out, SocketMessage{
IsLive: false,
Time: time.Unix(0, createdAt),
Type: eventType,
Time: e.CreatedAt,
Type: e.EventType,
Fields: fields,
})
}
return out, errors.Wrap(rows.Err(), "advancing row read")
return out, nil
}

View file

@ -0,0 +1,54 @@
package overlays
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Luzifer/twitch-bot/pkg/database"
"github.com/Luzifer/twitch-bot/plugins"
)
func TestEventDatabaseRoundtrip(t *testing.T) {
dbc := database.GetTestDatabase(t)
require.NoError(t, dbc.DB().AutoMigrate(&overlaysEvent{}))
var (
channel = "#test"
tEvent1 = time.Now()
tEvent2 = tEvent1.Add(time.Second)
)
evts, err := GetChannelEvents(dbc, channel)
assert.NoError(t, err, "getting events on empty db")
assert.Zero(t, evts, "expect no events on empty db")
assert.NoError(t, AddChannelEvent(dbc, channel, SocketMessage{
IsLive: true,
Time: tEvent2,
Type: "event 2",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding second event")
assert.NoError(t, AddChannelEvent(dbc, channel, SocketMessage{
IsLive: true,
Time: tEvent1,
Type: "event 1",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding first event")
assert.NoError(t, AddChannelEvent(dbc, "#otherchannel", SocketMessage{
IsLive: true,
Time: tEvent1,
Type: "event",
Fields: plugins.FieldCollectionFromData(map[string]any{"foo": "bar"}),
}), "adding other channel event")
evts, err = GetChannelEvents(dbc, channel)
assert.NoError(t, err, "getting events")
assert.Len(t, evts, 2, "expect 2 events")
assert.Less(t, evts[0].Time, evts[1].Time, "expect sorting")
}

View file

@ -30,7 +30,7 @@ const (
)
type (
socketMessage struct {
SocketMessage struct {
IsLive bool `json:"is_live"`
Time time.Time `json:"time"`
Type string `json:"type"`
@ -65,7 +65,7 @@ var (
func Register(args plugins.RegistrationArguments) error {
db = args.GetDatabaseConnector()
if err := db.Migrate("overlays", database.NewEmbedFSMigrator(schema, "schema")); err != nil {
if err := db.DB().AutoMigrate(&overlaysEvent{}); err != nil {
return errors.Wrap(err, "applying schema migration")
}
@ -129,7 +129,7 @@ func Register(args plugins.RegistrationArguments) error {
}
return errors.Wrap(
addEvent(plugins.DeriveChannel(nil, eventData), socketMessage{
AddChannelEvent(db, plugins.DeriveChannel(nil, eventData), SocketMessage{
IsLive: false,
Time: time.Now(),
Type: event,
@ -156,7 +156,7 @@ func Register(args plugins.RegistrationArguments) error {
func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
var (
channel = mux.Vars(r)["channel"]
msgs []socketMessage
msgs []SocketMessage
since = time.Time{}
)
@ -164,7 +164,7 @@ func handleEventsReplay(w http.ResponseWriter, r *http.Request) {
since = s
}
events, err := getChannelEvents("#" + strings.TrimLeft(channel, "#"))
events, err := GetChannelEvents(db, "#"+strings.TrimLeft(channel, "#"))
if err != nil {
http.Error(w, errors.Wrap(err, "getting channel events").Error(), http.StatusInternalServerError)
return
@ -210,12 +210,12 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
connLock = new(sync.Mutex)
errC = make(chan error, 1)
isAuthorized bool
sendMsgC = make(chan socketMessage, 1)
sendMsgC = make(chan SocketMessage, 1)
)
// Register listener
unsub := subscribeSocket(func(event string, eventData *plugins.FieldCollection) {
sendMsgC <- socketMessage{
sendMsgC <- SocketMessage{
IsLive: true,
Time: time.Now(),
Type: event,
@ -269,7 +269,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
continue
}
var recvMsg socketMessage
var recvMsg SocketMessage
if err = json.Unmarshal(p, &recvMsg); err != nil {
errC <- errors.Wrap(err, "decoding message")
return
@ -290,7 +290,7 @@ func handleSocketSubscription(w http.ResponseWriter, r *http.Request) {
authTimeout.Stop()
isAuthorized = true
sendMsgC <- socketMessage{
sendMsgC <- SocketMessage{
IsLive: true,
Time: time.Now(),
Type: msgTypeRequestAuth,

View file

@ -1,9 +0,0 @@
CREATE TABLE overlays_events (
channel STRING NOT NULL,
created_at INTEGER,
event_type STRING,
fields STRING
);
CREATE INDEX overlays_events_sort_idx
ON overlays_events (channel, created_at DESC);

View file

@ -1,10 +1,11 @@
package access
import (
"database/sql"
"strings"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/pkg/database"
@ -26,11 +27,21 @@ type (
TokenUpdateHook func()
}
extendedPermission struct {
Channel string `gorm:"primaryKey"`
AccessToken string
RefreshToken string
Scopes string
}
Service struct{ db database.Connector }
)
func New(db database.Connector) *Service {
return &Service{db}
func New(db database.Connector) (*Service, error) {
return &Service{db}, errors.Wrap(
db.DB().AutoMigrate(&extendedPermission{}),
"migrating database schema",
)
}
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
@ -59,30 +70,26 @@ func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
}
func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*twitch.Client, error) {
var err error
row := s.db.DB().QueryRow(
`SELECT access_token, refresh_token, scopes
FROM extended_permissions
WHERE channel = $1`,
channel,
var (
err error
perm extendedPermission
)
var accessToken, refreshToken, scopeStr string
if err = row.Scan(&accessToken, &refreshToken, &scopeStr); err != nil {
return nil, errors.Wrap(err, "getting twitch credentials from database")
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
return nil, errors.Wrap(err, "getting twitch credential from database")
}
if accessToken, err = s.db.DecryptField(accessToken); err != nil {
if perm.AccessToken, err = s.db.DecryptField(perm.AccessToken); err != nil {
return nil, errors.Wrap(err, "decrypting access token")
}
if refreshToken, err = s.db.DecryptField(refreshToken); err != nil {
if perm.RefreshToken, err = s.db.DecryptField(perm.RefreshToken); err != nil {
return nil, errors.Wrap(err, "decrypting refresh token")
}
scopes := strings.Split(scopeStr, " ")
scopes := strings.Split(perm.Scopes, " ")
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, accessToken, refreshToken)
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, perm.AccessToken, perm.RefreshToken)
tc.SetTokenUpdateHook(func(at, rt string) error {
return errors.Wrap(s.SetExtendedTwitchCredentials(channel, at, rt, scopes), "updating extended permissions token")
})
@ -91,22 +98,19 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
}
func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) {
row := s.db.DB().QueryRow(
`SELECT scopes
FROM extended_permissions
WHERE channel = $1`,
channel,
var (
err error
perm extendedPermission
)
var scopeStr string
if err := row.Scan(&scopeStr); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, errors.Wrap(err, "getting scopes from database")
return false, errors.Wrap(err, "getting twitch credential from database")
}
storedScopes := strings.Split(scopeStr, " ")
storedScopes := strings.Split(perm.Scopes, " ")
for _, scope := range scopes {
if str.StringInSlice(scope, storedScopes) {
@ -118,22 +122,19 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b
}
func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) {
row := s.db.DB().QueryRow(
`SELECT scopes
FROM extended_permissions
WHERE channel = $1`,
channel,
var (
err error
perm extendedPermission
)
var scopeStr string
if err := row.Scan(&scopeStr); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, errors.Wrap(err, "getting scopes from database")
return false, errors.Wrap(err, "getting twitch credential from database")
}
storedScopes := strings.Split(scopeStr, " ")
storedScopes := strings.Split(perm.Scopes, " ")
for _, scope := range scopes {
if !str.StringInSlice(scope, storedScopes) {
@ -145,13 +146,10 @@ func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (boo
}
func (s Service) RemoveExendedTwitchCredentials(channel string) error {
_, err := s.db.DB().Exec(
`DELETE FROM extended_permissions
WHERE channel = $1`,
channel,
return errors.Wrap(
s.db.DB().Delete(&extendedPermission{}, "channel = ?", channel).Error,
"deleting data from table",
)
return errors.Wrap(err, "deleting data from table")
}
func (s Service) SetBotTwitchCredentials(accessToken, refreshToken string) (err error) {
@ -175,16 +173,16 @@ func (s Service) SetExtendedTwitchCredentials(channel, accessToken, refreshToken
return errors.Wrap(err, "encrypting refresh token")
}
_, err = s.db.DB().Exec(
`INSERT INTO extended_permissions
(channel, access_token, refresh_token, scopes)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO UPDATE SET
access_token=excluded.access_token,
refresh_token=excluded.refresh_token,
scopes=excluded.scopes;`,
channel, accessToken, refreshToken, strings.Join(scope, " "),
return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}},
DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "scopes"}),
}).Create(extendedPermission{
Channel: channel,
AccessToken: accessToken,
RefreshToken: refreshToken,
Scopes: strings.Join(scope, " "),
}).Error,
"inserting data into table",
)
return errors.Wrap(err, "inserting data into table")
}

View file

@ -1,4 +0,0 @@
CREATE TABLE timers (
id STRING NOT NULL PRIMARY KEY,
expires_at INTEGER
);

View file

@ -2,12 +2,13 @@ package timer
import (
"crypto/sha256"
"embed"
"fmt"
"strings"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/Luzifer/twitch-bot/pkg/database"
"github.com/Luzifer/twitch-bot/plugins"
@ -18,21 +19,21 @@ type (
db database.Connector
permitTimeout time.Duration
}
timer struct {
ID string `gorm:"primaryKey"`
ExpiresAt time.Time
}
)
var (
_ plugins.TimerStore = (*Service)(nil)
//go:embed schema/**
schema embed.FS
)
var _ plugins.TimerStore = (*Service)(nil)
func New(db database.Connector) (*Service, error) {
s := &Service{
db: db,
}
return s, errors.Wrap(s.db.Migrate("timersvc", database.NewEmbedFSMigrator(schema, "schema")), "applying migrations")
return s, errors.Wrap(s.db.DB().AutoMigrate(&timer{}), "applying migrations")
}
func (s *Service) UpdatePermitTimeout(d time.Duration) {
@ -42,11 +43,11 @@ func (s *Service) UpdatePermitTimeout(d time.Duration) {
// Cooldown timer
func (s Service) AddCooldown(tt plugins.TimerType, limiter, ruleID string, expiry time.Time) error {
return s.setTimer(s.getCooldownTimerKey(tt, limiter, ruleID), expiry)
return s.SetTimer(s.getCooldownTimerKey(tt, limiter, ruleID), expiry)
}
func (s Service) InCooldown(tt plugins.TimerType, limiter, ruleID string) (bool, error) {
return s.hasTimer(s.getCooldownTimerKey(tt, limiter, ruleID))
return s.HasTimer(s.getCooldownTimerKey(tt, limiter, ruleID))
}
func (Service) getCooldownTimerKey(tt plugins.TimerType, limiter, ruleID string) string {
@ -58,11 +59,11 @@ func (Service) getCooldownTimerKey(tt plugins.TimerType, limiter, ruleID string)
// Permit timer
func (s Service) AddPermit(channel, username string) error {
return s.setTimer(s.getPermitTimerKey(channel, username), time.Now().Add(s.permitTimeout))
return s.SetTimer(s.getPermitTimerKey(channel, username), time.Now().Add(s.permitTimeout))
}
func (s Service) HasPermit(channel, username string) (bool, error) {
return s.hasTimer(s.getPermitTimerKey(channel, username))
return s.HasTimer(s.getPermitTimerKey(channel, username))
}
func (Service) getPermitTimerKey(channel, username string) string {
@ -73,31 +74,30 @@ func (Service) getPermitTimerKey(channel, username string) string {
// Generic timer
func (s Service) hasTimer(id string) (bool, error) {
row := s.db.DB().QueryRow(
`SELECT COUNT(1) as active_counters
FROM timers
WHERE id = $1 AND expires_at >= $2`,
id, time.Now().UTC().Unix(),
)
func (s Service) HasTimer(id string) (bool, error) {
var t timer
err := s.db.DB().First(&t, "id = ? AND expires_at >= ?", id, time.Now().UTC()).Error
switch {
case err == nil:
return true, nil
var nCounters int64
if err := row.Scan(&nCounters); err != nil {
return false, errors.Wrap(err, "getting active counters from database")
case errors.Is(err, gorm.ErrRecordNotFound):
return false, nil
default:
return false, errors.Wrap(err, "getting timer information")
}
return nCounters > 0, nil
}
func (s Service) setTimer(id string, expiry time.Time) error {
_, err := s.db.DB().Exec(
`INSERT INTO timers
(id, expires_at)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET expires_at = excluded.expires_at;`,
id, expiry.UTC().Unix(),
func (s Service) SetTimer(id string, expiry time.Time) error {
return errors.Wrap(
s.db.DB().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"expires_at"}),
}).Create(timer{
ID: id,
ExpiresAt: expiry.UTC(),
}).Error,
"storing counter in database",
)
return errors.Wrap(err, "storing counter in database")
}

View file

@ -0,0 +1,37 @@
package timer
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func TestTimerRoundtrip(t *testing.T) {
dbc := database.GetTestDatabase(t)
ts, err := New(dbc)
require.NoError(t, err, "creating timer service")
id := "78c0176a-938e-497b-bed4-83d5bdec6caf"
has, err := ts.HasTimer(id)
require.NoError(t, err, "checking for non-existent timer")
assert.False(t, has, "checking existence of non-existing timer")
err = ts.SetTimer(id, time.Now().Add(500*time.Millisecond))
require.NoError(t, err, "setting timer")
has, err = ts.HasTimer(id)
require.NoError(t, err, "checking for existent timer")
assert.True(t, has, "checking existence of existing timer")
err = ts.SetTimer(id, time.Now().Add(-time.Millisecond))
require.NoError(t, err, "updating timer")
has, err = ts.HasTimer(id)
require.NoError(t, err, "checking for expired timer")
assert.False(t, has, "checking existence of expired timer")
}

View file

@ -3,12 +3,18 @@ package v2migrator
import (
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/internal/actors/counter"
"github.com/Luzifer/twitch-bot/internal/actors/variables"
"github.com/Luzifer/twitch-bot/internal/service/access"
"github.com/Luzifer/twitch-bot/internal/service/timer"
"github.com/Luzifer/twitch-bot/pkg/database"
)
func (s storageFile) migrateCoreKV(db database.Connector) (err error) {
as := access.New(db)
as, err := access.New(db)
if err != nil {
return errors.Wrap(err, "creating access service")
}
if err = as.SetBotTwitchCredentials(s.BotAccessToken, s.BotRefreshToken); err != nil {
return errors.Wrap(err, "setting bot credentials")
@ -22,15 +28,8 @@ func (s storageFile) migrateCoreKV(db database.Connector) (err error) {
}
func (s storageFile) migrateCounters(db database.Connector) (err error) {
for counter, value := range s.Counters {
if _, err = db.DB().Exec(
`INSERT INTO counters
(name, value)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET value = excluded.value;`,
counter, value,
); err != nil {
for counterName, value := range s.Counters {
if err = counter.UpdateCounter(db, counterName, value, true); err != nil {
return errors.Wrap(err, "storing counter value")
}
}
@ -39,7 +38,10 @@ func (s storageFile) migrateCounters(db database.Connector) (err error) {
}
func (s storageFile) migratePermissions(db database.Connector) (err error) {
as := access.New(db)
as, err := access.New(db)
if err != nil {
return errors.Wrap(err, "creating access service")
}
for channel, perms := range s.ExtendedPermissions {
if err = as.SetExtendedTwitchCredentials(
@ -56,15 +58,13 @@ func (s storageFile) migratePermissions(db database.Connector) (err error) {
}
func (s storageFile) migrateTimers(db database.Connector) (err error) {
ts, err := timer.New(db)
if err != nil {
return errors.Wrap(err, "creating timer service")
}
for id, expiry := range s.Timers {
if _, err := db.DB().Exec(
`INSERT INTO timers
(id, expires_at)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET expires_at = excluded.expires_at;`,
id, expiry.Time.Unix(),
); err != nil {
if err := ts.SetTimer(id, expiry.Time); err != nil {
return errors.Wrap(err, "storing counter in database")
}
}
@ -74,14 +74,7 @@ func (s storageFile) migrateTimers(db database.Connector) (err error) {
func (s storageFile) migrateVariables(db database.Connector) (err error) {
for key, value := range s.Variables {
if _, err = db.DB().Exec(
`INSERT INTO variables
(name, value)
VALUES ($1, $2)
ON CONFLICT DO UPDATE
SET value = excluded.value;`,
key, value,
); err != nil {
if err := variables.SetVariable(db, key, value); err != nil {
return errors.Wrap(err, "updating value in database")
}
}

View file

@ -1,42 +1,22 @@
package v2migrator
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/internal/apimodules/overlays"
"github.com/Luzifer/twitch-bot/pkg/database"
"github.com/Luzifer/twitch-bot/plugins"
)
type (
storageModOverlays struct {
ChannelEvents map[string][]struct {
IsLive bool `json:"is_live"`
Time time.Time `json:"time"`
Type string `json:"type"`
Fields *plugins.FieldCollection `json:"fields"`
} `json:"channel_events"`
ChannelEvents map[string][]overlays.SocketMessage `json:"channel_events"`
}
)
func (s storageModOverlays) migrate(db database.Connector) (err error) {
for channel, evts := range s.ChannelEvents {
for _, evt := range evts {
buf := new(bytes.Buffer)
if err = json.NewEncoder(buf).Encode(evt.Fields); err != nil {
return errors.Wrap(err, "encoding fields")
}
if _, err = db.DB().Exec(
`INSERT INTO overlays_events
(channel, created_at, event_type, fields)
VALUES ($1, $2, $3, $4);`,
channel, evt.Time.UnixNano(), evt.Type, strings.TrimSpace(buf.String()),
); err != nil {
if err := overlays.AddChannelEvent(db, channel, evt); err != nil {
return errors.Wrap(err, "storing event to database")
}
}

View file

@ -1,39 +0,0 @@
package v2migrator
import (
"time"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/pkg/database"
)
type (
storageModPunish struct {
ActiveLevels map[string]*struct {
LastLevel int `json:"last_level"`
Executed time.Time `json:"executed"`
Cooldown time.Duration `json:"cooldown"`
} `json:"active_levels"`
}
)
func (s storageModPunish) migrate(db database.Connector) (err error) {
for key, lc := range s.ActiveLevels {
if _, err = db.DB().Exec(
`INSERT INTO punish_levels
(key, last_level, executed, cooldown)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO UPDATE
SET last_level = excluded.last_level,
executed = excluded.executed,
cooldown = excluded.cooldown;`,
key,
lc.LastLevel, lc.Executed.UTC().Unix(), int64(lc.Cooldown),
); err != nil {
return errors.Wrap(err, "updating punishment info")
}
}
return nil
}

View file

@ -1,10 +1,9 @@
package v2migrator
import (
"time"
"github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/internal/actors/quotedb"
"github.com/Luzifer/twitch-bot/pkg/database"
)
@ -16,18 +15,8 @@ type (
func (s storageModQuoteDB) migrate(db database.Connector) (err error) {
for channel, quotes := range s.ChannelQuotes {
t := time.Now()
for _, quote := range quotes {
if _, err = db.DB().Exec(
`INSERT INTO quotedb
(channel, created_at, quote)
VALUES ($1, $2, $3);`,
channel, t.UnixNano(), quote,
); err != nil {
return errors.Wrap(err, "adding quote for channel")
}
t = t.Add(time.Nanosecond) // Increase by one ns to adhere to unique index
if err := quotedb.SetQuotes(db, channel, quotes); err != nil {
return errors.Wrap(err, "setting quotes for channel")
}
}

View file

@ -31,7 +31,6 @@ type (
Variables map[string]string `json:"variables"`
ModuleStorage struct {
ModPunish storageModPunish `json:"44ab4646-ce50-4e16-9353-c1f0eb68962b"`
ModOverlays storageModOverlays `json:"f9ca2b3a-baf6-45ea-a347-c626168665e8"`
ModQuoteDB storageModQuoteDB `json:"917c83ee-ed40-41e4-a558-1c2e59fdf1f5"`
} `json:"module_storage"`
@ -105,7 +104,6 @@ func (s storageFile) Migrate(db database.Connector) error {
"timers": s.migrateTimers,
"variables": s.migrateVariables,
// Modules
"mod_punish": s.ModuleStorage.ModPunish.migrate,
"mod_overlays": s.ModuleStorage.ModOverlays.migrate,
"mod_quotedb": s.ModuleStorage.ModQuoteDB.migrate,
} {

20
main.go
View file

@ -53,7 +53,8 @@ var (
IRCRateLimit time.Duration `flag:"rate-limit" default:"1500ms" description:"How often to send a message (default: 20/30s=1500ms, if your bot is mod everywhere: 100/30s=300ms, different for known/verified bots)"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
PluginDir string `flag:"plugin-dir" default:"/usr/lib/twitch-bot" description:"Where to find and load plugins"`
StorageDatabase string `flag:"storage-database" default:"./storage.db" description:"Database file to store data in"`
StorageConnString string `flag:"storage-conn-string" default:"./storage.db" description:"Connection string for the database"`
StorageConnType string `flag:"storage-conn-type" default:"sqlite" description:"One of: mysql, postgres, sqlite"`
StorageEncryptionPass string `flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)"`
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"`
TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"`
@ -207,19 +208,14 @@ func handleSubCommand(args []string) {
func main() {
var err error
databaseConnectionString := strings.Join([]string{
cfg.StorageDatabase,
strings.Join([]string{
"_pragma=locking_mode(EXCLUSIVE)",
"_pragma=synchronous(FULL)",
}, "&"),
}, "?")
if db, err = database.New("sqlite", databaseConnectionString, cfg.StorageEncryptionPass); err != nil {
log.WithError(err).Fatal("Unable to open storage database")
if db, err = database.New(cfg.StorageConnType, cfg.StorageConnString, cfg.StorageEncryptionPass); err != nil {
log.WithError(err).Fatal("Unable to open storage backend")
}
if accessService, err = access.New(db); err != nil {
log.WithError(err).Fatal("Unable to apply access migration")
}
accessService = access.New(db)
if timerService, err = timer.New(db); err != nil {
log.WithError(err).Fatal("Unable to apply timer migration")
}

View file

@ -1,42 +1,69 @@
package database
import (
"embed"
"regexp"
"database/sql"
"net/url"
"strings"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type (
connector struct {
db *sqlx.DB
db *gorm.DB
encryptionSecret string
}
)
var (
// ErrCoreMetaNotFound is the error thrown when reading a non-existent
// core_kv key
ErrCoreMetaNotFound = errors.New("core meta entry not found")
//go:embed schema/**
schema embed.FS
migrationFilename = regexp.MustCompile(`^([0-9]+)\.sql$`)
)
// ErrCoreMetaNotFound is the error thrown when reading a non-existent
// core_kv key
var ErrCoreMetaNotFound = errors.New("core meta entry not found")
// New creates a new Connector with the given driver and database
func New(driverName, dataSourceName, encryptionSecret string) (Connector, error) {
db, err := sqlx.Connect(driverName, dataSourceName)
func New(driverName, connString, encryptionSecret string) (Connector, error) {
var (
dbTuner func(*sql.DB, error) error
innerDB gorm.Dialector
)
switch driverName {
case "mysql":
innerDB = mysql.Open(connString)
case "postgres":
innerDB = postgres.Open(connString)
case "sqlite":
var err error
if connString, err = patchSQLiteConnString(connString); err != nil {
return nil, errors.Wrap(err, "patching connection string")
}
innerDB = sqlite.Open(connString)
dbTuner = tuneSQLiteDatabase
default:
return nil, errors.Errorf("unknown database driver %s", driverName)
}
db, err := gorm.Open(innerDB, &gorm.Config{
Logger: gormLogger(),
})
if err != nil {
return nil, errors.Wrap(err, "connecting database")
}
db.SetConnMaxIdleTime(0)
db.SetConnMaxLifetime(0)
db.SetMaxIdleConns(1)
db.SetMaxOpenConns(1)
if dbTuner != nil {
if err = dbTuner(db.DB()); err != nil {
return nil, errors.Wrap(err, "tuning database")
}
}
conn := &connector{
db: db,
@ -46,22 +73,53 @@ func New(driverName, dataSourceName, encryptionSecret string) (Connector, error)
}
func (c connector) Close() error {
return errors.Wrap(c.db.Close(), "closing database")
// return errors.Wrap(c.db.Close(), "closing database")
return nil
}
func (c connector) DB() *sqlx.DB {
func (c connector) DB() *gorm.DB {
return c.db
}
func (c connector) applyCoreSchema() error {
coreSQL, err := schema.ReadFile("schema/core.sql")
if err != nil {
return errors.Wrap(err, "reading core.sql content")
}
if _, err = c.db.Exec(string(coreSQL)); err != nil {
return errors.Wrap(err, "applying core schema")
}
return errors.Wrap(c.Migrate("core", NewEmbedFSMigrator(schema, "schema")), "applying core migration")
return errors.Wrap(c.db.AutoMigrate(&coreKV{}), "applying coreKV schema")
}
func gormLogger() logger.Interface {
return logger.New(
newLogrusLogWriterWithLevel(logrus.TraceLevel),
logger.Config{},
)
}
func patchSQLiteConnString(connString string) (string, error) {
u, err := url.Parse(connString)
if err != nil {
return connString, errors.Wrap(err, "parsing connString")
}
q := u.Query()
q.Add("_pragma", "locking_mode(EXCLUSIVE)")
q.Add("_pragma", "synchronous(FULL)")
u.RawQuery = strings.NewReplacer(
"%28", "(",
"%29", ")",
).Replace(q.Encode())
return u.String(), nil
}
func tuneSQLiteDatabase(db *sql.DB, err error) error {
if err != nil {
return errors.Wrap(err, "getting database")
}
db.SetConnMaxIdleTime(0)
db.SetConnMaxLifetime(0)
db.SetMaxIdleConns(1)
db.SetMaxOpenConns(1)
return nil
}

View file

@ -1,98 +1,44 @@
package database
import (
"path"
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testEncryptionPass = "password123"
func TestNewConnector(t *testing.T) {
dbc, err := New("sqlite", ":memory:", testEncryptionPass)
if err != nil {
t.Fatalf("creating database connector: %s", err)
}
defer dbc.Close()
row := dbc.DB().QueryRow("SELECT count(1) AS tables FROM sqlite_master WHERE type='table' AND name='core_kv';")
var count int
if err = row.Scan(&count); err != nil {
t.Fatalf("reading table count result")
cStrings := map[string]string{
"filesystem": path.Join(t.TempDir(), "storage.db"),
"memory": "file::memory:?cache=shared",
}
if count != 1 {
t.Errorf("expected to find one result, got %d in count of core_kv table", count)
for name := range cStrings {
t.Run(name, func(t *testing.T) {
dbc, err := New("sqlite", cStrings[name], testEncryptionPass)
require.NoError(t, err, "creating database connector")
t.Cleanup(func() { dbc.Close() })
row := dbc.DB().Raw("SELECT count(1) AS tables FROM sqlite_master WHERE type='table' AND name='core_kvs';")
var count int
assert.NoError(t, row.Scan(&count).Error, "reading table count result")
assert.Equal(t, 1, count)
})
}
}
func TestCoreMetaRoundtrip(t *testing.T) {
dbc, err := New("sqlite", ":memory:", testEncryptionPass)
if err != nil {
t.Fatalf("creating database connector: %s", err)
}
defer dbc.Close()
var (
arbitrary struct{ A string }
testKey = "arbitrary"
)
if err = dbc.ReadCoreMeta(testKey, &arbitrary); !errors.Is(err, ErrCoreMetaNotFound) {
t.Error("expected core_kv not to contain key after init")
}
checkWriteRead := func(testString string) {
arbitrary.A = testString
if err = dbc.StoreCoreMeta(testKey, arbitrary); err != nil {
t.Errorf("storing core_kv: %s", err)
}
arbitrary.A = "" // Clear to test unmarshal
if err = dbc.ReadCoreMeta(testKey, &arbitrary); err != nil {
t.Errorf("reading core_kv: %s", err)
}
if arbitrary.A != testString {
t.Errorf("expected meta entry to have %q, got %q", testString, arbitrary.A)
}
}
checkWriteRead("just a string") // Turn one: Init from not existing
checkWriteRead("another random string") // Turn two: Overwrite
}
func TestCoreMetaEncryption(t *testing.T) {
dbc, err := New("sqlite", ":memory:", testEncryptionPass)
if err != nil {
t.Fatalf("creating database connector: %s", err)
}
defer dbc.Close()
var (
arbitrary struct{ A string }
testKey = "arbitrary"
testString = "foobar"
)
arbitrary.A = testString
if err = dbc.StoreEncryptedCoreMeta(testKey, arbitrary); err != nil {
t.Fatalf("storing encrypted core meta: %s", err)
}
if err = dbc.ReadCoreMeta(testKey, &arbitrary); err == nil {
t.Error("reading encrypted meta without decryption succeeded")
}
arbitrary.A = ""
if err = dbc.ReadEncryptedCoreMeta(testKey, &arbitrary); err != nil {
t.Errorf("reading encrypted meta: %s", err)
}
if arbitrary.A != testString {
t.Errorf("unexpected value: %q != %q", arbitrary.A, testString)
func TestPatchSQLiteConnString(t *testing.T) {
for in, out := range map[string]string{
"storage.db": "storage.db?_pragma=locking_mode(EXCLUSIVE)&_pragma=synchronous(FULL)",
"file::memory:?cache=shared": "file::memory:?_pragma=locking_mode(EXCLUSIVE)&_pragma=synchronous(FULL)&cache=shared",
} {
cs, err := patchSQLiteConnString(in)
require.NoError(t, err, "patching conn string %q", in)
assert.Equal(t, out, cs, "patching conn string %q", in)
}
}

View file

@ -2,11 +2,19 @@ package database
import (
"bytes"
"database/sql"
"encoding/json"
"strings"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type (
coreKV struct {
Name string `gorm:"primaryKey"`
Value string
}
)
// ReadCoreMeta reads an entry of the core_kv table specified by
@ -38,11 +46,10 @@ func (c connector) StoreEncryptedCoreMeta(key string, value any) error {
}
func (c connector) readCoreMeta(key string, value any, processor func(string) (string, error)) (err error) {
var data struct{ Key, Value string }
data.Key = key
var data coreKV
if err = c.db.Get(&data, "SELECT * FROM core_kv WHERE key = $1", data.Key); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if err = c.db.First(&data, "name = ?", key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCoreMetaNotFound
}
return errors.Wrap(err, "querying core meta table")
@ -78,13 +85,12 @@ func (c connector) storeCoreMeta(key string, value any, processor func(string) (
}
}
_, err = c.db.NamedExec(
"INSERT INTO core_kv (key, value) VALUES (:key, :value) ON CONFLICT DO UPDATE SET value=excluded.value;",
map[string]any{
"key": key,
"value": encValue,
},
data := coreKV{Name: key, Value: encValue}
return errors.Wrap(
c.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(data).Error,
"upserting core meta value",
)
return errors.Wrap(err, "upserting core meta value")
}

View file

@ -0,0 +1,51 @@
package database
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCoreMetaRoundtrip(t *testing.T) {
dbc := GetTestDatabase(t)
var (
arbitrary struct{ A string }
testKey = "arbitrary"
)
assert.ErrorIs(t, dbc.ReadCoreMeta(testKey, &arbitrary), ErrCoreMetaNotFound, "expected core_kv not to contain key after init")
checkWriteRead := func(testString string) {
arbitrary.A = testString
assert.NoError(t, dbc.StoreCoreMeta(testKey, arbitrary), "storing core_kv")
arbitrary.A = "" // Clear to test unmarshal
assert.NoError(t, dbc.ReadCoreMeta(testKey, &arbitrary), "reading core_kv")
assert.Equal(t, testString, arbitrary.A, "metadata equals")
}
checkWriteRead("just a string") // Turn one: Init from not existing
checkWriteRead("another random string") // Turn two: Overwrite
}
func TestCoreMetaEncryption(t *testing.T) {
dbc := GetTestDatabase(t)
var (
arbitrary struct{ A string }
testKey = "arbitrary"
testString = "foobar"
)
arbitrary.A = testString
assert.NoError(t, dbc.StoreEncryptedCoreMeta(testKey, arbitrary), "storing encrypted core meta")
assert.Error(t, dbc.ReadCoreMeta(testKey, &arbitrary), "reading encrypted meta without decryption succeeded")
arbitrary.A = ""
assert.NoError(t, dbc.ReadEncryptedCoreMeta(testKey, &arbitrary), "reading encrypted meta")
assert.Equal(t, testString, arbitrary.A, "unexpected value")
}

View file

@ -3,12 +3,7 @@
package database
import (
"io/fs"
"github.com/jmoiron/sqlx"
// Included support for pure-go sqlite
_ "github.com/glebarez/go-sqlite"
"gorm.io/gorm"
)
type (
@ -16,8 +11,7 @@ type (
// convenience methods
Connector interface {
Close() error
DB() *sqlx.DB
Migrate(module string, migrations MigrationStorage) error
DB() *gorm.DB
ReadCoreMeta(key string, value any) error
StoreCoreMeta(key string, value any) error
ReadEncryptedCoreMeta(key string, value any) error
@ -25,26 +19,4 @@ type (
DecryptField(string) (string, error)
EncryptField(string) (string, error)
}
// MigrationStorage represents a file storage containing migration
// files to migrate a namespace to its desired state. The files
// MUST be named in the schema `[0-9]+\.sql`.
//
// The storage is scanned recursively and all files are then
// string-sorted by their base-name (`/migrations/001.sql => 001.sql`).
// The last executed number is stored in numeric format, the next
// migration which basename evaluates to higher numeric will be
// executed.
//
// Numbers MUST be consecutive and MUST NOT leave out a number. A
// missing number will result in the migration processing not to
// catch up any migration afterwards.
//
// The first migration MUST be number 1
//
// Previously executed migrations MUST NOT be modified!
MigrationStorage interface {
ReadDir(name string) ([]fs.DirEntry, error)
ReadFile(name string) ([]byte, error)
}
)

21
pkg/database/logger.go Normal file
View file

@ -0,0 +1,21 @@
package database
import (
"fmt"
"io"
"github.com/sirupsen/logrus"
)
type (
logWriter struct{ io.Writer }
)
func newLogrusLogWriterWithLevel(level logrus.Level) logWriter {
writer := logrus.StandardLogger().WriterLevel(level)
return logWriter{writer}
}
func (l logWriter) Printf(format string, a ...any) {
fmt.Fprintf(l.Writer, format, a...)
}

View file

@ -1,94 +0,0 @@
package database
import (
"path"
"strconv"
"strings"
"github.com/pkg/errors"
)
func (c connector) Migrate(module string, migrations MigrationStorage) error {
m, err := collectMigrations(migrations, "/")
if err != nil {
return errors.Wrap(err, "collecting migrations")
}
migrationKey := strings.Join([]string{"migration_state", module}, "-")
var lastMigration int
if err = c.ReadCoreMeta(migrationKey, &lastMigration); err != nil && !errors.Is(err, ErrCoreMetaNotFound) {
return errors.Wrap(err, "getting last migration")
}
nextMigration := lastMigration
for {
nextMigration++
filename := m[nextMigration]
if filename == "" {
break
}
if err = c.applyMigration(migrations, filename); err != nil {
return errors.Wrapf(err, "applying migration %d", nextMigration)
}
if err = c.StoreCoreMeta(migrationKey, nextMigration); err != nil {
return errors.Wrap(err, "updating migration number")
}
}
return nil
}
func (c connector) applyMigration(migrations MigrationStorage, filename string) error {
rawMigration, err := migrations.ReadFile(filename)
if err != nil {
return errors.Wrap(err, "reading migration file")
}
_, err = c.db.Exec(string(rawMigration))
return errors.Wrap(err, "executing migration statement(s)")
}
func collectMigrations(migrations MigrationStorage, dir string) (map[int]string, error) {
out := map[int]string{}
entries, err := migrations.ReadDir(dir)
if err != nil {
return nil, errors.Wrapf(err, "reading dir %q", dir)
}
for _, e := range entries {
if e.IsDir() {
sout, err := collectMigrations(migrations, path.Join(dir, e.Name()))
if err != nil {
return nil, errors.Wrapf(err, "scanning subdir %q", e.Name())
}
for n, p := range sout {
if out[n] != "" {
return nil, errors.Errorf("migration %d found more than once", n)
}
out[n] = p
}
continue
}
if !migrationFilename.MatchString(e.Name()) {
continue
}
matches := migrationFilename.FindStringSubmatch(e.Name())
n, err := strconv.Atoi(matches[1])
if err != nil {
return nil, errors.Wrap(err, "parsing migration number")
}
out[n] = path.Join(dir, e.Name())
}
return out, nil
}

View file

@ -1,35 +0,0 @@
package database
import (
"embed"
"io/fs"
"path"
"strings"
)
type (
// EmbedFSMigrator is a wrapper around embed.FS enabling ReadDir("/")
// which normally would cause an error as path "/" is not available
// within an embed.FS
EmbedFSMigrator struct {
BasePath string
embed.FS
}
)
// NewEmbedFSMigrator creates a new EmbedFSMigrator
func NewEmbedFSMigrator(fs embed.FS, basePath string) MigrationStorage {
return EmbedFSMigrator{BasePath: basePath, FS: fs}
}
// ReadDir Wraps embed.FS.ReadDir with adjustment of the path prefix
func (e EmbedFSMigrator) ReadDir(name string) ([]fs.DirEntry, error) {
name = path.Join(e.BasePath, strings.TrimPrefix(name, "/"))
return e.FS.ReadDir(name)
}
// ReadFile Wraps embed.FS.ReadFile with adjustment of the path prefix
func (e EmbedFSMigrator) ReadFile(name string) ([]byte, error) {
name = path.Join(e.BasePath, strings.TrimPrefix(name, "/"))
return e.FS.ReadFile(name)
}

View file

@ -1,42 +0,0 @@
package database
import (
"embed"
"testing"
)
var (
//go:embed testdata/migration1/**
testMigration1 embed.FS
//go:embed testdata/migration2/**
testMigration2 embed.FS
)
func TestMigration(t *testing.T) {
dbc, err := New("sqlite", ":memory:", testEncryptionPass)
if err != nil {
t.Fatalf("creating database connector: %s", err)
}
defer dbc.Close()
var (
tm1 = NewEmbedFSMigrator(testMigration1, "testdata")
tm2 = NewEmbedFSMigrator(testMigration2, "testdata")
)
if err = dbc.Migrate("test", tm1); err != nil {
t.Errorf("migration 1 take 1: %s", err)
}
if err = dbc.Migrate("test", tm1); err != nil {
t.Errorf("migration 1 take 2: %s", err)
}
if err = dbc.Migrate("test", tm2); err != nil {
t.Errorf("migration 2 take 1: %s", err)
}
if err = dbc.Migrate("test", tm2); err != nil {
t.Errorf("migration 2 take 2: %s", err)
}
}

View file

@ -1,6 +0,0 @@
CREATE TABLE extended_permissions (
channel STRING NOT NULL PRIMARY KEY,
access_token STRING,
refresh_token STRING,
scopes STRING
);

View file

@ -1,6 +0,0 @@
-- Core database structure, to be applied before any migration
CREATE TABLE IF NOT EXISTS core_kv (
key STRING NOT NULL PRIMARY KEY,
value STRING
);

View file

@ -1,4 +0,0 @@
CREATE TABLE testdata (
key STRING NOT NULL PRIMARY KEY,
value STRING
);

View file

@ -1,4 +0,0 @@
CREATE TABLE testdata (
key STRING NOT NULL PRIMARY KEY,
value STRING
);

View file

@ -1 +0,0 @@
ALTER TABLE testdata ADD COLUMN another_value STRING;

View file

@ -0,0 +1,15 @@
package database
import (
"testing"
"github.com/stretchr/testify/require"
)
func GetTestDatabase(t *testing.T) Connector {
dbc, err := New("sqlite", "file::memory:?cache=shared", "encpass")
require.NoError(t, err, "creating database connector")
t.Cleanup(func() { dbc.Close() })
return dbc
}

View file

@ -73,9 +73,9 @@ var (
func initCorePlugins() error {
args := getRegistrationArguments()
for _, rf := range corePluginRegistrations {
for idx, rf := range corePluginRegistrations {
if err := rf(args); err != nil {
return errors.Wrap(err, "registering core plugin")
return errors.Wrapf(err, "registering core plugin %d", idx)
}
}
return nil