1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

..

77 Commits

Author SHA1 Message Date
466f2e1786 fix: summary caching (resolve #211) 2021-06-19 12:47:35 +02:00
82b8951437 fix: attempt to fix failing sqlite migrations (resolve #210) 2021-06-13 11:43:24 +02:00
25464e9519 chore: code smells 2021-06-13 10:14:15 +02:00
650fffa344 fix: exclude zero entries again 2021-06-12 12:06:24 +02:00
69627fbe11 fix: exclude zero entries 2021-06-12 12:04:38 +02:00
561198b203 chore: minor ui improvements 2021-06-12 12:01:20 +02:00
7c4a2024b6 chore: link to labels settings 2021-06-12 11:40:13 +02:00
7bcd6890d1 chore: adapt tests and bump version 2021-06-12 11:26:15 +02:00
1e4e530c21 chore: adapt tests 2021-06-12 11:09:24 +02:00
490cca05eb feat: ui for managing project labels 2021-06-12 10:44:19 +02:00
3780ae4255 fix: invalidate user summary cache (fix #209) 2021-06-12 10:43:56 +02:00
628ea0b9dd fix: nil pointer dereference
chore: allow to share labels publicly on settings page
2021-06-12 09:12:28 +02:00
0d64858721 feat: implement project labels (resolve #204) 2021-06-11 20:59:34 +02:00
c1c78d8d5b test: add more api tests 2021-06-11 17:47:33 +02:00
538b9d2463 fix: permissions for stats endpoint 2021-06-11 17:41:45 +02:00
f4612fd542 fix: badge endpoint permission fixes (resolve #205)
fix: reference past x days intervals from now instead of start of day
2021-06-11 16:02:28 +02:00
fb643571d2 Merge remote-tracking branch 'origin/master' 2021-06-10 23:22:58 +02:00
101fdfb957 chore: adapt default insert batch size (fix #206)
fix: set user data flag after import (fix #207)
2021-06-10 23:22:04 +02:00
a4d47fb566 test: more api tests [ci skip] 2021-05-29 09:52:26 +02:00
1a808f9197 feat: basic integration / api tests (wip) (resolve #9) 2021-05-28 17:14:16 +02:00
ee31212cdd fix: hotfix for invalid api base url prefix (#203) 2021-05-19 10:18:18 +02:00
712949afc7 chore: minor optimization to heartbeats by multi-user query 2021-05-14 09:38:31 +02:00
9dbc2039fc chore: add random time offset to scheduled reports jobs 2021-05-04 21:04:11 +02:00
f3b738b250 fix: empty projects (resolve #197)
fix: potential division by zero (see #199)
2021-05-03 21:32:26 +02:00
cf3d293688 feat: implement wakatime projects endpoint (resolve #196) 2021-05-01 13:52:03 +02:00
0fbb554fc3 fix: respect timezone parameter for wakatime summary endpoint (resolve #195) 2021-05-01 12:46:53 +02:00
11b224fc24 fix: exact path matching for api endpoints (resolve #194) 2021-04-30 18:08:53 +02:00
0673c26043 fix: attempt to fix race condition when counting 2021-04-30 17:19:17 +02:00
8dc69c58cb chore: upgrade dependencies 2021-04-30 16:33:48 +02:00
99d8349277 fix: rebuild tailwind assets 2021-04-30 16:23:27 +02:00
cf14fc46ef chore: less verbose logging 2021-04-30 16:22:28 +02:00
ef9303e61e feat: settings dialog for mail reports 2021-04-30 16:20:24 +02:00
a4e7158db2 refactor: mail service abstraction layer 2021-04-30 15:17:07 +02:00
29c04c3ac5 feat: email reports (resolve #124) 2021-04-30 14:07:14 +02:00
1beca82875 feat: implement wakatime users endpoint (resolve #193) 2021-04-30 10:13:32 +02:00
b16f777cc7 docs: minor typos [ci skip] 2021-04-29 21:46:22 +02:00
cead20a505 Merge branch 'master' of https://github.com/MeerBiene/wakapi into MeerBiene-master 2021-04-29 21:44:53 +02:00
5a8287a06b chore: exclude static endpoints from sentry tracing
chore: include user info to sentry tracing again
2021-04-29 21:19:43 +02:00
37d4d58b57 fix: make wakatime summary endpoint date range inclusive (resolve #192) 2021-04-29 21:08:47 +02:00
7d03a9b12d add code to stats example, add metrics example 2021-04-28 23:35:30 +02:00
331ace3c1e chore: remove config script [ci ckip] 2021-04-28 22:31:36 +02:00
4dd77ded26 docs: quick run script in readme 2021-04-28 22:26:44 +02:00
0bccbffd80 chore: quick run script
fix: run in production mode by default
2021-04-28 22:20:25 +02:00
2b45b064eb fix: permit simple date time format in wakatime summaries endpoint (resolve #190) 2021-04-28 22:19:44 +02:00
5d8fc99b93 docs: clarify time zone comments [ci skip] 2021-04-27 08:50:39 +02:00
8231d76200 Merge branch '184-fix-time-zone'
# Conflicts:
#	views/settings.tpl.html
2021-04-26 21:28:39 +02:00
c6fd43a964 chore: log requests from json response util method 2021-04-26 21:26:59 +02:00
4ab657ebd5 fix: fix divide by zero (resolve #189) 2021-04-26 21:26:56 +02:00
0a07ac1dd4 docs: document thoughts about time zones 2021-04-25 21:41:41 +02:00
a64201c93b fix: timezone selector 2021-04-25 21:12:36 +02:00
b105b0fe1c chore: version 2021-04-25 21:05:58 +02:00
649c658923 chore: add same date tests 2021-04-25 21:05:05 +02:00
bc9191a514 chore: fix api key on instructions page 2021-04-25 21:05:05 +02:00
04690d287d chore: guess user timezone on signup 2021-04-25 21:05:05 +02:00
c142b525a4 refactor: time zone sensitivity (resolve #184) 2021-04-25 21:05:04 +02:00
304fa3b03f chore: add same date tests 2021-04-25 20:53:17 +02:00
e01e6575db chore: fix api key on instructions page 2021-04-25 20:07:15 +02:00
75e61c0dc3 chore: guess user timezone on signup 2021-04-25 20:02:45 +02:00
6973743f41 refactor: time zone sensitivity (resolve #184) 2021-04-25 14:15:18 +02:00
26ef93c1af chore: minor refactorings to custom time parsing logic 2021-04-25 09:21:21 +02:00
0556efd39a chore: minor tweaks to migration script 2021-04-23 15:50:00 +02:00
030181fb2f sqlitemigrate patches for larger datasets 2021-04-22 23:37:20 +02:00
8b9a9a1a42 fix: merge summaries by unique from date only 2021-04-19 21:14:35 +02:00
6576837396 chore: batch mode for sample data script 2021-04-19 21:01:09 +02:00
1a10a4fb21 fix: prevent duplicate summaries from being counted twice (resolve #179) 2021-04-19 20:48:07 +02:00
0e3ce1e9e4 fix: lock aggregation jobs to one at a time on a per-user basis (resolve #180) 2021-04-19 20:36:37 +02:00
50a54bde22 chore: usage instructions for sqlite migration script [ci-skip] 2021-04-18 11:08:28 +02:00
53f3a9d685 chore: make back button on settings page a relative link 2021-04-18 11:05:59 +02:00
c37278e660 chore: add option to silently fail in case of schema migration errors 2021-04-18 11:03:54 +02:00
e2deadfd44 chore: add experimental sqlite to mysql migration script 2021-04-18 10:59:13 +02:00
ed35e7b82d chore: increment version 2021-04-16 19:17:30 +02:00
b672859021 fix: rebuild tailwind 2021-04-16 17:09:23 +02:00
d3713017e3 fix: include icon library to fix missing emojis on some platforms (resolve #119) 2021-04-16 17:07:11 +02:00
dca736752e refactor: logging (resolve #169) 2021-04-16 16:02:55 +02:00
337b39481b chore: set basic security headers (resolve #174) 2021-04-16 12:35:49 +02:00
b9ea6530f9 fix: serve static file from local fs when on dev (fix #176) 2021-04-16 12:24:19 +02:00
a9739a6db0 fix: make range picker show actual range with ceiled to date (fix #175) 2021-04-16 11:53:37 +02:00
113 changed files with 8423 additions and 1414 deletions

5
.gitignore vendored
View File

@ -7,4 +7,9 @@ build
*.db *.db
config*.yml config*.yml
!config.default.yml !config.default.yml
!testing/config.testing.yml
pkged.go pkged.go
package.json
yarn.lock
package-lock.json
node_modules

130
README.md
View File

@ -4,14 +4,13 @@
<p align="center"> <p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi"> <img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a> <a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi"> <img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
</p> </p>
<p align="center"> <p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a> <a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a> <a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a> <a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p> </p>
@ -24,7 +23,7 @@
<span> | </span> <span> | </span>
<a href="#-features">Features</a> <a href="#-features">Features</a>
<span> | </span> <span> | </span>
<a href="#-how-to-use">How to use</a> <a href="#%EF%B8%8F-how-to-use">How to use</a>
<span> | </span> <span> | </span>
<a href="https://github.com/muety/wakapi/issues">Issues</a> <a href="https://github.com/muety/wakapi/issues">Issues</a>
<span> | </span> <span> | </span>
@ -45,6 +44,7 @@
* [API Endpoints](#-api-endpoints) * [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations) * [Integrations](#-integrations)
* [Best Practices](#-best-practices) * [Best Practices](#-best-practices)
* [Tests](#-tests)
* [Developer Notes](#-developer-notes) * [Developer Notes](#-developer-notes)
* [Support](#-support) * [Support](#-support)
* [FAQs](#-faqs) * [FAQs](#-faqs)
@ -59,6 +59,7 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Built by developers for developers * ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems * ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges * ✅ Badges
* ✅ Weekly E-Mail Reports
* ✅ REST API * ✅ REST API
* ✅ Partially compatible with WakaTime * ✅ Partially compatible with WakaTime
* ✅ WakaTime integration * ✅ WakaTime integration
@ -77,7 +78,12 @@ If you want to you out free, hosted cloud service, all you need to do is create
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕ However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
### 🐳 Option 2: Use Docker ### 📦 Option 2: Quick-run a Release
```bash
$ curl -L https://wakapi.dev/get | bash
```
### 🐳 Option 3: Use Docker
```bash ```bash
# Create a persistent volume # Create a persistent volume
$ docker volume create wakapi-data $ docker volume create wakapi-data
@ -94,20 +100,7 @@ $ docker run -d \
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment. If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
### 📦 Option 3: Run a release ### 🧑‍💻 Option 4: Compile and run from source
```bash
# Download the release and unpack it
$ wget https://github.com/muety/wakapi/releases/download/1.20.2/wakapi_linux_amd64.zip
$ unzip wakapi_linux_amd64.zip
# Optionally adapt config to your needs
$ vi config.yml
# Run it
$ ./wakapi
```
### 🧑‍💻 Option 4: Run from source
#### Prerequisites #### Prerequisites
* Go >= 1.16 (with `$GOPATH` properly set) * Go >= 1.16 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3)) * gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
@ -117,18 +110,18 @@ $ ./wakapi
#### Compile & Run #### Compile & Run
```bash ```bash
# Build the executable
$ go build -o wakapi
# Adapt config to your needs # Adapt config to your needs
$ cp config.default.yml config.yml $ cp config.default.yml config.yml
$ vi config.yml $ vi config.yml
# Build the executable
$ go build -o wakapi
# Run it # Run it
$ ./wakapi $ ./wakapi
``` ```
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies = true` in `config.yml`. **Note:** Check the comments `config.yml` for best practices regarding security configuration and more.
### 💻 Client Setup ### 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up. Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
@ -139,8 +132,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```ini ```ini
[settings] [settings]
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server # Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api/heartbeat api_url = http://localhost:3000/api
# Your Wakapi API key (get it from the web interface after having created an account) # Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
@ -175,14 +168,16 @@ You can specify configuration options either via a config file (default: `config
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) | | `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | | `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | | `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | | `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` | `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) | | `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details | | `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details | | `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details |
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) | | `sentry.dsn` | `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing | | `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry | | `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry | | `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
### Supported databases ### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported. Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
@ -246,23 +241,98 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact) ![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
<details>
<summary>Click to view code</summary>
```md
![](https://github-readme-stats.vercel.app/api/wakatime?username={yourusername}&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
```
</details>
<br>
### Github Readme Metrics Integration
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
Preview:
![](https://raw.githubusercontent.com/lowlighter/lowlighter/master/metrics.plugin.wakatime.svg)
<details>
<summary>Click to view code</summary>
```yml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_wakatime: yes
plugin_wakatime_token: ${{ secrets.WAKATIME_TOKEN }} # Required
plugin_wakatime_days: 7 # Display last week stats
plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs
plugin_wakatime_limit: 4 # Show 4 entries per graph
plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint
plugin_wakatime_user: .user.login # User
```
</details>
<br>
## 👍 Best Practices ## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml` However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 🤓 Developer Notes ## 🧪 Tests
### Running tests ### Unit Tests
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
#### How to run
```bash ```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./... $ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
``` ```
### Building Tailwind ### API Tests
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
#### Prerequisites (Linux only)
```bash
# 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
# 2. screen
$ sudo apt install screen # Fedora: sudo dnf install screen
# 3. newman
$ npm install -g newman
```
#### How to run (Linux only)
```bash
$ ./testing/run_api_tests.sh
```
## 🤓 Developer Notes
### Building web assets
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.
#### TailwindCSS
```bash ```bash
$ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css $ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css
``` ```
#### Iconify
```bash
$ yarn add -D @iconify/json-tools @iconify/json
$ node scripts/bundle_icons.js
```
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
## 🙏 Support ## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi! If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!

View File

@ -1,57 +1,64 @@
env: development env: production
server: server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6 listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https tls_key_path: # leave blank to not use https
port: 3000 port: 3000
base_path: / base_path: /
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
app: app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
inactive_days: 7 # time of previous days within a user must have logged in to be considered active report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
custom_languages: custom_languages:
vue: Vue vue: Vue
jsx: JSX jsx: JSX
svelte: Svelte svelte: Svelte
db: db:
host: # leave blank when using sqlite3 host: # leave blank when using sqlite3
port: # leave blank when using sqlite3 port: # leave blank when using sqlite3
user: # leave blank when using sqlite3 user: # leave blank when using sqlite3
password: # leave blank when using sqlite3 password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db) name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
dialect: sqlite3 # mysql, postgres, sqlite3 dialect: sqlite3 # mysql, postgres, sqlite3
charset: utf8mb4 # only used for mysql connections charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security: security:
password_salt: # CHANGE ! password_salt: # change this
insecure_cookies: false # You need to set this to 'true' when on localhost insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
cookie_max_age: 172800 cookie_max_age: 172800
allow_signup: true allow_signup: true
expose_metrics: false expose_metrics: false
sentry: sentry:
dsn: # leave blank to disable sentry integration dsn: # leave blank to disable sentry integration
enable_tracing: true # whether to use performance monitoring enable_tracing: true # whether to use performance monitoring
sample_rate: 0.75 # probability of tracing a request sample_rate: 0.75 # probability of tracing a request
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
mail: mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.) enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
smtp: # smtp settings when sending mails via smtp sender: Wakapi <noreply@wakapi.dev> # ignored for mailwhale
# smtp settings when sending mails via smtp
smtp:
host: host:
port: port:
username: username:
password: password:
tls: tls:
sender: Wakapi <noreply@wakapi.dev>
mailwhale: # mailwhale.dev settings when using mailwhale as sending service # mailwhale.dev settings when using mailwhale as sending service
mailwhale:
url: url:
client_id: client_id:
client_secret: client_secret:

View File

@ -4,18 +4,17 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/jinzhu/configor" "github.com/jinzhu/configor"
"github.com/muety/wakapi/data" "github.com/muety/wakapi/data"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -59,11 +58,13 @@ var emailProviders = []string{
var cfg *Config var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location") var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct { type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"` ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"` ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"` InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"` CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"` Colors map[string]map[string]string `yaml:"-"`
@ -80,16 +81,17 @@ type securityConfig struct {
} }
type dbConfig struct { type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"` Host string `env:"WAKAPI_DB_HOST"`
Port uint `env:"WAKAPI_DB_PORT"` Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"` User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"` Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"` Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `yaml:"-"` Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"` Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"` Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"` MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"` Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
} }
type serverConfig struct { type serverConfig struct {
@ -114,6 +116,7 @@ type mailConfig struct {
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"` Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
MailWhale MailwhaleMailConfig `yaml:"mailwhale"` MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
Smtp SMTPMailConfig `yaml:"smtp"` Smtp SMTPMailConfig `yaml:"smtp"`
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
} }
type MailwhaleMailConfig struct { type MailwhaleMailConfig struct {
@ -128,7 +131,6 @@ type SMTPMailConfig struct {
Username string `env:"WAKAPI_MAIL_SMTP_USER"` Username string `env:"WAKAPI_MAIL_SMTP_USER"`
Password string `env:"WAKAPI_MAIL_SMTP_PASS"` Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"` TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"`
} }
type Config struct { type Config struct {
@ -174,25 +176,28 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect { switch dbDialect {
default: default:
return func(db *gorm.DB) error { return func(db *gorm.DB) error {
if err := db.AutoMigrate(&models.User{}); err != nil { if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil { if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Alias{}); err != nil { if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil { if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Summary{}); err != nil { if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil { if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil { if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
return nil return nil
@ -200,56 +205,6 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
} }
} }
func (c *dbConfig) GetDialector() gorm.Dialector {
switch c.Dialect {
case SQLDialectMysql:
return mysql.New(mysql.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
})
case SQLDialectPostgres:
return postgres.New(postgres.Config{
DSN: postgresConnectionString(c),
})
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
}
return nil
}
func mysqlConnectionString(config *dbConfig) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
config.Charset,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
sslmode := "disable"
if config.Ssl {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
sslmode,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func (c *appConfig) GetCustomLanguages() map[string]string { func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false) return cloneStringMap(c.CustomLanguages, false)
} }
@ -266,6 +221,15 @@ func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true) return cloneStringMap(c.Colors["operating_systems"], true)
} }
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
s := strings.Split(c.ReportTimeWeekly, ",")[0]
return parseWeekday(s)
}
func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
}
func (c *serverConfig) GetPublicUrl() string { func (c *serverConfig) GetPublicUrl() string {
return strings.TrimSuffix(c.PublicUrl, "/") return strings.TrimSuffix(c.PublicUrl, "/")
} }
@ -287,8 +251,14 @@ func readColors() map[string]map[string]string {
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after. // Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
// $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue) // $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
// $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue) // $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
raw := data.ColorsFile
if IsDev(env) {
raw, _ = ioutil.ReadFile("data/colors.json")
}
var colors = make(map[string]map[string]string) var colors = make(map[string]map[string]string)
if err := json.Unmarshal(data.ColorsFile, &colors); err != nil { if err := json.Unmarshal(raw, &colors); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
} }
@ -318,6 +288,26 @@ func findString(needle string, haystack []string, defaultVal string) string {
return defaultVal return defaultVal
} }
func parseWeekday(s string) time.Weekday {
switch strings.ToLower(s) {
case "mon", strings.ToLower(time.Monday.String()):
return time.Monday
case "tue", strings.ToLower(time.Tuesday.String()):
return time.Tuesday
case "wed", strings.ToLower(time.Wednesday.String()):
return time.Wednesday
case "thu", strings.ToLower(time.Thursday.String()):
return time.Thursday
case "fri", strings.ToLower(time.Friday.String()):
return time.Friday
case "sat", strings.ToLower(time.Saturday.String()):
return time.Saturday
case "sun", strings.ToLower(time.Sunday.String()):
return time.Sunday
}
return time.Monday
}
func Set(config *Config) { func Set(config *Config) {
cfg = config cfg = config
} }
@ -335,6 +325,7 @@ func Load(version string) *Config {
logbuch.Fatal("failed to read config: %v", err) logbuch.Fatal("failed to read config: %v", err)
} }
env = config.Env
config.Version = strings.TrimSpace(version) config.Version = strings.TrimSpace(version)
config.App.Colors = readColors() config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type) config.Db.Dialect = resolveDbDialect(config.Db.Type)
@ -353,22 +344,27 @@ func Load(version string) *Config {
} }
} }
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
if config.Sentry.Dsn != "" { if config.Sentry.Dsn != "" {
logbuch.Info("enabling sentry integration") logbuch.Info("enabling sentry integration")
initSentry(config.Sentry, config.IsDev()) initSentry(config.Sentry, config.IsDev())
} }
// some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" { if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider) logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
} }
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
logbuch.Fatal("invalid interval set for report_time_weekly")
}
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
Set(config) Set(config)
return Get() return Get()

86
config/db.go Normal file
View File

@ -0,0 +1,86 @@
package config
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
/*
A quick note to myself including some clarifications about time zones.
- There are basically four time zones (at least in case of MySQL): (1) User, (2) Wakapi (host system), (3) MySQL server, (4) MySQL session
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps))
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
- Setting a `loc` parameter specifies what location parsed time.Time objects will be in, however, does not affect the session time zone setting (https://github.com/go-sql-driver/mysql#loc)
- I.e., when not setting `time_zone` in addition, the session time zone will probably default to the server time zone (UTC in case of Docker)
- Session time zone will result in conversions of inserted times from that time zone to UTC
- From my understanding, TIMESTAMP only stores a plain time value without tz information and then converts it only for retrieval to whatever tz is set for the session
- E.g., when inserting '2021-04-27 08:26:07' with session tz set to Europe/Berlin and then viewing the database table with UTC tz will return '2021-04-27 06:26:07' instead
- Currently, no session tz is set (only loc), so the database server will assume it receives UTC. However, as no tz is set when retrieving the values either, they are also going to be returned just as is and as long as `loc=Local` is set properly, they are parsed in Go code with the correct time zone
- As long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
- It is important to have same `time_zone` and `loc` parameters set when sending and receiving, no matter what it is (writing / reading in 'UTC' will yield same results as writing / reading in 'Europe/Berlin')
- "The session time zone setting affects display and storage of time values that are zone-sensitive. This includes the values displayed by functions such as NOW() or CURTIME(), and values stored in and retrieved from TIMESTAMP columns. Values for TIMESTAMP columns are converted from the session time zone to UTC for storage, and from UTC to the session time zone for retrieval." (https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html)
- Wakapi always uses time.Local for everything, i.e. all times in the database have to be interpreted with that tz
- New heartbeats are sent with Python-like Unix timestamps, i.e. are absolute points in time as therefore not subject to any kind of tz issues
- E.g. with Wakapi running in Europe/Berlin, 1619379014.7335322 (2021-04-25T19:30:14.733Z (UTC)) will be inserted as 2021-04-25T21:30:14.733+0200 (CEST), but obviously represents the exact same point in time no matter where it originated from
- The reason why we need to explicitly care about tzs in the first place is the fact that user's can request their data within intervals and the results should correspond to their tz
- Users from California wouldn't have to care about their heartbeats being stored in German time zone
- However, they DO care when requesting their summaries
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
*/
func (c *dbConfig) GetDialector() gorm.Dialector {
switch c.Dialect {
case SQLDialectMysql:
return mysql.New(mysql.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
})
case SQLDialectPostgres:
return postgres.New(postgres.Config{
DSN: postgresConnectionString(c),
})
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
}
return nil
}
func mysqlConnectionString(config *dbConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
config.Charset,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
sslmode := "disable"
if config.Ssl {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
sslmode,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}

24
config/eventbus.go Normal file
View File

@ -0,0 +1,24 @@
package config
import "github.com/leandro-lugaresi/hub"
type ApplicationEvent struct {
Type string
Payload interface{}
}
const (
TopicUser = "user.*"
EventUserUpdate = "user.update"
FieldPayload = "payload"
)
var eventHub *hub.Hub
func init() {
eventHub = hub.New()
}
func EventBus() *hub.Hub {
return eventHub
}

14
config/fs.go Normal file
View File

@ -0,0 +1,14 @@
package config
import (
"io/fs"
"os"
)
// ChooseFS returns a local (DirFS) file system when on 'dev' environment and the given go-embed file system otherwise
func ChooseFS(localDir string, embeddedFS fs.FS) fs.FS {
if Get().IsDev() {
return os.DirFS(localDir)
}
return embeddedFS
}

View File

@ -4,22 +4,107 @@ import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
) )
type SentryErrorWriter struct{} // How to: Logging
// Use logbuch.[Debug|Info|Warn|Error|Fatal]() by default
// Use config.Log().[Debug|Info|Warn|Error|Fatal]() when wanting the log to appear in Sentry as well
// TODO: extend sentry error logging to include context and stacktrace type capturingWriter struct {
// see https://github.com/muety/wakapi/issues/169 Writer io.Writer
func (s *SentryErrorWriter) Write(p []byte) (n int, err error) { Message string
sentry.CaptureMessage(string(p))
return os.Stderr.Write(p)
} }
func init() { func (c *capturingWriter) Clear() {
logbuch.SetOutput(os.Stdout, &SentryErrorWriter{}) c.Message = ""
}
func (c *capturingWriter) Write(p []byte) (n int, err error) {
c.Message = string(p)
return c.Writer.Write(p)
}
// SentryWrapperLogger is a wrapper around a logbuch.Logger that forwards events to Sentry in addition and optionally allows to attach a request context
type SentryWrapperLogger struct {
*logbuch.Logger
req *http.Request
outWriter *capturingWriter
errWriter *capturingWriter
}
func Log() *SentryWrapperLogger {
ow, ew := &capturingWriter{Writer: os.Stdout}, &capturingWriter{Writer: os.Stderr}
return &SentryWrapperLogger{
Logger: logbuch.NewLogger(ow, ew),
outWriter: ow,
errWriter: ew,
}
}
func (l *SentryWrapperLogger) Request(req *http.Request) *SentryWrapperLogger {
l.req = req
return l
}
func (l *SentryWrapperLogger) Debug(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Debug(msg, params...)
l.log(l.errWriter.Message, sentry.LevelDebug)
}
func (l *SentryWrapperLogger) Info(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Info(msg, params...)
l.log(l.errWriter.Message, sentry.LevelInfo)
}
func (l *SentryWrapperLogger) Warn(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Warn(msg, params...)
l.log(l.errWriter.Message, sentry.LevelWarning)
}
func (l *SentryWrapperLogger) Error(msg string, params ...interface{}) {
l.errWriter.Clear()
l.Logger.Error(msg, params...)
l.log(l.errWriter.Message, sentry.LevelError)
}
func (l *SentryWrapperLogger) Fatal(msg string, params ...interface{}) {
l.errWriter.Clear()
l.Logger.Fatal(msg, params...)
l.log(l.errWriter.Message, sentry.LevelFatal)
}
func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
event := sentry.NewEvent()
event.Level = level
event.Message = msg
if l.req != nil {
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
hub := h.(*sentry.Hub)
hub.Scope().SetRequest(l.req)
if u := getPrincipal(l.req); u != nil {
hub.Scope().SetUser(sentry.User{ID: u.ID})
}
hub.CaptureEvent(event)
return
}
}
sentry.CaptureEvent(event)
}
var excludedRoutes = []string{
"GET /assets",
"GET /api/health",
"GET /swagger-ui",
"GET /docs",
} }
func initSentry(config sentryConfig, debug bool) { func initSentry(config sentryConfig, debug bool) {
@ -34,8 +119,10 @@ func initSentry(config sentryConfig, debug bool) {
hub := sentry.GetHubFromContext(ctx.Span.Context()) hub := sentry.GetHubFromContext(ctx.Span.Context())
txName := hub.Scope().Transaction() txName := hub.Scope().Transaction()
if strings.HasPrefix(txName, "GET /assets") || strings.HasPrefix(txName, "GET /api/health") { for _, ex := range excludedRoutes {
return sentry.SampledFalse if strings.HasPrefix(txName, ex) {
return sentry.SampledFalse
}
} }
if txName == "POST /api/heartbeat" { if txName == "POST /api/heartbeat" {
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx) return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
@ -43,13 +130,10 @@ func initSentry(config sentryConfig, debug bool) {
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx) return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
}), }),
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
type principalGetter interface {
GetPrincipal() *models.User
}
if hint.Context != nil { if hint.Context != nil {
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok { if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
if p := req.Context().Value("principal"); p != nil { if u := getPrincipal(req); u != nil {
event.User.ID = p.(principalGetter).GetPrincipal().ID event.User.ID = u.ID
} }
} }
} }
@ -59,3 +143,13 @@ func initSentry(config sentryConfig, debug bool) {
logbuch.Fatal("failed to initialized sentry %v", err) logbuch.Fatal("failed to initialized sentry %v", err)
} }
} }
func getPrincipal(r *http.Request) *models.User {
type principalGetter interface {
GetPrincipal() *models.User
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal()
}
return nil
}

File diff suppressed because it is too large Load Diff

33
go.mod
View File

@ -6,29 +6,34 @@ require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0 github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.1.1 github.com/emvi/logbuch v1.2.0
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/getsentry/sentry-go v0.10.0 github.com/getsentry/sentry-go v0.10.0
github.com/go-co-op/gocron v0.3.3 github.com/go-co-op/gocron v1.5.0
github.com/go-openapi/spec v0.20.2 // indirect github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.4.2 github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.1.0 github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/jinzhu/configor v1.2.0 github.com/jackc/pgproto3/v2 v2.0.7 // indirect
github.com/jackc/pgx/v4 v4.11.0 // indirect
github.com/jinzhu/configor v1.2.1
github.com/leandro-lugaresi/hub v1.1.1
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mitchellh/hashstructure/v2 v2.0.1 github.com/mitchellh/hashstructure/v2 v2.0.1
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.6.1 github.com/stretchr/testify v1.7.0
github.com/swaggo/swag v1.7.0 github.com/swaggo/swag v1.7.0
go.uber.org/atomic v1.6.0 go.uber.org/atomic v1.7.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/tools v0.1.0 // indirect golang.org/x/tools v0.1.0 // indirect
gorm.io/driver/mysql v1.0.3 gorm.io/driver/mysql v1.0.6
gorm.io/driver/postgres v1.0.5 gorm.io/driver/postgres v1.0.8
gorm.io/driver/sqlite v1.1.3 gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.20.11 gorm.io/gorm v1.21.9
) )

364
go.sum
View File

@ -1,29 +1,64 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 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/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 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/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
@ -34,17 +69,31 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU= github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw= github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o=
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g= github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
@ -53,10 +102,16 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs= github.com/go-co-op/gocron v1.5.0 h1:tIiwAPwKGcazVFJTNmGe0wE73UpZSEHovoahqGGx9+c=
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M= github.com/go-co-op/gocron v1.5.0/go.mod h1:7MgKum7jD7YgIRj7Zx7K1iJKAf1MlSIsEieRl18+KyU=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
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/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
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-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
@ -71,38 +126,82 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY= github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog= github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
@ -119,8 +218,9 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk= github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA= github.com/jackc/pgconn v1.8.1 h1:ySBX7Q87vOMqKU2bbmKbUvtYhauDFclYbNDYIE1/h6s=
github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 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/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
@ -134,8 +234,9 @@ github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 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.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.0.7 h1:6Pwi1b3QdY65cuv6SyVO0FgPd5J3Bl7wf/nQQjinHMA=
github.com/jackc/pgproto3/v2 v2.0.7/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= 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/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@ -145,44 +246,54 @@ github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrU
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po= github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ= github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc= github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig= github.com/jackc/pgtype v1.7.0 h1:6f4kVsW01QftE38ufBYxKciO6gyioXSC0ABIRLcZrGs=
github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 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-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.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA= github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o= github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg= github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c= github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms= github.com/jackc/pgx/v4 v4.11.0 h1:J86tSWd3Y7nKjwT/43xZBvpi04keQWx8gNC2YkdJhZI=
github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 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 v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA= github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
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.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@ -191,85 +302,158 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8=
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 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.4/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.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.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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY= github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
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/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 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/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.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 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 v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
@ -278,14 +462,18 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo= github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
@ -295,6 +483,7 @@ github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
@ -302,15 +491,26 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.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.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= 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/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 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/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/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.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/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-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/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-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-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
@ -321,44 +521,76 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
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-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/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.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 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/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-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/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-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -371,19 +603,29 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/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/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 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-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/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-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-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-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
@ -394,18 +636,42 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -415,16 +681,22 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw= gorm.io/driver/mysql v1.0.6 h1:mA0XRPjIKi4bkE9nv+NKs6qj6QWOchqUSdWOcpd3x1E=
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA= gorm.io/driver/mysql v1.0.6/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc= gorm.io/driver/postgres v1.0.8 h1:PAgM+PaHOSAeroTjHkCHCBIHHoBIf9RgPWGo8dF2DA8=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c= gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc= gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.9 h1:INieZtn4P2Pw6xPJ8MzT0G4WUOsHq3RhfuDF1M6GW0E=
gorm.io/gorm v1.21.9/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
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= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=

22
main.go
View File

@ -49,6 +49,7 @@ var (
heartbeatRepository repositories.IHeartbeatRepository heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository keyValueRepository repositories.IKeyValueRepository
) )
@ -58,10 +59,12 @@ var (
heartbeatService services.IHeartbeatService heartbeatService services.IHeartbeatService
userService services.IUserService userService services.IUserService
languageMappingService services.ILanguageMappingService languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
summaryService services.ISummaryService summaryService services.ISummaryService
aggregationService services.IAggregationService aggregationService services.IAggregationService
mailService services.IMailService mailService services.IMailService
keyValueService services.IKeyValueService keyValueService services.IKeyValueService
reportService services.IReportService
miscService services.IMiscService miscService services.IMiscService
) )
@ -112,12 +115,13 @@ func main() {
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}) db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" { if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;") db.Raw("PRAGMA foreign_keys = ON;")
db.DisableForeignKeyConstraintWhenMigrating = true
} }
if config.IsDev() { if config.IsDev() {
db = db.Debug() db = db.Debug()
} }
sqlDb, _ := db.DB() sqlDb, err := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn)) sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn)) sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil { if err != nil {
@ -134,6 +138,7 @@ func main() {
heartbeatRepository = repositories.NewHeartbeatRepository(db) heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db) userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db) languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db) summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db) keyValueRepository = repositories.NewKeyValueRepository(db)
@ -141,16 +146,19 @@ func main() {
aliasService = services.NewAliasService(aliasRepository) aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository) userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository) languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService) summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService() mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository) keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
miscService = services.NewMiscService(userService, summaryService, keyValueService) miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks // Schedule background tasks
go aggregationService.Schedule() go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime() go miscService.ScheduleCountTotalTime()
go reportService.Schedule()
routes.Init() routes.Init()
@ -164,11 +172,13 @@ func main() {
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService) wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService) wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers // MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService) summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
homeHandler := routes.NewHomeHandler(keyValueService) homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService) loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService) imprintHandler := routes.NewImprintHandler(keyValueService)
@ -185,6 +195,7 @@ func main() {
if config.Sentry.Dsn != "" { if config.Sentry.Dsn != "" {
router.Use(middlewares.NewSentryMiddleware()) router.Use(middlewares.NewSentryMiddleware())
} }
rootRouter.Use(middlewares.NewSecurityMiddleware())
// Route registrations // Route registrations
homeHandler.RegisterRoutes(rootRouter) homeHandler.RegisterRoutes(rootRouter)
@ -201,11 +212,14 @@ func main() {
wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter) shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes // Static Routes
// https://github.com/golang/go/issues/43431 // https://github.com/golang/go/issues/43431
static, _ := fs.Sub(staticFiles, "static") embeddedStatic, _ := fs.Sub(staticFiles, "static")
static := conf.ChooseFS("static", embeddedStatic)
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)}) fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
router.PathPrefix("/contribute.json").Handler(fileServer) router.PathPrefix("/contribute.json").Handler(fileServer)
router.PathPrefix("/assets").Handler(fileServer) router.PathPrefix("/assets").Handler(fileServer)

32
middlewares/security.go Normal file
View File

@ -0,0 +1,32 @@
package middlewares
import (
"net/http"
)
var securityHeaders = map[string]string{
"Cross-Origin-Opener-Policy": "same-origin",
"Content-Security-Policy": "default-src 'self' 'unsafe-inline'; img-src 'self' https: data:; form-action 'self'; block-all-mixed-content;",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
}
// SecurityMiddleware is a handler to add some basic security headers to responses
type SecurityMiddleware struct {
handler http.Handler
}
func NewSecurityMiddleware() func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &SecurityMiddleware{h}
}
}
func (f *SecurityMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
for k, v := range securityHeaders {
if w.Header().Get(k) == "" {
w.Header().Set(k, v)
}
}
f.handler.ServeHTTP(w, r)
}

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
) )
// SentryMiddleware is a wrapper around sentryhttp to include user information to traces
type SentryMiddleware struct { type SentryMiddleware struct {
handler http.Handler handler http.Handler
} }

View File

@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
mock.Mock mock.Mock
} }
func (m *AliasRepositoryMock) GetAll() ([]*models.Alias, error) {
args := m.Called()
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) { func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s) args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1) return args.Get(0).([]*models.Alias), args.Error(1)

View File

@ -45,11 +45,21 @@ func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
return args.Get(0).([]*models.TimeByUser), args.Error(1) return args.Get(0).([]*models.TimeByUser), args.Error(1)
} }
func (m *HeartbeatServiceMock) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
args := m.Called(user)
return args.Get(0).(*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) { func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
args := m.Called(s, user) args := m.Called(s, user)
return args.Get(0).(*models.Heartbeat), args.Error(1) return args.Get(0).(*models.Heartbeat), args.Error(1)
} }
func (m *HeartbeatServiceMock) GetEntitySetByUser(u uint8, user *models.User) ([]string, error) {
args := m.Called(u, user)
return args.Get(0).([]string), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error { func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time) args := m.Called(time)
return args.Error(0) return args.Error(0)

View File

@ -0,0 +1,35 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type ProjectLabelServiceMock struct {
mock.Mock
}
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
args := p.Called(u)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
args := p.Called(l)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
args := p.Called(l)
return args.Error(0)
}

View File

@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
return args.Error(0) return args.Error(0)
} }
func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
args := m.Called()
return args.Get(0).([]*models.Summary), args.Error(1)
}
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) { func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
args := m.Called(user, time, time2) args := m.Called(user, time, time2)
return args.Get(0).([]*models.Summary), args.Error(1) return args.Get(0).([]*models.Summary), args.Error(1)

View File

@ -34,6 +34,11 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)
} }
func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetActive() ([]*models.User, error) { func (m *UserServiceMock) GetActive() ([]*models.User, error) {
args := m.Called() args := m.Called()
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)

View File

@ -1,6 +1,8 @@
package v1 package v1
import "github.com/muety/wakapi/models" import (
"github.com/muety/wakapi/models"
)
type HeartbeatsViewModel struct { type HeartbeatsViewModel struct {
Data []*HeartbeatEntry `json:"data"` Data []*HeartbeatEntry `json:"data"`
@ -22,4 +24,6 @@ type HeartbeatEntry struct {
UserId string `json:"user_id"` UserId string `json:"user_id"`
MachineNameId string `json:"machine_name_id"` MachineNameId string `json:"machine_name_id"`
UserAgentId string `json:"user_agent_id"` UserAgentId string `json:"user_agent_id"`
CreatedAt models.CustomTime `json:"created_at"`
ModifiedAt models.CustomTime `json:"created_at"`
} }

View File

@ -0,0 +1,11 @@
package v1
type ProjectsViewModel struct {
Data []*Project `json:"data"`
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Repository string `json:"repository"`
}

View File

@ -2,6 +2,7 @@ package v1
import ( import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"math"
"time" "time"
) )
@ -41,6 +42,10 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
DaysIncludingHolidays: numDays, DaysIncludingHolidays: numDays,
} }
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
data.DailyAverage = 0
}
editors := make([]*SummariesEntry, len(summary.Editors)) editors := make([]*SummariesEntry, len(summary.Editors))
for i, e := range summary.Editors { for i, e := range summary.Editors {
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor)) editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))

View File

@ -58,6 +58,7 @@ type SummariesRange struct {
} }
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
// TODO: implement filtering (https://github.com/muety/wakapi/issues/58)
data := make([]*SummariesData, len(summaries)) data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
@ -129,7 +130,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
defer wg.Done() defer wg.Done()
for i, e := range s.Languages { for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
} }
}(data) }(data)
@ -152,9 +152,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
} }
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry { func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds total := e.TotalFixed()
// TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second
hrs := int(total.Hours()) hrs := int(total.Hours())
mins := int((total - time.Duration(hrs)*time.Hour).Minutes()) mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds()) secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())

View File

@ -0,0 +1,55 @@
package v1
import (
"github.com/muety/wakapi/models"
"time"
)
const DefaultWakaUserDisplayName = "Anonymous User"
// partially compatible with https://wakatime.com/developers#users
type UserViewModel struct {
Data *User `json:"data"`
}
type User struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
FullName string `json:"full_name"`
Email string `json:"email"`
IsEmailPublic bool `json:"is_email_public"`
IsEmailConfirmed bool `json:"is_email_confirmed"`
TimeZone string `json:"timezone"`
LastHeartbeatAt models.CustomTime `json:"last_heartbeat_at"`
LastProject string `json:"last_project"`
LastPluginName string `json:"last_plugin_name"`
Username string `json:"username"`
Website string `json:"website"`
CreatedAt models.CustomTime `json:"created_at"`
ModifiedAt models.CustomTime `json:"modified_at"`
}
func NewFromUser(user *models.User) *User {
tz, _ := time.Now().Zone()
if user.Location != "" {
tz = user.Location
}
return &User{
ID: user.ID,
DisplayName: DefaultWakaUserDisplayName,
Email: user.Email,
TimeZone: tz,
Username: user.ID,
CreatedAt: user.CreatedAt,
ModifiedAt: user.CreatedAt,
}
}
func (u *User) WithLatestHeartbeat(h *models.Heartbeat) *User {
u.LastHeartbeatAt = h.Time
u.LastProject = h.Project
u.LastPluginName = h.Editor
return u
}

View File

@ -6,6 +6,7 @@ type Filters struct {
Language string Language string
Editor string Editor string
Machine string Machine string
Label string
} }
type FilterElement struct { type FilterElement struct {
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
return &Filters{Editor: key} return &Filters{Editor: key}
case SummaryMachine: case SummaryMachine:
return &Filters{Machine: key} return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
} }
return &Filters{} return &Filters{}
} }
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
return true, SummaryEditor, f.Editor return true, SummaryEditor, f.Editor
} else if f.Machine != "" { } else if f.Machine != "" {
return true, SummaryMachine, f.Machine return true, SummaryMachine, f.Machine
} else if f.Label != "" {
return true, SummaryLabel, f.Label
} }
return false, 0, "" return false, 0, ""
} }

View File

@ -5,6 +5,9 @@ import (
"strings" "strings"
) )
const HtmlType = "text/html; charset=UTF-8"
const PlainType = "text/html; charset=UTF-8"
type Mail struct { type Mail struct {
From MailAddress From MailAddress
To MailAddresses To MailAddresses
@ -15,13 +18,13 @@ type Mail struct {
func (m *Mail) WithText(text string) *Mail { func (m *Mail) WithText(text string) *Mail {
m.Body = text m.Body = text
m.Type = "text/plain; charset=UTF-8" m.Type = PlainType
return m return m
} }
func (m *Mail) WithHTML(html string) *Mail { func (m *Mail) WithHTML(html string) *Mail {
m.Body = html m.Body = html
m.Type = "text/html; charset=UTF-8" m.Type = HtmlType
return m return m
} }

View File

@ -0,0 +1,88 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestMailAddress_SingleRaw(t *testing.T) {
tests := []struct {
in string
out string
}{
{
"john.doe@example.org",
"john.doe@example.org",
},
{
"John Doe <john.doe@example.org>",
"john.doe@example.org",
},
{
"invalid",
"",
},
}
for _, test := range tests {
out := MailAddress(test.in).Raw()
assert.Equal(t, test.out, out)
}
}
func TestMailAddress_AllRaw(t *testing.T) {
tests := []struct {
in []string
out []string
}{
{
[]string{"john.doe@example.org", "foo@bar.com"},
[]string{"john.doe@example.org", "foo@bar.com"},
},
{
[]string{"John Doe <john.doe@example.org>", "foo@bar.com"},
[]string{"john.doe@example.org", "foo@bar.com"},
},
{
[]string{"john.doe@example.org", "invalid"},
[]string{"john.doe@example.org", ""},
},
}
for _, test := range tests {
out := castAddresses(test.in).RawStrings()
assert.EqualValues(t, test.out, out)
}
}
func TestMailAddress_AllValid(t *testing.T) {
tests := []struct {
in []string
out bool
}{
{
[]string{"john.doe@example.org", "foo@bar.com"},
true,
},
{
[]string{"John Doe <john.doe@example.org>", "ínvalid"},
false,
},
{
[]string{"", "invalid"},
false,
},
}
for _, test := range tests {
out := castAddresses(test.in).AllValid()
assert.EqualValues(t, test.out, out)
}
}
func castAddresses(addresses []string) (m MailAddresses) {
for _, a := range addresses {
m = append(m, MailAddress(a))
}
return m
}

13
models/project_label.go Normal file
View File

@ -0,0 +1,13 @@
package models
type ProjectLabel struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
ProjectKey string `json:"project"`
Label string `json:"label" gorm:"type:varchar(64)"`
}
func (l *ProjectLabel) IsValid() bool {
return l.ProjectKey != "" && l.Label != ""
}

10
models/report.go Normal file
View File

@ -0,0 +1,10 @@
package models
import "time"
type Report struct {
From time.Time
To time.Time
User *User
Summary *Summary
}

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -30,24 +29,24 @@ type Interval struct {
End time.Time End time.Time
} }
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time type CustomTime time.Time
func (j *CustomTime) MarshalJSON() ([]byte, error) { func (j *CustomTime) MarshalJSON() ([]byte, error) {
return json.Marshal(j.String()) return json.Marshal(j.T())
} }
func (j *CustomTime) UnmarshalJSON(b []byte) error { func (j *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) s := strings.Trim(string(b), "\"")
i, err := strconv.ParseInt(s, 10, 64) ts, err := strconv.ParseFloat(s, 64)
if err != nil { if err != nil {
return err return err
} }
t := time.Unix(0, i*int64(math.Pow10(19-len(s)))) t := time.Unix(0, int64(ts*1e9)) // ms to ns
*j = CustomTime(t) *j = CustomTime(t)
return nil return nil
} }
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
func (j *CustomTime) Scan(value interface{}) error { func (j *CustomTime) Scan(value interface{}) error {
var ( var (
t time.Time t time.Time
@ -56,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
switch value.(type) { switch value.(type) {
case string: case string:
// with sqlite, some queries (like GetLastByUser()) return dates as strings,
// however, most of the time they are returned as time.Time
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string)) t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
if err != nil { if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value)) return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
} }
case int64:
t = time.Unix(0, value.(int64))
break
case time.Time: case time.Time:
t = value.(time.Time) t = value.(time.Time)
break break
@ -76,18 +74,17 @@ func (j *CustomTime) Scan(value interface{}) error {
return nil return nil
} }
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) Value() (driver.Value, error) { func (j CustomTime) Value() (driver.Value, error) {
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil return t, nil
} }
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) String() string { func (j CustomTime) String() string {
t := time.Time(j) return j.T().String()
return t.Format("2006-01-02 15:04:05.000")
} }
func (j CustomTime) T() time.Time { func (j CustomTime) T() time.Time {

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"errors"
"sort" "sort"
"time" "time"
) )
@ -12,9 +13,11 @@ const (
SummaryEditor uint8 = 2 SummaryEditor uint8 = 2
SummaryOS uint8 = 3 SummaryOS uint8 = 3
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
) )
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default"
type Summary struct { type Summary struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key"`
@ -27,6 +30,7 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
type SummaryItems []*SummaryItem type SummaryItems []*SummaryItem
@ -47,6 +51,7 @@ type SummaryItemContainer struct {
type SummaryViewModel struct { type SummaryViewModel struct {
*Summary *Summary
*SummaryParams
User *User User *User
LanguageColors map[string]string LanguageColors map[string]string
EditorColors map[string]string EditorColors map[string]string
@ -67,6 +72,10 @@ type SummaryParams struct {
type AliasResolver func(t uint8, k string) string type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 { func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
} }
@ -76,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.OperatingSystems)) sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages)) sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors)) sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
return s return s
} }
@ -90,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryEditor: &s.Editors, SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems, SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines, SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
} }
} }
@ -108,7 +119,7 @@ of time than the other ones.
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type, To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown". such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/ */
func (s *Summary) FillUnknown() { func (s *Summary) FillMissing() {
types := s.Types() types := s.Types()
typeItems := s.MappedItems() typeItems := s.MappedItems()
missingTypes := make([]uint8, 0) missingTypes := make([]uint8, 0)
@ -124,15 +135,46 @@ func (s *Summary) FillUnknown() {
return return
} }
timeSum := s.TotalTime()
// construct dummy item for all missing types // construct dummy item for all missing types
presentType, err := s.findFirstPresentType()
if err != nil {
return // all types are either zero or missing entirely, nothing to fill
}
for _, t := range missingTypes { for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{ s.FillBy(presentType, t)
Type: t, }
Key: UnknownSummaryKey, }
Total: timeSum,
}) // inplace!
func (s *Summary) FillBy(fromType uint8, toType uint8) {
typeItems := s.MappedItems()
totalWanted := s.TotalTimeBy(fromType)
totalActual := s.TotalTimeBy(toType)
key := UnknownSummaryKey
if toType == SummaryLabel {
key = DefaultProjectLabel
}
existingEntryIdx := -1
for i, item := range *typeItems[toType] {
if item.Key == key {
existingEntryIdx = i
break
}
}
total := (totalWanted - totalActual) / time.Second // workaround
if total > 0 {
if existingEntryIdx >= 0 {
(*typeItems[toType])[existingEntryIdx].Total = total
} else {
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
Type: toType,
Key: key,
Total: total,
})
}
} }
} }
@ -140,14 +182,12 @@ func (s *Summary) TotalTime() time.Duration {
var timeSum time.Duration var timeSum time.Duration
mappedItems := s.MappedItems() mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items t, err := s.findFirstPresentType()
for _, t := range s.Types() { if err != nil {
if items := mappedItems[t]; len(*items) > 0 { return 0
for _, item := range *items { }
timeSum += item.Total for _, item := range *mappedItems[t] {
} timeSum += item.Total
break
}
} }
return timeSum * time.Second return timeSum * time.Second
@ -230,10 +270,26 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Languages = processAliases(s.Languages) s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems) s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines) s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
return s return s
} }
func (s *Summary) findFirstPresentType() (uint8, error) {
for _, t := range s.Types() {
if s.TotalTimeBy(t) != 0 {
return t, nil
}
}
return 127, errors.New("no type present")
}
func (s *SummaryItem) TotalFixed() time.Duration {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items
return s.Total * time.Second
}
func (s SummaryItems) Len() int { func (s SummaryItems) Len() int {
return len(s) return len(s)
} }

View File

@ -6,7 +6,7 @@ import (
"time" "time"
) )
func TestSummary_FillUnknown(t *testing.T) { func TestSummary_FillMissing(t *testing.T) {
testDuration := 10 * time.Minute testDuration := 10 * time.Minute
sut := &Summary{ sut := &Summary{
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
}, },
} }
sut.FillUnknown() sut.FillMissing()
itemLists := [][]*SummaryItem{ itemLists := [][]*SummaryItem{
sut.Machines, sut.Machines,
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
for _, l := range itemLists { for _, l := range itemLists {
assert.Len(t, l, 1) assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key) assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total) assert.Equal(t, testDuration, l[0].TotalFixed())
} }
assert.Len(t, sut.Labels, 1)
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
} }
func TestSummary_TotalTimeBy(t *testing.T) { func TestSummary_TotalTimeBy(t *testing.T) {

View File

@ -1,6 +1,9 @@
package models package models
import "regexp" import (
"regexp"
"time"
)
func init() { func init() {
mailRegex = regexp.MustCompile(MailPattern) mailRegex = regexp.MustCompile(MailPattern)
@ -10,6 +13,7 @@ type User struct {
ID string `json:"id" gorm:"primary_key"` ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"` Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"`
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
@ -19,10 +23,12 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"` ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"` ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"` ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"` IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"` HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"` WakatimeApiKey string `json:"-"`
ResetToken string `json:"-"` ResetToken string `json:"-"`
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
} }
type Login struct { type Login struct {
@ -35,6 +41,7 @@ type Signup struct {
Email string `schema:"email"` Email string `schema:"email"`
Password string `schema:"password"` Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"` PasswordRepeat string `schema:"password_repeat"`
Location string `schema:"location"`
} }
type SetPasswordRequest struct { type SetPasswordRequest struct {
@ -54,7 +61,9 @@ type CredentialsReset struct {
} }
type UserDataUpdate struct { type UserDataUpdate struct {
Email string `schema:"email"` Email string `schema:"email"`
Location string `schema:"location"`
ReportsWeekly bool `schema:"reports_weekly"`
} }
type TimeByUser struct { type TimeByUser struct {
@ -67,6 +76,22 @@ type CountByUser struct {
Count int64 Count int64
} }
func (u *User) TZ() *time.Location {
if u.Location == "" {
u.Location = "Local"
}
tz, err := time.LoadLocation(u.Location)
if err != nil {
return time.Local
}
return tz
}
func (u *User) TZOffset() time.Duration {
_, offset := time.Now().In(u.TZ()).Zone()
return time.Duration(offset * int(time.Second))
}
func (c *CredentialsReset) IsValid() bool { func (c *CredentialsReset) IsValid() bool {
return ValidatePassword(c.PasswordNew) && return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat c.PasswordNew == c.PasswordRepeat
@ -85,7 +110,7 @@ func (s *Signup) IsValid() bool {
} }
func (r *UserDataUpdate) IsValid() bool { func (r *UserDataUpdate) IsValid() bool {
return ValidateEmail(r.Email) return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
} }
func ValidateUsername(username string) bool { func ValidateUsername(username string) bool {
@ -99,3 +124,8 @@ func ValidatePassword(password string) bool {
func ValidateEmail(email string) bool { func ValidateEmail(email string) bool {
return email == "" || mailRegex.Match([]byte(email)) return email == "" || mailRegex.Match([]byte(email))
} }
func ValidateTimezone(tz string) bool {
_, err := time.LoadLocation(tz)
return err == nil
}

19
models/user_test.go Normal file
View File

@ -0,0 +1,19 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestUser_TZ(t *testing.T) {
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
pst, _ := time.LoadLocation("America/Los_Angeles")
_, offset := time.Now().Zone()
assert.Equal(t, time.Local, sut1.TZ())
assert.Equal(t, pst, sut2.TZ())
assert.InDelta(t, time.Duration(offset*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
assert.InDelta(t, time.Duration(-7*int(time.Hour)), sut2.TZOffset(), float64(1*time.Second))
}

View File

@ -6,6 +6,8 @@ type SettingsViewModel struct {
User *models.User User *models.User
LanguageMappings []*models.LanguageMapping LanguageMappings []*models.LanguageMapping
Aliases []*SettingsVMCombinedAlias Aliases []*SettingsVMCombinedAlias
Labels []*SettingsVMCombinedLabel
Projects []string
Success string Success string
Error string Error string
} }
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
Values []string Values []string
} }
type SettingsVMCombinedLabel struct {
Key string
Values []string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel { func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m s.Success = m
return s return s

View File

@ -14,6 +14,14 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
return &AliasRepository{db: db} return &AliasRepository{db: db}
} }
func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) { func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias var aliases []*models.Alias
if err := r.db. if err := r.db.

View File

@ -1,6 +1,7 @@
package repositories package repositories
import ( import (
"errors"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@ -15,6 +16,15 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db} return &HeartbeatRepository{db: db}
} }
// Use with caution!!
func (r *HeartbeatRepository) GetAll() ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error { func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db. if err := r.db.
Clauses(clause.OnConflict{ Clauses(clause.OnConflict{
@ -26,6 +36,18 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
return nil return nil
} }
func (r *HeartbeatRepository) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
var heartbeat models.Heartbeat
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Order("time desc").
First(&heartbeat).Error; err != nil {
return nil, err
}
return &heartbeat, nil
}
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) { func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
var heartbeat models.Heartbeat var heartbeat models.Heartbeat
if err := r.db. if err := r.db.
@ -42,11 +64,12 @@ func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *mode
} }
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) { func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
// https://stackoverflow.com/a/20765152/3112139
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
if err := r.db. if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}). Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from). Where("time >= ?", from.Local()).
Where("time < ?", to). Where("time < ?", to.Local()).
Order("time asc"). Order("time asc").
Find(&heartbeats).Error; err != nil { Find(&heartbeats).Error; err != nil {
return nil, err return nil, err
@ -104,9 +127,8 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
} }
if err := r.db. if err := r.db.
Model(&models.User{}). Model(&models.Heartbeat{}).
Select("users.id as user, count(heartbeats.id) as count"). Select("user_id as user, count(id) as count").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Where("user_id in ?", userIds). Where("user_id in ?", userIds).
Group("user"). Group("user").
Find(&counts).Error; err != nil { Find(&counts).Error; err != nil {
@ -115,9 +137,27 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
return counts, nil return counts, nil
} }
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
columns := []string{"project", "language", "editor", "operating_system", "machine"}
if int(entityType) >= len(columns) {
// invalid entity type
return nil, errors.New("invalid entity type")
}
var results []string
if err := r.db.
Model(&models.Heartbeat{}).
Distinct(columns[entityType]).
Where(&models.Heartbeat{UserID: user.ID}).
Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error { func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db. if err := r.db.
Where("time <= ?", t). Where("time <= ?", t.Local()).
Delete(models.Heartbeat{}).Error; err != nil { Delete(models.Heartbeat{}).Error; err != nil {
return err return err
} }

View File

@ -15,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
return &KeyValueRepository{db: db} return &KeyValueRepository{db: db}
} }
func (r *KeyValueRepository) GetAll() ([]*models.KeyStringValue, error) {
var keyValues []*models.KeyStringValue
if err := r.db.Find(&keyValues).Error; err != nil {
return nil, err
}
return keyValues, nil
}
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) { func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{} kv := &models.KeyStringValue{}
if err := r.db. if err := r.db.

View File

@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
return &LanguageMappingRepository{config: config.Get(), db: db} return &LanguageMappingRepository{config: config.Get(), db: db}
} }
func (r *LanguageMappingRepository) GetAll() ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if err := r.db.Find(&mappings).Error; err != nil {
return nil, err
}
return mappings, nil
}
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) { func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
mapping := &models.LanguageMapping{} mapping := &models.LanguageMapping{}
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil { if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {

View File

@ -0,0 +1,60 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type ProjectLabelRepository struct {
config *config.Config
db *gorm.DB
}
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
return &ProjectLabelRepository{config: config.Get(), db: db}
}
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.Find(&labels).Error; err != nil {
return nil, err
}
return labels, nil
}
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
label := &models.ProjectLabel{}
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
return label, err
}
return label, nil
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).
Find(&labels).Error; err != nil {
return labels, err
}
return labels, nil
}
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
if !label.IsValid() {
return nil, errors.New("invalid label")
}
result := r.db.Create(label)
if err := result.Error; err != nil {
return nil, err
}
return label, nil
}
func (r *ProjectLabelRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.ProjectLabel{}).Error
}

View File

@ -9,6 +9,7 @@ type IAliasRepository interface {
Insert(*models.Alias) (*models.Alias, error) Insert(*models.Alias) (*models.Alias, error)
Delete(uint) error Delete(uint) error
DeleteBatch([]uint) error DeleteBatch([]uint) error
GetAll() ([]*models.Alias, error)
GetByUser(string) ([]*models.Alias, error) GetByUser(string) ([]*models.Alias, error)
GetByUserAndKey(string, string) ([]*models.Alias, error) GetByUserAndKey(string, string) ([]*models.Alias, error)
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error) GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
@ -17,31 +18,45 @@ type IAliasRepository interface {
type IHeartbeatRepository interface { type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
GetAll() ([]*models.Heartbeat, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error) GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error) Count() (int64, error)
CountByUser(*models.User) (int64, error) CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error) CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
} }
type IKeyValueRepository interface { type IKeyValueRepository interface {
GetAll() ([]*models.KeyStringValue, error)
GetString(string) (*models.KeyStringValue, error) GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error PutString(*models.KeyStringValue) error
DeleteString(string) error DeleteString(string) error
} }
type ILanguageMappingRepository interface { type ILanguageMappingRepository interface {
GetAll() ([]*models.LanguageMapping, error)
GetById(uint) (*models.LanguageMapping, error) GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error) GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error) Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(uint) error Delete(uint) error
} }
type IProjectLabelRepository interface {
GetAll() ([]*models.ProjectLabel, error)
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(uint) error
}
type ISummaryRepository interface { type ISummaryRepository interface {
Insert(*models.Summary) error Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error) GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error) GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error DeleteByUser(string) error
@ -54,6 +69,7 @@ type IUserRepository interface {
GetByEmail(string) (*models.User, error) GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error) GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error) GetAll() ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error) GetByLoggedInAfter(time.Time) ([]*models.User, error)
GetByLastActiveAfter(time.Time) ([]*models.User, error) GetByLastActiveAfter(time.Time) ([]*models.User, error)
Count() (int64, error) Count() (int64, error)

View File

@ -14,6 +14,22 @@ func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
return &SummaryRepository{db: db} return &SummaryRepository{db: db}
} }
func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (r *SummaryRepository) Insert(summary *models.Summary) error { func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil { if err := r.db.Create(summary).Error; err != nil {
return err return err
@ -25,14 +41,15 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
var summaries []*models.Summary var summaries []*models.Summary
if err := r.db. if err := r.db.
Where(&models.Summary{UserID: user.ID}). Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from). Where("from_time >= ?", from.Local()).
Where("to_time <= ?", to). Where("to_time <= ?", to.Local()).
Order("from_time asc"). Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject). Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage). Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor). Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS). Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine). Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil { Find(&summaries).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -74,10 +74,18 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
return users, nil return users, nil
} }
func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
var users []*models.User
if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) { func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := r.db. if err := r.db.
Where("last_logged_in_at >= ?", t). Where("last_logged_in_at >= ?", t.Local()).
Find(&users).Error; err != nil { Find(&users).Error; err != nil {
return nil, err return nil, err
} }
@ -96,7 +104,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro
if err := r.db. if err := r.db.
Select("user as id"). Select("user as id").
Table("(?) as q", subQuery1). Table("(?) as q", subQuery1).
Where("time >= ?", t). Where("time >= ?", t.Local()).
Scan(&userIds).Error; err != nil { Scan(&userIds).Error; err != nil {
return nil, err return nil, err
} }
@ -139,9 +147,12 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_oss": user.ShareOSs, "share_oss": user.ShareOSs,
"share_projects": user.ShareProjects, "share_projects": user.ShareProjects,
"share_machines": user.ShareMachines, "share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey, "wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData, "has_data": user.HasData,
"reset_token": user.ResetToken, "reset_token": user.ResetToken,
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
} }
result := r.db.Model(user).Updates(updateMap) result := r.db.Model(user).Updates(updateMap)

View File

@ -17,7 +17,7 @@ func NewHealthApiHandler(db *gorm.DB) *HealthApiHandler {
func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) { func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/health").Subrouter() r := router.PathPrefix("/health").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// @Summary Check the application's health status // @Summary Check the application's health status

View File

@ -2,11 +2,11 @@ package api
import ( import (
"encoding/json" "encoding/json"
"github.com/emvi/logbuch"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom" customMiddleware "github.com/muety/wakapi/middlewares/custom"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@ -35,12 +35,14 @@ type heartbeatResponseVm struct {
} }
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) { func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter() r := router.PathPrefix("").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler, customMiddleware.NewWakatimeRelayMiddleware().Handler,
) )
r.Methods(http.MethodPost).HandlerFunc(h.Post) r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
} }
// @Summary Push a new heartbeat // @Summary Push a new heartbeat
@ -52,8 +54,12 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
// @Success 201 // @Success 201
// @Router /heartbeat [post] // @Router /heartbeat [post]
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) { func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
user := middlewares.GetPrincipal(r)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name") machineName := r.Header.Get("X-Machine-Name")
@ -83,7 +89,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil { if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to batch-insert heartbeats %v", err) conf.Log().Request(r).Error("failed to batch-insert heartbeats %v", err)
return return
} }
@ -92,12 +98,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to update user %v", err) conf.Log().Request(r).Error("failed to update user %v", err)
return return
} }
} }
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats))) utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
} }
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288) // construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)

View File

@ -27,6 +27,7 @@ const (
DescLanguages = "Total seconds for each language." DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system." DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine." DescMachines = "Total seconds for each machine."
DescLabels = "Total seconds for each project label."
DescAdminTotalTime = "Total seconds (all users, all time)." DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)" DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
@ -64,7 +65,7 @@ func (h *MetricsHandler) RegisterRoutes(router *mux.Router) {
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
@ -78,6 +79,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
var metrics mm.Metrics var metrics mm.Metrics
if userMetrics, err := h.getUserMetrics(reqUser); err != nil { if userMetrics, err := h.getUserMetrics(reqUser); err != nil {
conf.Log().Request(r).Error("%v", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
return return
@ -89,6 +91,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
if reqUser.IsAdmin { if reqUser.IsAdmin {
if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil { if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil {
conf.Log().Request(r).Error("%v", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
return return
@ -114,7 +117,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
return nil, err return nil, err
} }
from, to := utils.MustResolveIntervalRaw("today") from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false) summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
if err != nil { if err != nil {
@ -196,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}) })
} }
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil return &metrics, nil
} }

View File

@ -29,7 +29,7 @@ func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// @Summary Retrieve a summary // @Summary Retrieve a summary
@ -51,5 +51,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondJSON(w, http.StatusOK, summary) utils.RespondJSON(w, r, http.StatusOK, summary)
} }

View File

@ -16,7 +16,7 @@ import (
const ( const (
intervalPattern = `interval:([a-z0-9_]+)` intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)` entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
) )
type BadgeHandler struct { type BadgeHandler struct {
@ -74,8 +74,8 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
_, rangeFrom, rangeTo := utils.ResolveInterval(interval) _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))) minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
// negative value means no limit // negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 { if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@ -83,25 +83,41 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
var permitEntity bool
var filters *models.Filters var filters *models.Filters
switch filterEntity { switch filterEntity {
case "project": case "project":
permitEntity = user.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey) filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os": case "os":
permitEntity = user.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey) filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor": case "editor":
permitEntity = user.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey) filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language": case "language":
permitEntity = user.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey) filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine": case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey) filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
default: default:
permitEntity = true
filters = &models.Filters{} filters = &models.Filters{}
} }
if !permitEntity {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("user did not opt in to share entity-specific data"))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey) cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok { if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, http.StatusOK, cacheResult.(*v1.BadgeData)) utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
return return
} }
@ -114,11 +130,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
vm := v1.NewBadgeDataFrom(summary, filters) vm := v1.NewBadgeDataFrom(summary, filters)
h.cache.SetDefault(cacheKey, vm) h.cache.SetDefault(cacheKey, vm)
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) { func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval) err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
if err != nil { if err != nil {
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest
} }

View File

@ -6,6 +6,7 @@ import (
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@ -32,7 +33,7 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// @Summary Retrieve summary for all time // @Summary Retrieve summary for all time
@ -45,18 +46,14 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.AllTimeViewModel // @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get] // @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery) values, _ := url.ParseQuery(r.URL.RawQuery)
requestedUser := vars["user"] user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
authorizedUser := middlewares.GetPrincipal(r) if err != nil {
return // response was already sent by util function
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
} }
summary, err, status := h.loadUserSummary(authorizedUser) summary, err, status := h.loadUserSummary(user)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -64,7 +61,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project"))) vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {

View File

@ -0,0 +1,73 @@
package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
type ProjectsHandler struct {
config *conf.Config
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
}
func NewProjectsHandler(userService services.IUserService, heartbeatsService services.IHeartbeatService) *ProjectsHandler {
return &ProjectsHandler{
userSrvc: userService,
heartbeatSrvc: heartbeatsService,
config: conf.Get(),
}
}
func (h *ProjectsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/projects").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Retrieve and fitler the user's projects
// @Description Mimics https://wakatime.com/developers#projects
// @ID get-wakatime-projects
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Param q query string true "Query to filter projects by"
// @Security ApiKeyAuth
// @Success 200 {object} v1.ProjectsViewModel
// @Router /compat/wakatime/v1/users/{user}/projects [get]
func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
results, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("something went wrong"))
conf.Log().Request(r).Error(err.Error())
return
}
q := r.URL.Query().Get("q")
projects := make([]*v1.Project, 0, len(results))
for _, p := range results {
if strings.HasPrefix(p, q) {
projects = append(projects, &v1.Project{ID: p, Name: p})
}
}
vm := &v1.ProjectsViewModel{Data: projects}
utils.RespondJSON(w, r, http.StatusOK, vm)
}

View File

@ -41,6 +41,16 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108) // TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
// @Summary Retrieve statistics for a given user
// @Description Mimics https://wakatime.com/developers#stats
// @ID get-wakatimes-tats
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Param range query string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
// @Security ApiKeyAuth
// @Success 200 {object} v1.StatsViewModel
// @Router /compat/wakatime/v1/users/{user}/stats/{range} [get]
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
var vars = mux.Vars(r) var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User var authorizedUser, requestedUser *models.User
@ -62,14 +72,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
rangeParam = (*models.IntervalPast7Days)[0] rangeParam = (*models.IntervalPast7Days)[0]
} }
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam) err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range")) w.Write([]byte("invalid range"))
return return
} }
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))) minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) && if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 { rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@ -103,7 +113,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
stats.Data.Machines = nil stats.Data.Machines = nil
} }
utils.RespondJSON(w, http.StatusOK, stats) utils.RespondJSON(w, r, http.StatusOK, stats)
} }
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) { func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {

View File

@ -7,6 +7,7 @@ import (
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@ -33,10 +34,10 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// TODO: Support parameters: project, branches, timeout, writes_only, timezone // TODO: Support parameters: project, branches, timeout, writes_only
// See https://wakatime.com/developers#summaries. // See https://wakatime.com/developers#summaries.
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings. // Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108. // Requires https://github.com/muety/wakapi/issues/108.
@ -54,13 +55,9 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.SummariesViewModel // @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get] // @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) _, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
requestedUser := vars["user"] if err != nil {
authorizedUser := middlewares.GetPrincipal(r) return // response was already sent by util function
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
} }
summaries, err, status := h.loadUserSummaries(r) summaries, err, status := h.loadUserSummaries(r)
@ -76,55 +73,70 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
vm := v1.NewSummariesFrom(summaries, filters) vm := v1.NewSummariesFrom(summaries, filters)
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := middlewares.GetPrincipal(r) user := middlewares.GetPrincipal(r)
params := r.URL.Query() params := r.URL.Query()
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end") rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")
timezone := user.TZ()
if tzParam != "" {
if tz, err := time.LoadLocation(tzParam); err == nil {
timezone = tz
}
}
var start, end time.Time var start, end time.Time
if rangeParam != "" { if rangeParam != "" {
// range param takes precedence // range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil { if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
} }
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam { } else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
// also accept start param to be a range param // also accept start param to be a range param
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {
// eventually, consider start and end params a date // eventually, consider start and end params a date
var err error var err error
start, err = time.Parse(time.RFC3339, strings.Replace(startParam, " ", "+", 1)) start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
if err != nil { if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
} }
end, err = time.Parse(time.RFC3339, strings.Replace(endParam, " ", "+", 1)) end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
if err != nil { if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
} }
} }
// wakatime interprets end date as "inclusive", wakapi usually as "exclusive"
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
// while for wakapi it would be empty
// see https://github.com/muety/wakapi/issues/192
end = utils.EndOfDay(end).Add(-1 * time.Second)
overallParams := &models.SummaryParams{ overallParams := &models.SummaryParams{
From: start, From: start,
To: end, To: end,
User: user, User: user,
Recompute: false,
} }
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To) intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals)) summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals { for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, false) summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now()))
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }
// wakatime returns requested instead of actual summary range
summary.FromTime = models.CustomTime(interval[0])
summary.ToTime = models.CustomTime(interval[1].Add(-1 * time.Second))
summaries[i] = summary summaries[i] = summary
} }

View File

@ -0,0 +1,59 @@
package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type UsersHandler struct {
config *conf.Config
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
}
func NewUsersHandler(userService services.IUserService, heartbeatService services.IHeartbeatService) *UsersHandler {
return &UsersHandler{
userSrvc: userService,
heartbeatSrvc: heartbeatService,
config: conf.Get(),
}
}
func (h *UsersHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/compat/wakatime/v1/users/{user}").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Retrieve the given user
// @Description Mimics https://wakatime.com/developers#users
// @ID get-wakatime-user
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch (or 'current')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.UserViewModel
// @Router /compat/wakatime/v1/users/{user} [get]
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
user := v1.NewFromUser(wakapiUser)
if hb, err := h.heartbeatSrvc.GetLatestByUser(wakapiUser); err == nil {
user = user.WithLatestHeartbeat(hb)
} else {
conf.Log().Request(r).Error("%v", err)
}
utils.RespondJSON(w, r, http.StatusOK, v1.UserViewModel{Data: user})
}

View File

@ -284,14 +284,14 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
go func(user *models.User) { go func(user *models.User) {
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil { if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
logbuch.Error("failed to send password reset mail to %s %v", user.ID, err) conf.Log().Request(r).Error("failed to send password reset mail to %s %v", user.ID, err)
} else { } else {
logbuch.Info("sent password reset mail to %s", user.ID) logbuch.Info("sent password reset mail to %s", user.ID)
} }
}(u) }(u)
} }
} else { } else {
logbuch.Warn("password reset requested for unregistered address '%s'", resetRequest.Email) conf.Log().Request(r).Warn("password reset requested for unregistered address '%s'", resetRequest.Email)
} }
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound)

View File

@ -23,13 +23,16 @@ type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template var templates map[string]*template.Template
func loadTemplates() { func DefaultTemplateFuncs() template.FuncMap {
const tplPath = "/views" return template.FuncMap{
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json, "json": utils.Json,
"date": utils.FormatDateHuman, "date": utils.FormatDateHuman,
"datetime": utils.FormatDateTimeHuman,
"simpledate": utils.FormatDate, "simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime, "simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"floordate": utils.FloorDate,
"ceildate": utils.CeilDate,
"title": strings.Title, "title": strings.Title,
"join": strings.Join, "join": strings.Join,
"add": utils.Add, "add": utils.Add,
@ -52,10 +55,17 @@ func loadTemplates() {
"htmlSafe": func(html string) template.HTML { "htmlSafe": func(html string) template.HTML {
return template.HTML(html) return template.HTML(html)
}, },
}) }
}
func loadTemplates() {
tpls := template.New("").Funcs(DefaultTemplateFuncs())
templates = make(map[string]*template.Template) templates = make(map[string]*template.Template)
files, err := fs.ReadDir(views.TemplateFiles, ".") // Use local file system when in 'dev' environment, go embed file system otherwise
templateFs := config.ChooseFS("views", views.TemplateFiles)
files, err := fs.ReadDir(templateFs, ".")
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -66,7 +76,7 @@ func loadTemplates() {
continue continue
} }
templateFile, err := views.TemplateFiles.Open(tplName) templateFile, err := templateFs.Open(tplName)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -102,6 +112,9 @@ func typeName(t uint8) string {
if t == models.SummaryMachine { if t == models.SummaryMachine {
return "machine" return "machine"
} }
if t == models.SummaryLabel {
return "label"
}
return "unknown" return "unknown"
} }

View File

@ -14,10 +14,14 @@ import (
"github.com/muety/wakapi/services/imports" "github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"time" "time"
) )
const criticalError = "a critical error has occurred, sorry"
type SettingsHandler struct { type SettingsHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
@ -26,6 +30,7 @@ type SettingsHandler struct {
aliasSrvc services.IAliasService aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService languageMappingSrvc services.ILanguageMappingService
projectLabelSrvc services.IProjectLabelService
keyValueSrvc services.IKeyValueService keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService mailSrvc services.IMailService
httpClient *http.Client httpClient *http.Client
@ -40,6 +45,7 @@ func NewSettingsHandler(
aliasService services.IAliasService, aliasService services.IAliasService,
aggregationService services.IAggregationService, aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService, languageMappingService services.ILanguageMappingService,
projectLabelService services.IProjectLabelService,
keyValueService services.IKeyValueService, keyValueService services.IKeyValueService,
mailService services.IMailService, mailService services.IMailService,
) *SettingsHandler { ) *SettingsHandler {
@ -49,6 +55,7 @@ func NewSettingsHandler(
aliasSrvc: aliasService, aliasSrvc: aliasService,
aggregationSrvc: aggregationService, aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService, languageMappingSrvc: languageMappingService,
projectLabelSrvc: projectLabelService,
userSrvc: userService, userSrvc: userService,
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r)) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
} }
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteAlias return h.actionDeleteAlias
case "add_alias": case "add_alias":
return h.actionAddAlias return h.actionAddAlias
case "add_label":
return h.actionAddLabel
case "delete_label":
return h.actionDeleteLabel
case "delete_mapping": case "delete_mapping":
return h.actionDeleteLanguageMapping return h.actionDeleteLanguageMapping
case "add_mapping": case "add_mapping":
@ -166,6 +176,8 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
} }
user.Email = payload.Email user.Email = payload.Email
user.Location = payload.Location
user.ReportsWeekly = payload.ReportsWeekly
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError return http.StatusInternalServerError, "", conf.ErrInternalServerError
@ -250,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors")) user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss")) user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines")) user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days")) user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil { if err != nil {
@ -311,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
return http.StatusOK, "alias added successfully", "" return http.StatusOK, "alias added successfully", ""
} }
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
label := &models.ProjectLabel{
UserID: user.ID,
ProjectKey: r.PostFormValue("key"),
Label: r.PostFormValue("value"),
}
if !label.IsValid() {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.projectLabelSrvc.Create(label); err != nil {
// TODO: distinguish between bad request, conflict and server error
return http.StatusBadRequest, "", "invalid input"
}
return http.StatusOK, "label added successfully", ""
}
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
labelKey := r.PostFormValue("key")
labelValue := r.PostFormValue("value")
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
if projectLabels, ok := labelMap[labelKey]; ok {
for _, l := range projectLabels {
if l.Label == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
return http.StatusOK, "label deleted successfully", ""
}
}
return http.StatusNotFound, "", "label not found"
} else {
return http.StatusNotFound, "", "project not found"
}
}
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@ -448,9 +514,16 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
h.regenerateSummaries(user) h.regenerateSummaries(user)
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s %v", user.ID, err)
}
}
if user.Email != "" { if user.Email != "" {
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil { if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
logbuch.Error("failed to send import notification mail to %s %v", user.ID, err) conf.Log().Request(r).Error("failed to send import notification mail to %s %v", user.ID, err)
} else { } else {
logbuch.Info("sent import notification mail to %s", user.ID) logbuch.Info("sent import notification mail to %s", user.ID)
} }
@ -472,7 +545,7 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
go func(user *models.User) { go func(user *models.User) {
if err := h.regenerateSummaries(user); err != nil { if err := h.regenerateSummaries(user); err != nil {
logbuch.Error("failed to regenerate summaries for user '%s' %v", user.ID, err) conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' %v", user.ID, err)
} }
}(middlewares.GetPrincipal(r)) }(middlewares.GetPrincipal(r))
@ -489,7 +562,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
logbuch.Info("deleting user '%s' shortly", user.ID) logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute) time.Sleep(5 * time.Minute)
if err := h.userSrvc.Delete(user); err != nil { if err := h.userSrvc.Delete(user); err != nil {
logbuch.Error("failed to delete user '%s' %v", user.ID, err) conf.Log().Request(r).Error("failed to delete user '%s' %v", user.ID, err)
} else { } else {
logbuch.Info("successfully deleted user '%s'", user.ID) logbuch.Info("successfully deleted user '%s'", user.ID)
} }
@ -544,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel { func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r) user := middlewares.GetPrincipal(r)
// mappings
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID) mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
// aliases
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
aliasMap := make(map[string][]*models.Alias) aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases { for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type) k := fmt.Sprintf("%s_%d", a.Key, a.Type)
@ -569,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
combinedAliases = append(combinedAliases, ca) combinedAliases = append(combinedAliases, ca)
} }
// labels
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
for _, l := range labelMap {
cl := &view.SettingsVMCombinedLabel{
Key: l[0].ProjectKey,
Values: make([]string, len(l)),
}
for i, l1 := range l {
cl.Values[i] = l1.Label
}
combinedLabels = append(combinedLabels, cl)
}
sort.Slice(combinedLabels, func(i, j int) bool {
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
})
// projects
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
sort.Strings(projects)
return &view.SettingsViewModel{ return &view.SettingsViewModel{
User: user, User: user,
LanguageMappings: mappings, LanguageMappings: mappings,
Aliases: combinedAliases, Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
} }

View File

@ -46,6 +46,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
} }
summaryParams, _ := utils.ParseSummaryParams(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r) summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
@ -62,6 +63,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, Summary: summary,
SummaryParams: summaryParams,
User: user, User: user,
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages), LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors), EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),

View File

@ -1,6 +1,7 @@
package utils package utils
import ( import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -8,6 +9,7 @@ import (
) )
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) { func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
summaryParams, err := utils.ParseSummaryParams(r) summaryParams, err := utils.ParseSummaryParams(r)
if err != nil { if err != nil {
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest
@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
return summary, nil, http.StatusOK return summary, nil, http.StatusOK
} }

View File

@ -0,0 +1,46 @@
package utils
import (
"errors"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"net/http"
)
// CheckEffectiveUser extracts the requested user from a URL (like '/users/{user}'), compares it with the currently authorized user and writes an HTTP error if they differ.
// Fallback can be used to manually set a value for '{user}' if none is present.
func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService services.IUserService, fallback string) (*models.User, error) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if vars["user"] == "" {
vars["user"] = fallback
}
authorizedUser = middlewares.GetPrincipal(r)
if authorizedUser != nil {
if vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
}
requestedUser, err := userService.GetUserById(vars["user"])
if err != nil {
err := errors.New("user not found")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(err.Error()))
return nil, err
}
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
err := errors.New(conf.ErrUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(err.Error()))
return nil, err
}
return authorizedUser, nil
}

80
scripts/bundle_icons.js Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env node
'use strict'
// Usage:
// yarn add -D @iconify/json-tools @iconify/json
// node bundle_icons.js
// https://iconify.design/docs/icon-bundles/
const fs = require('fs')
const path = require('path')
const { Collection } = require('@iconify/json-tools')
let icons = [
'fxemoji:key',
'fxemoji:rocket',
'fxemoji:satelliteantenna',
'fxemoji:lockandkey',
'fxemoji:clipboard',
'flat-color-icons:donate',
'flat-color-icons:clock',
'codicon:github-inverted',
'ant-design:check-square-filled',
'emojione-v1:white-heavy-check-mark',
'emojione-v1:alarm-clock',
'emojione-v1:warning',
'emojione-v1:backhand-index-pointing-right',
'twemoji:light-bulb',
'noto:play-button',
'noto:stop-button',
'noto:lock',
'twemoji:gear',
'eva:corner-right-down-fill',
'bi:heart-fill',
]
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))
const pretty = false
// Sort icons by collections: filtered[prefix][array of icons]
let filtered = {}
icons.forEach(icon => {
let parts = icon.split(':'),
prefix
if (parts.length > 1) {
prefix = parts.shift()
icon = parts.join(':')
} else {
parts = icon.split('-')
prefix = parts.shift()
icon = parts.join('-')
}
if (filtered[prefix] === void 0) {
filtered[prefix] = []
}
if (filtered[prefix].indexOf(icon) === -1) {
filtered[prefix].push(icon)
}
})
// Parse each collection
let code = ''
Object.keys(filtered).forEach(prefix => {
let collection = new Collection()
if (!collection.loadIconifyCollection(prefix)) {
console.error('Error loading collection', prefix)
return
}
code += collection.scriptify({
icons: filtered[prefix],
optimize: true,
pretty: pretty
})
})
// Save code
fs.writeFileSync(output, code, 'utf8')
console.log('Saved bundle to', output, ' (' + code.length + ' bytes)')

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5 docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:8

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres docker run -d -p 5432:5432 -e POSTGRES_DB=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres

86
scripts/get.sh Normal file
View File

@ -0,0 +1,86 @@
#!/bin/sh
# This script installs Wakapi.
#
# Quick install: `curl https://wakapi.dev/get | bash`
#
# This script will install Wakapi to the directory you're in. To install
# somewhere else (e.g. /usr/local/bin), cd there and make sure you can write to
# that directory, e.g. `cd /usr/local/bin; curl https://wakapi.dev/get | sudo bash`
#
# Acknowledgments:
# - Micro Editor for this script: https://micro-editor.github.io/
# - ASCII art courtesy of figlet: http://www.figlet.org/
set -e -u
githubLatestTag() {
finalUrl=$(curl "https://github.com/$1/releases/latest" -s -L -I -o /dev/null -w '%{url_effective}')
printf "%s\n" "${finalUrl##*/}"
}
platform=''
machine=$(uname -m) # currently, Wakapi builds are only available for AMD64 anyway
if [ "${GETWAKAPI_PLATFORM:-x}" != "x" ]; then
platform="$GETWAKAPI_PLATFORM"
else
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
"linux") platform='linux_amd64' ;;
"msys"*|"cygwin"*|"mingw"*|*"_nt"*|"win"*) platform='win_amd64' ;;
esac
fi
if [ "x$platform" = "x" ]; then
cat << 'EOM'
/=====================================\\
| COULD NOT DETECT PLATFORM |
\\=====================================/
Uh oh! We couldn't automatically detect your operating system. You can file a
bug here: https://github.com/muety/wakapi
EOM
exit 1
else
printf "Detected platform: %s\n" "$platform"
fi
TAG=$(githubLatestTag muety/wakapi)
printf "Tag: %s" "$TAG"
extension='zip'
printf "Latest Version: %s\n" "$TAG"
printf "Downloading https://github.com/muety/wakapi/releases/download/%s/wakapi_%s.%s\n" "$TAG" "$platform" "$extension"
curl -L "https://github.com/muety/wakapi/releases/download/$TAG/wakapi_$platform.$extension" > "wakapi.$extension"
case "$extension" in
"zip") unzip -j "wakapi.$extension" -d "wakapi-$TAG" ;;
"tar.gz") tar -xvzf "wakapi.$extension" "wakapi-$TAG/wakapi" ;;
esac
mv "wakapi-$TAG/wakapi" ./wakapi
mv "wakapi-$TAG/config.yml" ./config.yml
rm "wakapi.$extension"
rm -rf "wakapi-$TAG"
cat <<-'EOM'
__ __ _ _
\ \ / /_ _| | ____ _ _ __ (_)
\ \ /\ / / _` | |/ / _` | '_ \| |
\ V V / (_| | < (_| | |_) | |
\_/\_/ \__,_|_|\_\__,_| .__/|_|
|_|
Wakapi has been downloaded to the current directory.
You can run it with:
./wakapi
For further instructions see https://github.com/muety/wakapi
EOM

View File

@ -9,7 +9,6 @@ from datetime import datetime, timedelta
from typing import List, Union, Callable from typing import List, Union, Callable
import requests import requests
from tqdm import tqdm
MACHINE = "devmachine" MACHINE = "devmachine"
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0' UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0'
@ -53,6 +52,7 @@ class ConfigParams:
self.n_projects = 0 self.n_projects = 0
self.offset = 0 self.offset = 0
self.seed = 0 self.seed = 0
self.batch = False
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]: def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
@ -86,21 +86,21 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
def post_data_sync(data: List[Heartbeat], url: str, api_key: str): def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8') encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
for h in data: r = requests.post(url, json=[h.__dict__ for h in data], headers={
r = requests.post(url, json=[h.__dict__], headers={ 'User-Agent': UA,
'User-Agent': UA, 'Authorization': f'Basic {encoded_key}',
'Authorization': f'Basic {encoded_key}', 'X-Machine-Name': MACHINE,
'X-Machine-Name': MACHINE, })
}) if r.status_code != 201:
if r.status_code != 201: print(r.text)
print(r.text) sys.exit(1)
sys.exit(1)
def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) -> ('QApplication', 'QWidget'): def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) -> ('QApplication', 'QWidget'):
# https://doc.qt.io/qt-5/qtwidgets-module.html
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \ from PyQt5.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \
QLineEdit, QSpinBox, QProgressBar, QPushButton QLineEdit, QSpinBox, QProgressBar, QPushButton, QCheckBox
# Main app # Main app
app = QApplication([]) app = QApplication([])
@ -153,10 +153,14 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
seed_input.setMaximum(2147483647) seed_input.setMaximum(2147483647)
seed_input.setValue(1337) seed_input.setValue(1337)
batch_checkbox = QCheckBox('Batch Mode')
batch_checkbox.setTristate(False)
form_layout_2.addRow(heartbeats_input_label, heartbeats_input) form_layout_2.addRow(heartbeats_input_label, heartbeats_input)
form_layout_2.addRow(projects_input_label, projects_input) form_layout_2.addRow(projects_input_label, projects_input)
form_layout_2.addRow(offset_input_label, offset_input) form_layout_2.addRow(offset_input_label, offset_input)
form_layout_2.addRow(seed_input_label, seed_input) form_layout_2.addRow(seed_input_label, seed_input)
form_layout_2.addRow(batch_checkbox)
# Bottom controls # Bottom controls
bottom_layout = QHBoxLayout() bottom_layout = QHBoxLayout()
@ -195,6 +199,7 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
params.n_projects = projects_input.value() params.n_projects = projects_input.value()
params.offset = offset_input.value() params.offset = offset_input.value()
params.seed = seed_input.value() params.seed = seed_input.value()
params.batch = batch_checkbox.isChecked()
return params return params
def update_progress(inc=1): def update_progress(inc=1):
@ -231,6 +236,7 @@ def parse_arguments():
help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for') help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for')
parser.add_argument('-s', '--seed', type=int, default=2020, parser.add_argument('-s', '--seed', type=int, default=2020,
help='a seed for initializing the pseudo-random number generator') help='a seed for initializing the pseudo-random number generator')
parser.add_argument('-b', '--batch', default=False, help='batch mode (push all heartbeats at once)', action='store_true')
return parser.parse_args() return parser.parse_args()
@ -242,6 +248,7 @@ def args_to_params(parsed_args: argparse.Namespace) -> (ConfigParams, bool):
params.seed = parsed_args.seed params.seed = parsed_args.seed
params.api_url = parsed_args.url params.api_url = parsed_args.url
params.api_key = parsed_args.apikey params.api_key = parsed_args.apikey
params.batch = parsed_args.batch
return params, not parsed_args.headless return params, not parsed_args.headless
@ -258,9 +265,14 @@ def run(params: ConfigParams, update_progress: Callable[[int], None]):
params.offset * -1 if params.offset < 0 else params.offset params.offset * -1 if params.offset < 0 else params.offset
) )
for d in data: # batch-mode won't work when using sqlite backend
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key) if params.batch:
update_progress(1) post_data_sync(data, f'{params.api_url}/heartbeats', params.api_key)
update_progress(len(data))
else:
for d in data:
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key)
update_progress(1)
if __name__ == '__main__': if __name__ == '__main__':
@ -270,5 +282,7 @@ if __name__ == '__main__':
window.show() window.show()
app.exec() app.exec()
else: else:
from tqdm import tqdm
pbar = tqdm(total=params.n) pbar = tqdm(total=params.n)
run(params, pbar.update) run(params, pbar.update)

276
scripts/sqlite2mysql.go Normal file
View File

@ -0,0 +1,276 @@
package main
/*
A script to migrate Wakapi data from SQLite to MySQL or Postgres.
Usage:
---
1. Set up an empty MySQL or Postgres database (see docker_[mysql|postgres].sh for example)
2. Create a migration config file (e.g. config.yml) as shown below
3. go run sqlite2mysql.go -config config.yml
Example: config.yml
---
source:
name: ../wakapi_db.db
# MySQL / Postgres
target:
host:
port:
user:
password:
name:
dialect:
Troubleshooting:
---
- Check https://wiki.postgresql.org/wiki/Fixing_Sequences in case of errors with Postgres
- Check https://github.com/muety/wakapi/pull/181#issue-621585477 on further details about Postgres migration
*/
import (
"flag"
"fmt"
"log"
"os"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type config struct {
Source dbConfig // sqlite
Target dbConfig // mysql / postgres
}
type dbConfig struct {
Host string
Port uint
User string
Password string
Name string
Dialect string `default:"mysql"`
}
const InsertBatchSize = 100
var cfg *config
var dbSource, dbTarget *gorm.DB
var cFlag *string
func init() {
cfg = &config{}
if f := flag.Lookup("config"); f == nil {
cFlag = flag.String("config", "sqlite2mysql.yml", "config file location")
} else {
ff := f.Value.(flag.Getter).Get().(string)
cFlag = &ff
}
flag.Parse()
if err := configor.New(&configor.Config{}).Load(cfg, mustConfigPath()); err != nil {
log.Fatalln("failed to read config", err)
}
log.Println("attempting to open sqlite database as source")
if db, err := gorm.Open(sqlite.Open(cfg.Source.Name), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbSource = db
}
if cfg.Target.Dialect == "postgres" {
log.Println("attempting to open postgresql database as target")
if db, err := gorm.Open(postgres.Open(fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s sslmode=disable timezone=Europe/Berlin",
cfg.Target.User,
cfg.Target.Password,
cfg.Target.Host,
cfg.Target.Port,
cfg.Target.Name,
)), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbTarget = db
}
} else {
log.Println("attempting to open mysql database as target")
if db, err := gorm.Open(mysql.New(mysql.Config{
DriverName: "mysql",
DSN: fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
cfg.Target.User,
cfg.Target.Password,
cfg.Target.Host,
cfg.Target.Port,
cfg.Target.Name,
"utf8mb4",
"Local",
),
}), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbTarget = db
}
}
}
func destroy() {
if db, _ := dbSource.DB(); db != nil {
db.Close()
}
if db, _ := dbTarget.DB(); db != nil {
db.Close()
}
}
func main() {
defer destroy()
if err := createSchema(); err != nil {
log.Fatalln(err)
}
keyValueSource := repositories.NewKeyValueRepository(dbSource)
keyValueTarget := repositories.NewKeyValueRepository(dbTarget)
userSource := repositories.NewUserRepository(dbSource)
userTarget := repositories.NewUserRepository(dbTarget)
languageMappingSource := repositories.NewLanguageMappingRepository(dbSource)
languageMappingTarget := repositories.NewLanguageMappingRepository(dbTarget)
aliasSource := repositories.NewAliasRepository(dbSource)
aliasTarget := repositories.NewAliasRepository(dbTarget)
summarySource := repositories.NewSummaryRepository(dbSource)
summaryTarget := repositories.NewSummaryRepository(dbTarget)
heartbeatSource := repositories.NewHeartbeatRepository(dbSource)
heartbeatTarget := repositories.NewHeartbeatRepository(dbTarget)
// TODO: things could be optimized through batch-inserts / inserts within a single transaction
log.Println("Migrating key-value pairs ...")
if data, err := keyValueSource.GetAll(); err == nil {
for _, e := range data {
if err := keyValueTarget.PutString(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating users ...")
if data, err := userSource.GetAll(); err == nil {
for _, e := range data {
if _, _, err := userTarget.InsertOrGet(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating language mappings ...")
if data, err := languageMappingSource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if _, err := languageMappingTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating aliases ...")
if data, err := aliasSource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if _, err := aliasTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating summaries ...")
if data, err := summarySource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if err := summaryTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
// TODO: copy in mini-batches instead of loading all heartbeats into memory (potentially millions)
log.Println("Migrating heartbeats ...")
if data, err := heartbeatSource.GetAll(); err == nil {
log.Printf("Got %d heartbeats loaded into memory. Batch-inserting them now ...\n", len(data))
var slice = make([]*models.Heartbeat, len(data))
for i, heartbeat := range data {
heartbeat = heartbeat.Hashed()
slice[i] = heartbeat
}
left, right, size := 0, InsertBatchSize, len(slice)
for right < size {
log.Printf("Inserting batch from %d", left)
if err := heartbeatTarget.InsertBatch(slice[left:right]); err != nil {
log.Fatalln(err)
}
left += InsertBatchSize
right += InsertBatchSize
}
if err := heartbeatTarget.InsertBatch(slice[left:]); err != nil {
log.Fatalln(err)
}
} else {
log.Fatalln(err)
}
}
func createSchema() error {
if err := dbTarget.AutoMigrate(&models.User{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.KeyStringValue{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Alias{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Heartbeat{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Summary{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.SummaryItem{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.LanguageMapping{}); err != nil {
return err
}
return nil
}
func mustConfigPath() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalln("failed to find config file at", *cFlag)
}
return *cFlag
}

View File

@ -1,9 +1,11 @@
package services package services
import ( import (
"errors"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"runtime" "runtime"
"sync"
"time" "time"
"github.com/go-co-op/gocron" "github.com/go-co-op/gocron"
@ -14,11 +16,14 @@ const (
aggregateIntervalDays int = 1 aggregateIntervalDays int = 1
) )
var aggregationLock = sync.Mutex{}
type AggregationService struct { type AggregationService struct {
config *config.Config config *config.Config
userService IUserService userService IUserService
summaryService ISummaryService summaryService ISummaryService
heartbeatService IHeartbeatService heartbeatService IHeartbeatService
inProgress map[string]bool
} }
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService { func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -27,6 +32,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
userService: userService, userService: userService,
summaryService: summaryService, summaryService: summaryService,
heartbeatService: heartbeatService, heartbeatService: heartbeatService,
inProgress: map[string]bool{},
} }
} }
@ -49,6 +55,11 @@ func (srv *AggregationService) Schedule() {
} }
func (srv *AggregationService) Run(userIds map[string]bool) error { func (srv *AggregationService) Run(userIds map[string]bool) error {
if err := srv.lockUsers(userIds); err != nil {
return err
}
defer srv.unlockUsers(userIds)
jobs := make(chan *AggregationJob) jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary) summaries := make(chan *models.Summary)
@ -73,7 +84,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) { func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs { for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil { if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
logbuch.Error("failed to generate summary (%v, %v, %s) %v", job.From, job.To, job.UserID, err) config.Log().Error("failed to generate summary (%v, %v, %s) %v", job.From, job.To, job.UserID, err)
} else { } else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID) logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary summaries <- summary
@ -84,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) { func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries { for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil { if err := srv.summaryService.Insert(summary); err != nil {
logbuch.Error("failed to save summary (%v, %v, %s) %v", summary.UserID, summary.FromTime, summary.ToTime, err) config.Log().Error("failed to save summary (%v, %v, %s) %v", summary.UserID, summary.FromTime, summary.ToTime, err)
} }
} }
} }
@ -94,7 +105,7 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
var users []*models.User var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil { if allUsers, err := srv.userService.GetAll(); err != nil {
logbuch.Error(err.Error()) config.Log().Error(err.Error())
return err return err
} else if userIds != nil && len(userIds) > 0 { } else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, 0) users = make([]*models.User, 0)
@ -110,14 +121,14 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
// Get a map from user ids to the time of their latest summary or nil if none exists yet // Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser() lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil { if err != nil {
logbuch.Error(err.Error()) config.Log().Error(err.Error())
return err return err
} }
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet // Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers() firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
if err != nil { if err != nil {
logbuch.Error(err.Error()) config.Log().Error(err.Error())
return err return err
} }
@ -145,6 +156,28 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
return nil return nil
} }
func (srv *AggregationService) lockUsers(userIds map[string]bool) error {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
if _, ok := srv.inProgress[uid]; ok {
return errors.New("aggregation already in progress for at least of the request users")
}
}
for uid := range userIds {
srv.inProgress[uid] = true
}
return nil
}
func (srv *AggregationService) unlockUsers(userIds map[string]bool) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
delete(srv.inProgress, uid)
}
}
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) { func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var to time.Time var to time.Time

View File

@ -1,8 +1,12 @@
package services package services
import ( import (
"fmt"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"strings"
"time" "time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
@ -10,6 +14,7 @@ import (
type HeartbeatService struct { type HeartbeatService struct {
config *config.Config config *config.Config
cache *cache.Cache
repository repositories.IHeartbeatRepository repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService languageMappingSrvc ILanguageMappingService
} }
@ -17,12 +22,14 @@ type HeartbeatService struct {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService { func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{ return &HeartbeatService{
config: config.Get(), config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: heartbeatRepo, repository: heartbeatRepo,
languageMappingSrvc: languageMappingService, languageMappingSrvc: languageMappingService,
} }
} }
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error { func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
srv.updateEntityUserCacheByHeartbeat(heartbeat)
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat}) return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
} }
@ -36,6 +43,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
filteredHeartbeats = append(filteredHeartbeats, hb) filteredHeartbeats = append(filteredHeartbeats, hb)
hashes[hb.Hash] = true hashes[hb.Hash] = true
} }
srv.updateEntityUserCacheByHeartbeat(hb)
} }
return srv.repository.InsertBatch(filteredHeartbeats) return srv.repository.InsertBatch(filteredHeartbeats)
@ -61,6 +69,10 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID) return srv.augmented(heartbeats, user.ID)
} }
func (srv *HeartbeatService) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
return srv.repository.GetLatestByUser(user)
}
func (srv *HeartbeatService) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) { func (srv *HeartbeatService) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
return srv.repository.GetLatestByOriginAndUser(origin, user) return srv.repository.GetLatestByOriginAndUser(origin, user)
} }
@ -69,6 +81,28 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers() return srv.repository.GetFirstByUsers()
} }
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if results, found := srv.cache.Get(cacheKey); found {
return utils.SetToStrings(results.(map[string]bool)), nil
}
results, err := srv.repository.GetEntitySetByUser(entityType, user)
if err != nil {
return nil, err
}
filtered := make([]string, 0, len(results))
for _, r := range results {
if strings.TrimSpace(r) != "" {
filtered = append(filtered, r)
}
}
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
return filtered, nil
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error { func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
return srv.repository.DeleteBefore(t) return srv.repository.DeleteBefore(t)
} }
@ -85,3 +119,26 @@ func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId st
return heartbeats, nil return heartbeats, nil
} }
func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *models.User) string {
return fmt.Sprintf("entity_set_%d_%s", entityType, user.ID)
}
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if entities, found := srv.cache.Get(cacheKey); found {
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
// -> invalidate cache
srv.cache.Delete(cacheKey)
}
}
}
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
}

View File

@ -42,7 +42,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
go func(user *models.User, out chan *models.Heartbeat) { go func(user *models.User, out chan *models.Heartbeat) {
startDate, endDate, err := w.fetchRange() startDate, endDate, err := w.fetchRange()
if err != nil { if err != nil {
logbuch.Error("failed to fetch date range while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return return
} }
@ -55,13 +55,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
userAgents, err := w.fetchUserAgents() userAgents, err := w.fetchUserAgents()
if err != nil { if err != nil {
logbuch.Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return return
} }
machinesNames, err := w.fetchMachineNames() machinesNames, err := w.fetchMachineNames()
if err != nil { if err != nil {
logbuch.Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return return
} }
@ -84,7 +84,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
d := day.Format(config.SimpleDateFormat) d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d) heartbeats, err := w.fetchHeartbeats(d)
if err != nil { if err != nil {
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", d, user.ID, err) config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' &v", d, user.ID, err)
} }
for _, h := range heartbeats { for _, h := range heartbeats {

View File

@ -3,42 +3,97 @@ package mail
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil" "io/ioutil"
"text/template" "time"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/views" "github.com/muety/wakapi/views"
) )
const ( const (
tplNamePasswordReset = "reset_password" tplNamePasswordReset = "reset_password"
tplNameImportNotification = "import_finished" tplNameImportNotification = "import_finished"
subjectPasswordReset = "Wakapi Password Reset" tplNameReport = "report"
subjectImportNotification = "Wakapi Data Import Finished" subjectPasswordReset = "Wakapi - Password Reset"
subjectImportNotification = "Wakapi - Data Import Finished"
subjectReport = "Wakapi - Report from %s"
) )
type PasswordResetTplData struct { type SendingService interface {
ResetLink string Send(*models.Mail) error
} }
type ImportNotificationTplData struct { type MailService struct {
PublicUrl string config *conf.Config
Duration string sendingService SendingService
NumHeartbeats int
} }
// Factory
func NewMailService() services.IMailService { func NewMailService() services.IMailService {
config := conf.Get() config := conf.Get()
var sendingService SendingService
sendingService = &NoopSendingService{}
if config.Mail.Enabled { if config.Mail.Enabled {
if config.Mail.Provider == conf.MailProviderMailWhale { if config.Mail.Provider == conf.MailProviderMailWhale {
return NewMailWhaleService(config.Mail.MailWhale, config.Server.PublicUrl) sendingService = NewMailWhaleSendingService(config.Mail.MailWhale)
} else if config.Mail.Provider == conf.MailProviderSmtp { } else if config.Mail.Provider == conf.MailProviderSmtp {
return NewSMTPMailService(config.Mail.Smtp, config.Server.PublicUrl) sendingService = NewSMTPSendingService(config.Mail.Smtp)
} }
} }
return &NoopMailService{}
return &MailService{sendingService: sendingService, config: config}
}
func (m *MailService) SendPasswordReset(recipient *models.User, resetLink string) error {
tpl, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectPasswordReset,
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
}
func (m *MailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
tpl, err := getImportNotificationTemplate(ImportNotificationTplData{
PublicUrl: m.config.Server.PublicUrl,
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
NumHeartbeats: numHeartbeats,
})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectImportNotification,
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
}
func (m *MailService) SendReport(recipient *models.User, report *models.Report) error {
tpl, err := getReportTemplate(ReportTplData{report})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: fmt.Sprintf(subjectReport, utils.FormatDateHuman(time.Now().In(recipient.TZ()))),
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
} }
func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) { func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
@ -65,6 +120,18 @@ func getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffe
return &rendered, nil return &rendered, nil
} }
func getReportTemplate(data ReportTplData) (*bytes.Buffer, error) {
tpl, err := loadTemplate(tplNameReport)
if err != nil {
return nil, err
}
var rendered bytes.Buffer
if err := tpl.Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func loadTemplate(tplName string) (*template.Template, error) { func loadTemplate(tplName string) (*template.Template, error) {
tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName)) tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName))
if err != nil { if err != nil {
@ -77,5 +144,8 @@ func loadTemplate(tplName string) (*template.Template, error) {
return nil, err return nil, err
} }
return template.New(tplName).Parse(string(tplData)) return template.
New(tplName).
Funcs(routes.DefaultTemplateFuncs()).
Parse(string(tplData))
} }

View File

@ -11,8 +11,7 @@ import (
"time" "time"
) )
type MailWhaleMailService struct { type MailWhaleSendingService struct {
publicUrl string
config conf.MailwhaleMailConfig config conf.MailwhaleMailConfig
httpClient *http.Client httpClient *http.Client
} }
@ -26,49 +25,28 @@ type MailWhaleSendRequest struct {
TemplateVars map[string]string `json:"template_vars"` TemplateVars map[string]string `json:"template_vars"`
} }
func NewMailWhaleService(config conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService { func NewMailWhaleSendingService(config conf.MailwhaleMailConfig) *MailWhaleSendingService {
return &MailWhaleMailService{ return &MailWhaleSendingService{
publicUrl: publicUrl, config: config,
config: config,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
} }
} }
func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error { func (s *MailWhaleSendingService) Send(mail *models.Mail) error {
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) if len(mail.To) == 0 {
if err != nil {
return err
}
return s.send(recipient.Email, subjectPasswordReset, template.String(), true)
}
func (s *MailWhaleMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
template, err := getImportNotificationTemplate(ImportNotificationTplData{
PublicUrl: s.publicUrl,
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
NumHeartbeats: numHeartbeats,
})
if err != nil {
return err
}
return s.send(recipient.Email, subjectImportNotification, template.String(), true)
}
func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error {
if to == "" {
return errors.New("not sending mail as recipient mail address seems to be invalid") return errors.New("not sending mail as recipient mail address seems to be invalid")
} }
sendRequest := &MailWhaleSendRequest{ sendRequest := &MailWhaleSendRequest{
To: []string{to}, To: mail.To.Strings(),
Subject: subject, Subject: mail.Subject,
} }
if isHtml { if mail.Type == models.HtmlType {
sendRequest.Html = body sendRequest.Html = mail.Body
} else { } else {
sendRequest.Text = body sendRequest.Text = mail.Body
} }
payload, _ := json.Marshal(sendRequest) payload, _ := json.Marshal(sendRequest)

View File

@ -3,17 +3,11 @@ package mail
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"time"
) )
type NoopMailService struct{} type NoopSendingService struct{}
func (n *NoopMailService) SendPasswordReset(recipient *models.User, resetLink string) error { func (n *NoopSendingService) Send(mail *models.Mail) error {
logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) logbuch.Info("noop mail service doing nothing instead of sending password reset mail to [%v]", mail.To.Strings())
return nil
}
func (n *NoopMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
logbuch.Info("noop mail service doing nothing instead of sending import notification mail to %s", recipient.ID)
return nil return nil
} }

View File

@ -2,25 +2,21 @@ package mail
import ( import (
"errors" "errors"
"fmt"
"github.com/emersion/go-sasl" "github.com/emersion/go-sasl"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"io" "io"
"time"
) )
type SMTPMailService struct { type SMTPSendingService struct {
publicUrl string config conf.SMTPMailConfig
config conf.SMTPMailConfig auth sasl.Client
auth sasl.Client
} }
func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailService { func NewSMTPSendingService(config conf.SMTPMailConfig) *SMTPSendingService {
return &SMTPMailService{ return &SMTPSendingService{
publicUrl: publicUrl, config: config,
config: config,
auth: sasl.NewPlainClient( auth: sasl.NewPlainClient(
"", "",
config.Username, config.Username,
@ -29,51 +25,15 @@ func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailS
} }
} }
func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error { func (s *SMTPSendingService) Send(mail *models.Mail) error {
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(s.config.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectPasswordReset,
}
mail.WithHTML(template.String())
return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader())
}
func (s *SMTPMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
template, err := getImportNotificationTemplate(ImportNotificationTplData{
PublicUrl: s.publicUrl,
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
NumHeartbeats: numHeartbeats,
})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(s.config.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectImportNotification,
}
mail.WithHTML(template.String())
return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader())
}
func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string, to []string, r io.Reader) error {
dial := smtp.Dial dial := smtp.Dial
if tls { if s.config.TLS {
dial = func(addr string) (*smtp.Client, error) { dial = func(addr string) (*smtp.Client, error) {
return smtp.DialTLS(addr, nil) return smtp.DialTLS(addr, nil)
} }
} }
c, err := dial(addr) c, err := dial(s.config.ConnStr())
if err != nil { if err != nil {
return err return err
} }
@ -85,18 +45,18 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string
return err return err
} }
} }
if a != nil { if s.auth != nil {
if ok, _ := c.Extension("AUTH"); !ok { if ok, _ := c.Extension("AUTH"); !ok {
return errors.New("smtp: server doesn't support AUTH") return errors.New("smtp: server doesn't support AUTH")
} }
if err = c.Auth(a); err != nil { if err = c.Auth(s.auth); err != nil {
return err return err
} }
} }
if err = c.Mail(from, nil); err != nil { if err = c.Mail(mail.From.Raw(), nil); err != nil {
return err return err
} }
for _, addr := range to { for _, addr := range mail.To.RawStrings() {
if err = c.Rcpt(addr); err != nil { if err = c.Rcpt(addr); err != nil {
return err return err
} }
@ -105,7 +65,7 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string
if err != nil { if err != nil {
return err return err
} }
_, err = io.Copy(w, r) _, err = io.Copy(w, mail.Reader())
if err != nil { if err != nil {
return err return err
} }

17
services/mail/types.go Normal file
View File

@ -0,0 +1,17 @@
package mail
import "github.com/muety/wakapi/models"
type PasswordResetTplData struct {
ResetLink string
}
type ImportNotificationTplData struct {
PublicUrl string
Duration string
NumHeartbeats int
}
type ReportTplData struct {
Report *models.Report
}

View File

@ -3,7 +3,6 @@ package services
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"go.uber.org/atomic"
"runtime" "runtime"
"strconv" "strconv"
"time" "time"
@ -17,7 +16,6 @@ type MiscService struct {
userService IUserService userService IUserService
summaryService ISummaryService summaryService ISummaryService
keyValueService IKeyValueService keyValueService IKeyValueService
jobCount atomic.Uint32
} }
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService { func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
@ -42,7 +40,7 @@ type CountTotalTimeResult struct {
func (srv *MiscService) ScheduleCountTotalTime() { func (srv *MiscService) ScheduleCountTotalTime() {
// Run once initially // Run once initially
if err := srv.runCountTotalTime(); err != nil { if err := srv.runCountTotalTime(); err != nil {
logbuch.Error("failed to run CountTotalTimeJob: %v", err) logbuch.Fatal("failed to run CountTotalTimeJob: %v", err)
} }
s := gocron.NewScheduler(time.Local) s := gocron.NewScheduler(time.Local)
@ -51,57 +49,34 @@ func (srv *MiscService) ScheduleCountTotalTime() {
} }
func (srv *MiscService) runCountTotalTime() error { func (srv *MiscService) runCountTotalTime() error {
jobs := make(chan *CountTotalTimeJob) users, err := srv.userService.GetAll()
results := make(chan *CountTotalTimeResult) if err != nil {
return err
}
defer close(jobs) jobs := make(chan *CountTotalTimeJob, len(users))
results := make(chan *CountTotalTimeResult, len(users))
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
}
}
close(jobs)
for i := 0; i < runtime.NumCPU(); i++ { for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results) go srv.countTotalTimeWorker(jobs, results)
} }
go srv.persistTotalTimeWorker(results) // persist
var i int
// generate the jobs
if users, err := srv.userService.GetAll(); err == nil {
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
}
}
} else {
return err
}
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, false); err != nil {
logbuch.Error("failed to count total for user %s: %v", job.UserID, err)
} else {
logbuch.Info("successfully counted total for user %s", job.UserID)
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
}
}
if srv.jobCount.Inc() == uint32(job.NumJobs) {
srv.jobCount.Store(0)
close(results)
}
}
}
func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeResult) {
var c int
var total time.Duration var total time.Duration
for result := range results { for i = 0; i < len(users); i++ {
result := <-results
total += result.Total total += result.Total
c++
} }
close(results)
if err := srv.keyValueService.PutString(&models.KeyStringValue{ if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime, Key: config.KeyLatestTotalTime,
@ -112,8 +87,23 @@ func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeRes
if err := srv.keyValueService.PutString(&models.KeyStringValue{ if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers, Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(c), Value: strconv.Itoa(i),
}); err != nil { }); err != nil {
logbuch.Error("failed to save total users count: %v", err) logbuch.Error("failed to save total users count: %v", err)
} }
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, false); err != nil {
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
} else {
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
}
}
}
} }

78
services/project_label.go Normal file
View File

@ -0,0 +1,78 @@
package services
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type ProjectLabelService struct {
config *config.Config
cache *cache.Cache
repository repositories.IProjectLabelRepository
}
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
return &ProjectLabelService{
config: config.Get(),
repository: projectLabelRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
return srv.repository.GetById(id)
}
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if labels, found := srv.cache.Get(userId); found {
return labels.([]*models.ProjectLabel), nil
}
labels, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, labels, cache.DefaultExpiration)
return labels, nil
}
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
labels := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labels[l.ProjectKey]; !ok {
labels[l.ProjectKey] = []*models.ProjectLabel{l}
} else {
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
}
}
return labels, nil
}
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
result, err := srv.repository.Insert(label)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
return result, nil
}
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
if label.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(label.ID)
srv.cache.Delete(label.UserID)
return err
}

141
services/report.go Normal file
View File

@ -0,0 +1,141 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/go-co-op/gocron"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"math/rand"
"sync"
"time"
)
var reportLock = sync.Mutex{}
// range for random offset to add / subtract when scheduling a new job
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
const offsetIntervalMin = 15
type ReportService struct {
config *config.Config
eventBus *hub.Hub
summaryService ISummaryService
userService IUserService
mailService IMailService
scheduler *gocron.Scheduler
rand *rand.Rand
}
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
srv := &ReportService{
config: config.Get(),
eventBus: config.EventBus(),
summaryService: summaryService,
userService: userService,
mailService: mailService,
scheduler: gocron.NewScheduler(time.Local),
rand: rand.New(rand.NewSource(time.Now().Unix())),
}
srv.scheduler.StartAsync()
sub := srv.eventBus.Subscribe(0, config.EventUserUpdate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
srv.SyncSchedule(m.Fields[config.FieldPayload].(*models.User))
}
}(&sub)
return srv
}
func (srv *ReportService) Schedule() {
logbuch.Info("initializing report service")
users, err := srv.userService.GetAllByReports(true)
if err != nil {
config.Log().Fatal("%v", err)
}
logbuch.Info("scheduling reports for %d users", len(users))
for _, u := range users {
srv.SyncSchedule(u)
}
}
// SyncSchedule syncs the currently active schedulers with the user's wish about whether or not to receive reports.
// Returns whether a scheduler is active after this operation has run.
func (srv *ReportService) SyncSchedule(u *models.User) bool {
reportLock.Lock()
defer reportLock.Unlock()
// unschedule
if !u.ReportsWeekly {
_ = srv.scheduler.RemoveByTag(u.ID)
return false
}
// schedule
if j := srv.getJobByTag(u.ID); j == nil && u.ReportsWeekly {
t, _ := time.ParseInLocation("15:04", srv.config.App.GetWeeklyReportTime(), u.TZ())
t = t.Add(time.Duration(srv.rand.Intn(offsetIntervalMin)*srv.rand.Intn(2)) * time.Minute)
if _, err := srv.scheduler.
Every(1).
Week().
Weekday(srv.config.App.GetWeeklyReportDay()).
At(t).
Tag(u.ID).
Do(srv.Run, u, 7*24*time.Hour); err != nil {
config.Log().Error("failed to schedule report job for user '%s' %v", u.ID, err)
}
}
return u.ReportsWeekly
}
func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
if user.Email == "" {
logbuch.Warn("not generating report for '%s' as no e-mail address is set")
}
if !srv.SyncSchedule(user) {
logbuch.Info("reports for user '%s' were turned off in the meanwhile since last report job ran")
return nil
}
end := time.Now().In(user.TZ())
start := time.Now().Add(-1 * duration)
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, false)
if err != nil {
config.Log().Error("failed to generate report for '%s' %v", user.ID, err)
return err
}
report := &models.Report{
From: start,
To: end,
User: user,
Summary: summary,
}
if err := srv.mailService.SendReport(user, report); err != nil {
config.Log().Error("failed to send report for '%s' %v", user.ID, err)
return err
}
logbuch.Info("sent report to user '%s'", user.ID)
return nil
}
func (srv *ReportService) getJobByTag(tag string) *gocron.Job {
for _, j := range srv.scheduler.Jobs() {
for _, t := range j.Tags() {
if t == tag {
return j
}
}
}
return nil
}

View File

@ -33,7 +33,9 @@ type IHeartbeatService interface {
CountByUsers([]*models.User) ([]*models.CountByUser, error) CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
} }
@ -52,6 +54,20 @@ type ILanguageMappingService interface {
Delete(mapping *models.LanguageMapping) error Delete(mapping *models.LanguageMapping) error
} }
type IProjectLabelService interface {
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(*models.ProjectLabel) error
}
type IMailService interface {
SendPasswordReset(*models.User, string) error
SendImportNotification(*models.User, time.Duration, int) error
SendReport(*models.User, *models.Report) error
}
type ISummaryService interface { type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error) Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error) Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
@ -61,12 +77,19 @@ type ISummaryService interface {
Insert(*models.Summary) error Insert(*models.Summary) error
} }
type IReportService interface {
Schedule()
SyncSchedule(user *models.User) bool
Run(*models.User, time.Duration) error
}
type IUserService interface { type IUserService interface {
GetUserById(string) (*models.User, error) GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error) GetUserByKey(string) (*models.User, error)
GetUserByEmail(string) (*models.User, error) GetUserByEmail(string) (*models.User, error)
GetUserByResetToken(string) (*models.User, error) GetUserByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error) GetAll() ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetActive() ([]*models.User, error) GetActive() ([]*models.User, error)
Count() (int64, error) Count() (int64, error)
CreateOrGet(*models.Signup, bool) (*models.User, bool, error) CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
@ -78,8 +101,3 @@ type IUserService interface {
GenerateResetToken(*models.User) (*models.User, error) GenerateResetToken(*models.User) (*models.User, error)
FlushCache() FlushCache()
} }
type IMailService interface {
SendPasswordReset(*models.User, string) error
SendImportNotification(*models.User, time.Duration, int) error
}

View File

@ -1,36 +1,39 @@
package services package services
import ( import (
"crypto/md5"
"errors" "errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"math" "math"
"sort" "sort"
"strings"
"time" "time"
) )
const HeartbeatDiffThreshold = 2 * time.Minute const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct { type SummaryService struct {
config *config.Config config *config.Config
cache *cache.Cache cache *cache.Cache
repository repositories.ISummaryRepository repository repositories.ISummaryRepository
heartbeatService IHeartbeatService heartbeatService IHeartbeatService
aliasService IAliasService aliasService IAliasService
projectLabelService IProjectLabelService
} }
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error) type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService { func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
return &SummaryService{ return &SummaryService{
config: config.Get(), config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour), cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo, repository: summaryRepo,
heartbeatService: heartbeatService, heartbeatService: heartbeatService,
aliasService: aliasService, aliasService: aliasService,
projectLabelService: projectLabelService,
} }
} }
@ -62,17 +65,14 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
// Post-process summary and cache it // Post-process summary and cache it
summary := s.WithResolvedAliases(resolve) summary := s.WithResolvedAliases(resolve)
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
srv.cache.SetDefault(cacheKey, summary) srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil return summary.Sorted(), nil
} }
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) { func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval // Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to) summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil { if err != nil {
@ -95,8 +95,6 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
return nil, err return nil, err
} }
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil return summary.Sorted(), nil
} }
@ -109,7 +107,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
return nil, err return nil, err
} }
types := models.SummaryTypes() types := models.NativeSummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer) typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations) defer close(typedAggregations)
@ -155,8 +153,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
OperatingSystems: osItems, OperatingSystems: osItems,
Machines: machineItems, Machines: machineItems,
} }
summary = srv.withProjectLabels(summary)
//summary.FillUnknown()
return summary.Sorted(), nil return summary.Sorted(), nil
} }
@ -168,10 +165,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
} }
func (srv *SummaryService) DeleteByUser(userId string) error { func (srv *SummaryService) DeleteByUser(userId string) error {
srv.invalidateUserCache(userId)
return srv.repository.DeleteByUser(userId) return srv.repository.DeleteByUser(userId)
} }
func (srv *SummaryService) Insert(summary *models.Summary) error { func (srv *SummaryService) Insert(summary *models.Summary) error {
srv.invalidateUserCache(summary.UserID)
return srv.repository.Insert(summary) return srv.repository.Insert(summary)
} }
@ -219,6 +218,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items} c <- models.SummaryItemContainer{Type: summaryType, Items: items}
} }
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
newEntry := func(key string, total time.Duration) *models.SummaryItem {
return &models.SummaryItem{
Type: models.SummaryLabel,
Key: key,
Total: total,
}
}
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
if err != nil {
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
return summary
}
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
for _, p := range summary.Projects {
mappedProjects[p.Key] = p
}
var totalLabelTime time.Duration
labelMap := make(map[string]*models.SummaryItem, 0)
for _, l := range allLabels {
if p, ok := mappedProjects[l.ProjectKey]; ok {
if _, ok2 := labelMap[l.Label]; !ok2 {
labelMap[l.Label] = newEntry(l.Label, 0)
}
labelMap[l.Label].Total += p.Total
totalLabelTime += p.Total
}
}
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
labels := make([]*models.SummaryItem, 0, len(labelMap))
for _, v := range labelMap {
if v.Total > 0 {
labels = append(labels, v)
}
}
summary.Labels = labels
return summary
}
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) { func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 { if len(summaries) < 1 {
return nil, errors.New("no summaries given") return nil, errors.New("no summaries given")
@ -234,9 +276,18 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Editors: make([]*models.SummaryItem, 0), Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0), OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0), Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
} }
var processed = map[time.Time]bool{}
for _, s := range summaries { for _, s := range summaries {
hash := s.FromTime.T()
if _, found := processed[hash]; found {
logbuch.Warn("summary from %v to %v (user '%s') was attempted to be processed more often than once", s.FromTime, s.ToTime, s.UserID)
continue
}
if s.UserID != finalSummary.UserID { if s.UserID != finalSummary.UserID {
return nil, errors.New("users don't match") return nil, errors.New("users don't match")
} }
@ -254,6 +305,9 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors) finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems) finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines) finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
processed[hash] = true
} }
finalSummary.FromTime = models.CustomTime(minTime) finalSummary.FromTime = models.CustomTime(minTime)
@ -338,9 +392,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
} }
func (srv *SummaryService) getHash(args ...string) string { func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New() return strings.Join(args, "__")
for _, a := range args { }
digest.Write([]byte(a))
} func (srv *SummaryService) invalidateUserCache(userId string) {
return string(digest.Sum(nil)) for key := range srv.cache.Items() {
if strings.Contains(key, userId) {
srv.cache.Delete(key)
}
}
} }

View File

@ -16,6 +16,9 @@ const (
TestUserId = "muety" TestUserId = "muety"
TestProject1 = "test-project-1" TestProject1 = "test-project-1"
TestProject2 = "test-project-2" TestProject2 = "test-project-2"
TestProjectLabel1 = "private"
TestProjectLabel2 = "work"
TestProjectLabel3 = "non-existing"
TestLanguageGo = "Go" TestLanguageGo = "Go"
TestLanguageJava = "Java" TestLanguageJava = "Java"
TestLanguagePython = "Python" TestLanguagePython = "Python"
@ -31,12 +34,14 @@ const (
type SummaryServiceTestSuite struct { type SummaryServiceTestSuite struct {
suite.Suite suite.Suite
TestUser *models.User TestUser *models.User
TestStartTime time.Time TestStartTime time.Time
TestHeartbeats []*models.Heartbeat TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock TestLabels []*models.ProjectLabel
HeartbeatService *mocks.HeartbeatServiceMock SummaryRepository *mocks.SummaryRepositoryMock
AliasService *mocks.AliasServiceMock HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock
} }
func (suite *SummaryServiceTestSuite) SetupSuite() { func (suite *SummaryServiceTestSuite) SetupSuite() {
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
}, },
} }
suite.TestLabels = []*models.ProjectLabel{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProject1,
Label: TestProjectLabel1,
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProjectLabel3,
Label: "blaahh",
},
}
} }
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) { func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock) suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock) suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock) suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
} }
func TestSummaryServiceTestSuite(t *testing.T) { func TestSummaryServiceTestSuite(t *testing.T) {
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var ( var (
from time.Time from time.Time
@ -100,6 +120,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */ /* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -113,6 +134,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */ /* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -126,6 +148,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 3 */ /* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -136,12 +159,16 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
assert.Equal(suite.T(), 150*time.Second, result.TotalTime()) assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland)) assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode)) assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Equal(suite.T(), 150*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel3))
assert.Len(suite.T(), result.Editors, 2) assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e") assertNumAllItems(suite.T(), 1, result, "e")
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var ( var (
summaries []*models.Summary summaries []*models.Summary
@ -291,8 +318,58 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1) suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1)
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
summaries []*models.Summary
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice!
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var ( var (
from time.Time from time.Time

View File

@ -1,6 +1,7 @@
package services package services
import ( import (
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
@ -11,16 +12,18 @@ import (
) )
type UserService struct { type UserService struct {
Config *config.Config config *config.Config
cache *cache.Cache cache *cache.Cache
eventBus *hub.Hub
repository repositories.IUserRepository repository repositories.IUserRepository
} }
func NewUserService(userRepo repositories.IUserRepository) *UserService { func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{ return &UserService{
Config: config.Get(), config: config.Get(),
repository: userRepo, eventBus: config.EventBus(),
cache: cache.New(1*time.Hour, 2*time.Hour), cache: cache.New(1*time.Hour, 2*time.Hour),
repository: userRepo,
} }
} }
@ -64,8 +67,12 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
return srv.repository.GetAll() return srv.repository.GetAll()
} }
func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
return srv.repository.GetAllByReports(reportsEnabled)
}
func (srv *UserService) GetActive() ([]*models.User, error) { func (srv *UserService) GetActive() ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.Config.App.InactiveDays)) minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.config.App.InactiveDays))
return srv.repository.GetByLastActiveAfter(minDate) return srv.repository.GetByLastActiveAfter(minDate)
} }
@ -76,13 +83,14 @@ func (srv *UserService) Count() (int64, error) {
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) { func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
u := &models.User{ u := &models.User{
ID: signup.Username, ID: signup.Username,
Email: signup.Email,
ApiKey: uuid.NewV4().String(), ApiKey: uuid.NewV4().String(),
Email: signup.Email,
Location: signup.Location,
Password: signup.Password, Password: signup.Password,
IsAdmin: isAdmin, IsAdmin: isAdmin,
} }
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil { if hash, err := utils.HashBcrypt(u.Password, srv.config.Security.PasswordSalt); err != nil {
return nil, false, err return nil, false, err
} else { } else {
u.Password = hash u.Password = hash
@ -93,6 +101,7 @@ func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*model
func (srv *UserService) Update(user *models.User) (*models.User, error) { func (srv *UserService) Update(user *models.User) (*models.User, error) {
srv.cache.Flush() srv.cache.Flush()
srv.notifyUpdate(user)
return srv.repository.Update(user) return srv.repository.Update(user)
} }
@ -110,7 +119,7 @@ func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*mo
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
srv.cache.Flush() srv.cache.Flush()
user.Password = login.Password user.Password = login.Password
if hash, err := utils.HashBcrypt(user.Password, srv.Config.Security.PasswordSalt); err != nil { if hash, err := utils.HashBcrypt(user.Password, srv.config.Security.PasswordSalt); err != nil {
return nil, err return nil, err
} else { } else {
user.Password = hash user.Password = hash
@ -124,9 +133,20 @@ func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, err
func (srv *UserService) Delete(user *models.User) error { func (srv *UserService) Delete(user *models.User) error {
srv.cache.Flush() srv.cache.Flush()
user.ReportsWeekly = false
srv.notifyUpdate(user)
return srv.repository.Delete(user) return srv.repository.Delete(user)
} }
func (srv *UserService) FlushCache() { func (srv *UserService) FlushCache() {
srv.cache.Flush() srv.cache.Flush()
} }
func (srv *UserService) notifyUpdate(user *models.User) {
srv.eventBus.Publish(hub.Message{
Name: config.EventUserUpdate,
Fields: map[string]interface{}{config.FieldPayload: user},
})
}

View File

@ -1,3 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/** sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
sonar.tests=. sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
const editorsCanvas = document.getElementById('chart-editor') const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language') const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine') const machinesCanvas = document.getElementById('chart-machine')
const labelsCanvas = document.getElementById('chart-label')
const projectContainer = document.getElementById('project-container') const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container') const osContainer = document.getElementById('os-container')
const editorContainer = document.getElementById('editor-container') const editorContainer = document.getElementById('editor-container')
const languageContainer = document.getElementById('language-container') const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container') const machineContainer = document.getElementById('machine-container')
const labelContainer = document.getElementById('label-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer] const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas] const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines] const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
let topNPickers = [...document.getElementsByClassName('top-picker')] let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value))) topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -255,9 +257,42 @@ function draw(subselection) {
}) })
: null : null
let labelChart = !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
? new Chart(labelsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('labels'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems) getTotal(wakapiData.operatingSystems)
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c) charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
if (!subselection) { if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart)) charts.forEach(c => c.options.onResize(c.chart))

9
static/assets/icons.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#eee"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/><path d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm-.743-.55M28.93 94.535c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zm-.575-.618M31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm0 0M34.573 101.373c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm0 0M39.073 103.324c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm0 0M44.016 103.685c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm0 0M48.614 102.903c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

352
static/assets/timezones.js Normal file
View File

@ -0,0 +1,352 @@
// https://stackoverflow.com/a/54500197/3112139
const tzs = [
'Europe/Andorra',
'Asia/Dubai',
'Asia/Kabul',
'Europe/Tirane',
'Asia/Yerevan',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Mawson',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'America/Argentina/Buenos_Aires',
'America/Argentina/Cordoba',
'America/Argentina/Salta',
'America/Argentina/Jujuy',
'America/Argentina/Tucuman',
'America/Argentina/Catamarca',
'America/Argentina/La_Rioja',
'America/Argentina/San_Juan',
'America/Argentina/Mendoza',
'America/Argentina/San_Luis',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Ushuaia',
'Pacific/Pago_Pago',
'Europe/Vienna',
'Australia/Lord_Howe',
'Antarctica/Macquarie',
'Australia/Hobart',
'Australia/Currie',
'Australia/Melbourne',
'Australia/Sydney',
'Australia/Broken_Hill',
'Australia/Brisbane',
'Australia/Lindeman',
'Australia/Adelaide',
'Australia/Darwin',
'Australia/Perth',
'Australia/Eucla',
'Asia/Baku',
'America/Barbados',
'Asia/Dhaka',
'Europe/Brussels',
'Europe/Sofia',
'Atlantic/Bermuda',
'Asia/Brunei',
'America/La_Paz',
'America/Noronha',
'America/Belem',
'America/Fortaleza',
'America/Recife',
'America/Araguaina',
'America/Maceio',
'America/Bahia',
'America/Sao_Paulo',
'America/Campo_Grande',
'America/Cuiaba',
'America/Santarem',
'America/Porto_Velho',
'America/Boa_Vista',
'America/Manaus',
'America/Eirunepe',
'America/Rio_Branco',
'America/Nassau',
'Asia/Thimphu',
'Europe/Minsk',
'America/Belize',
'America/St_Johns',
'America/Halifax',
'America/Glace_Bay',
'America/Moncton',
'America/Goose_Bay',
'America/Blanc-Sablon',
'America/Toronto',
'America/Nipigon',
'America/Thunder_Bay',
'America/Iqaluit',
'America/Pangnirtung',
'America/Atikokan',
'America/Winnipeg',
'America/Rainy_River',
'America/Resolute',
'America/Rankin_Inlet',
'America/Regina',
'America/Swift_Current',
'America/Edmonton',
'America/Cambridge_Bay',
'America/Yellowknife',
'America/Inuvik',
'America/Creston',
'America/Dawson_Creek',
'America/Fort_Nelson',
'America/Vancouver',
'America/Whitehorse',
'America/Dawson',
'Indian/Cocos',
'Europe/Zurich',
'Africa/Abidjan',
'Pacific/Rarotonga',
'America/Santiago',
'America/Punta_Arenas',
'Pacific/Easter',
'Asia/Shanghai',
'Asia/Urumqi',
'America/Bogota',
'America/Costa_Rica',
'America/Havana',
'Atlantic/Cape_Verde',
'America/Curacao',
'Indian/Christmas',
'Asia/Nicosia',
'Asia/Famagusta',
'Europe/Prague',
'Europe/Berlin',
'Europe/Copenhagen',
'America/Santo_Domingo',
'Africa/Algiers',
'America/Guayaquil',
'Pacific/Galapagos',
'Europe/Tallinn',
'Africa/Cairo',
'Africa/El_Aaiun',
'Europe/Madrid',
'Africa/Ceuta',
'Atlantic/Canary',
'Europe/Helsinki',
'Pacific/Fiji',
'Atlantic/Stanley',
'Pacific/Chuuk',
'Pacific/Pohnpei',
'Pacific/Kosrae',
'Atlantic/Faroe',
'Europe/Paris',
'Europe/London',
'Asia/Tbilisi',
'America/Cayenne',
'Africa/Accra',
'Europe/Gibraltar',
'America/Godthab',
'America/Danmarkshavn',
'America/Scoresbysund',
'America/Thule',
'Europe/Athens',
'Atlantic/South_Georgia',
'America/Guatemala',
'Pacific/Guam',
'Africa/Bissau',
'America/Guyana',
'Asia/Hong_Kong',
'America/Tegucigalpa',
'America/Port-au-Prince',
'Europe/Budapest',
'Asia/Jakarta',
'Asia/Pontianak',
'Asia/Makassar',
'Asia/Jayapura',
'Europe/Dublin',
'Asia/Jerusalem',
'Asia/Kolkata',
'Indian/Chagos',
'Asia/Baghdad',
'Asia/Tehran',
'Atlantic/Reykjavik',
'Europe/Rome',
'America/Jamaica',
'Asia/Amman',
'Asia/Tokyo',
'Africa/Nairobi',
'Asia/Bishkek',
'Pacific/Tarawa',
'Pacific/Enderbury',
'Pacific/Kiritimati',
'Asia/Pyongyang',
'Asia/Seoul',
'Asia/Almaty',
'Asia/Qyzylorda',
'Asia/Qostanay',
'Asia/Aqtobe',
'Asia/Aqtau',
'Asia/Atyrau',
'Asia/Oral',
'Asia/Beirut',
'Asia/Colombo',
'Africa/Monrovia',
'Europe/Vilnius',
'Europe/Luxembourg',
'Europe/Riga',
'Africa/Tripoli',
'Africa/Casablanca',
'Europe/Monaco',
'Europe/Chisinau',
'Pacific/Majuro',
'Pacific/Kwajalein',
'Asia/Yangon',
'Asia/Ulaanbaatar',
'Asia/Hovd',
'Asia/Choibalsan',
'Asia/Macau',
'America/Martinique',
'Europe/Malta',
'Indian/Mauritius',
'Indian/Maldives',
'America/Mexico_City',
'America/Cancun',
'America/Merida',
'America/Monterrey',
'America/Matamoros',
'America/Mazatlan',
'America/Chihuahua',
'America/Ojinaga',
'America/Hermosillo',
'America/Tijuana',
'America/Bahia_Banderas',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Africa/Maputo',
'Africa/Windhoek',
'Pacific/Noumea',
'Pacific/Norfolk',
'Africa/Lagos',
'America/Managua',
'Europe/Amsterdam',
'Europe/Oslo',
'Asia/Kathmandu',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Auckland',
'Pacific/Chatham',
'America/Panama',
'America/Lima',
'Pacific/Tahiti',
'Pacific/Marquesas',
'Pacific/Gambier',
'Pacific/Port_Moresby',
'Pacific/Bougainville',
'Asia/Manila',
'Asia/Karachi',
'Europe/Warsaw',
'America/Miquelon',
'Pacific/Pitcairn',
'America/Puerto_Rico',
'Asia/Gaza',
'Asia/Hebron',
'Europe/Lisbon',
'Atlantic/Madeira',
'Atlantic/Azores',
'Pacific/Palau',
'America/Asuncion',
'Asia/Qatar',
'Indian/Reunion',
'Europe/Bucharest',
'Europe/Belgrade',
'Europe/Kaliningrad',
'Europe/Moscow',
'Europe/Simferopol',
'Europe/Kirov',
'Europe/Astrakhan',
'Europe/Volgograd',
'Europe/Saratov',
'Europe/Ulyanovsk',
'Europe/Samara',
'Asia/Yekaterinburg',
'Asia/Omsk',
'Asia/Novosibirsk',
'Asia/Barnaul',
'Asia/Tomsk',
'Asia/Novokuznetsk',
'Asia/Krasnoyarsk',
'Asia/Irkutsk',
'Asia/Chita',
'Asia/Yakutsk',
'Asia/Khandyga',
'Asia/Vladivostok',
'Asia/Ust-Nera',
'Asia/Magadan',
'Asia/Sakhalin',
'Asia/Srednekolymsk',
'Asia/Kamchatka',
'Asia/Anadyr',
'Asia/Riyadh',
'Pacific/Guadalcanal',
'Indian/Mahe',
'Africa/Khartoum',
'Europe/Stockholm',
'Asia/Singapore',
'America/Paramaribo',
'Africa/Juba',
'Africa/Sao_Tome',
'America/El_Salvador',
'Asia/Damascus',
'America/Grand_Turk',
'Africa/Ndjamena',
'Indian/Kerguelen',
'Asia/Bangkok',
'Asia/Dushanbe',
'Pacific/Fakaofo',
'Asia/Dili',
'Asia/Ashgabat',
'Africa/Tunis',
'Pacific/Tongatapu',
'Europe/Istanbul',
'America/Port_of_Spain',
'Pacific/Funafuti',
'Asia/Taipei',
'Europe/Kiev',
'Europe/Uzhgorod',
'Europe/Zaporozhye',
'Pacific/Wake',
'America/New_York',
'America/Detroit',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Indiana/Indianapolis',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Vevay',
'America/Chicago',
'America/Indiana/Tell_City',
'America/Indiana/Knox',
'America/Menominee',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/North_Dakota/Beulah',
'America/Denver',
'America/Boise',
'America/Phoenix',
'America/Los_Angeles',
'America/Anchorage',
'America/Juneau',
'America/Sitka',
'America/Metlakatla',
'America/Yakutat',
'America/Nome',
'America/Adak',
'Pacific/Honolulu',
'America/Montevideo',
'Asia/Samarkand',
'Asia/Tashkent',
'America/Caracas',
'Asia/Ho_Chi_Minh',
'Pacific/Efate',
'Pacific/Wallis',
'Pacific/Apia',
'Africa/Johannesburg'
]

File diff suppressed because one or more lines are too long

View File

@ -641,6 +641,12 @@ video {
background-color: rgba(47, 133, 90, var(--bg-opacity)); background-color: rgba(47, 133, 90, var(--bg-opacity));
} }
.hover\:bg-gray-700:hover {
--bg-opacity: 1;
background-color: #4a5568;
background-color: rgba(74, 85, 104, var(--bg-opacity));
}
.hover\:bg-red-600:hover { .hover\:bg-red-600:hover {
--bg-opacity: 1; --bg-opacity: 1;
background-color: #e53e3e; background-color: #e53e3e;
@ -713,6 +719,10 @@ video {
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.rounded-full {
border-radius: 9999px;
}
.border { .border {
border-width: 1px; border-width: 1px;
} }
@ -745,10 +755,18 @@ video {
display: inline-block; display: inline-block;
} }
.inline {
display: inline;
}
.flex { .flex {
display: flex; display: flex;
} }
.inline-flex {
display: inline-flex;
}
.table { .table {
display: table; display: table;
} }
@ -769,6 +787,10 @@ video {
align-items: center; align-items: center;
} }
.self-end {
align-self: flex-end;
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -813,6 +835,10 @@ video {
font-weight: 600; font-weight: 600;
} }
.h-4 {
height: 1rem;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
@ -845,6 +871,10 @@ video {
font-size: 2.25rem; font-size: 2.25rem;
} }
.leading-none {
line-height: 1;
}
.list-inside { .list-inside {
list-style-position: inside; list-style-position: inside;
} }
@ -1178,10 +1208,18 @@ video {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.w-4 {
width: 1rem;
}
.w-1\/2 { .w-1\/2 {
width: 50%; width: 50%;
} }
.w-1\/3 {
width: 33.333333%;
}
.w-1\/4 { .w-1\/4 {
width: 25%; width: 25%;
} }

2
static/assets/vendor/twemoji.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -90,6 +90,41 @@ var doc = `{
} }
} }
}, },
"/compat/wakatime/v1/users/{user}": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#users",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve the given user",
"operationId": "get-wakatime-user",
"parameters": [
{
"type": "string",
"description": "User ID to fetch (or 'current')",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.UserViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/all_time_since_today": { "/compat/wakatime/v1/users/{user}/all_time_since_today": {
"get": { "get": {
"security": [ "security": [
@ -125,6 +160,103 @@ var doc = `{
} }
} }
}, },
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#projects",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve and fitler the user's projects",
"operationId": "get-wakatime-projects",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Query to filter projects by",
"name": "q",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.ProjectsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#stats",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve statistics for a given user",
"operationId": "get-wakatimes-tats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": { "/compat/wakatime/v1/users/{user}/summaries": {
"get": { "get": {
"security": [ "security": [
@ -309,57 +441,6 @@ var doc = `{
} }
} }
} }
},
"/v1/users/{user}/stats/{range}": {
"get": {
"description": "Mimics https://wakatime.com/developers#stats. Requires public data access to be allowed.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve stats",
"operationId": "get-stats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
@ -372,6 +453,10 @@ var doc = `{
"category": { "category": {
"type": "string" "type": "string"
}, },
"created_at": {
"description": "https://gorm.io/docs/conventions.html#CreatedAt",
"type": "number"
},
"editor": { "editor": {
"description": "ignored because editor might be parsed differently by wakatime", "description": "ignored because editor might be parsed differently by wakatime",
"type": "string" "type": "string"
@ -473,6 +558,9 @@ var doc = `{
"description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e", "description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e",
"type": "boolean" "type": "boolean"
}, },
"range": {
"$ref": "#/definitions/v1.AllTimeRange"
},
"text": { "text": {
"description": "total time logged since account created as human readable string\u003e", "description": "total time logged since account created as human readable string\u003e",
"type": "string" "type": "string"
@ -483,6 +571,26 @@ var doc = `{
} }
} }
}, },
"v1.AllTimeRange": {
"type": "object",
"properties": {
"end": {
"type": "string"
},
"end_date": {
"type": "string"
},
"start": {
"type": "string"
},
"start_date": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"v1.AllTimeViewModel": { "v1.AllTimeViewModel": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -508,6 +616,31 @@ var doc = `{
} }
} }
}, },
"v1.Project": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"repository": {
"type": "string"
}
}
},
"v1.ProjectsViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.Project"
}
}
}
},
"v1.StatsData": { "v1.StatsData": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -710,6 +843,61 @@ var doc = `{
"type": "string" "type": "string"
} }
} }
},
"v1.User": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"display_name": {
"type": "string"
},
"email": {
"type": "string"
},
"full_name": {
"type": "string"
},
"id": {
"type": "string"
},
"is_email_confirmed": {
"type": "boolean"
},
"is_email_public": {
"type": "boolean"
},
"last_heartbeat_at": {
"type": "string"
},
"last_plugin_name": {
"type": "string"
},
"last_project": {
"type": "string"
},
"modified_at": {
"type": "string"
},
"timezone": {
"type": "string"
},
"username": {
"type": "string"
},
"website": {
"type": "string"
}
}
},
"v1.UserViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.User"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@ -74,6 +74,41 @@
} }
} }
}, },
"/compat/wakatime/v1/users/{user}": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#users",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve the given user",
"operationId": "get-wakatime-user",
"parameters": [
{
"type": "string",
"description": "User ID to fetch (or 'current')",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.UserViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/all_time_since_today": { "/compat/wakatime/v1/users/{user}/all_time_since_today": {
"get": { "get": {
"security": [ "security": [
@ -109,6 +144,103 @@
} }
} }
}, },
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#projects",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve and fitler the user's projects",
"operationId": "get-wakatime-projects",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Query to filter projects by",
"name": "q",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.ProjectsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#stats",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve statistics for a given user",
"operationId": "get-wakatimes-tats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": { "/compat/wakatime/v1/users/{user}/summaries": {
"get": { "get": {
"security": [ "security": [
@ -293,57 +425,6 @@
} }
} }
} }
},
"/v1/users/{user}/stats/{range}": {
"get": {
"description": "Mimics https://wakatime.com/developers#stats. Requires public data access to be allowed.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve stats",
"operationId": "get-stats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
@ -356,6 +437,10 @@
"category": { "category": {
"type": "string" "type": "string"
}, },
"created_at": {
"description": "https://gorm.io/docs/conventions.html#CreatedAt",
"type": "number"
},
"editor": { "editor": {
"description": "ignored because editor might be parsed differently by wakatime", "description": "ignored because editor might be parsed differently by wakatime",
"type": "string" "type": "string"
@ -457,6 +542,9 @@
"description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e", "description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e",
"type": "boolean" "type": "boolean"
}, },
"range": {
"$ref": "#/definitions/v1.AllTimeRange"
},
"text": { "text": {
"description": "total time logged since account created as human readable string\u003e", "description": "total time logged since account created as human readable string\u003e",
"type": "string" "type": "string"
@ -467,6 +555,26 @@
} }
} }
}, },
"v1.AllTimeRange": {
"type": "object",
"properties": {
"end": {
"type": "string"
},
"end_date": {
"type": "string"
},
"start": {
"type": "string"
},
"start_date": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"v1.AllTimeViewModel": { "v1.AllTimeViewModel": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -492,6 +600,31 @@
} }
} }
}, },
"v1.Project": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"repository": {
"type": "string"
}
}
},
"v1.ProjectsViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.Project"
}
}
}
},
"v1.StatsData": { "v1.StatsData": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -694,6 +827,61 @@
"type": "string" "type": "string"
} }
} }
},
"v1.User": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"display_name": {
"type": "string"
},
"email": {
"type": "string"
},
"full_name": {
"type": "string"
},
"id": {
"type": "string"
},
"is_email_confirmed": {
"type": "boolean"
},
"is_email_public": {
"type": "boolean"
},
"last_heartbeat_at": {
"type": "string"
},
"last_plugin_name": {
"type": "string"
},
"last_project": {
"type": "string"
},
"modified_at": {
"type": "string"
},
"timezone": {
"type": "string"
},
"username": {
"type": "string"
},
"website": {
"type": "string"
}
}
},
"v1.UserViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.User"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@ -6,6 +6,9 @@ definitions:
type: string type: string
category: category:
type: string type: string
created_at:
description: https://gorm.io/docs/conventions.html#CreatedAt
type: number
editor: editor:
description: ignored because editor might be parsed differently by wakatime description: ignored because editor might be parsed differently by wakatime
type: string type: string
@ -76,6 +79,8 @@ definitions:
description: true if the stats are up to date; when false, a 202 response description: true if the stats are up to date; when false, a 202 response
code is returned and stats will be refreshed soon> code is returned and stats will be refreshed soon>
type: boolean type: boolean
range:
$ref: '#/definitions/v1.AllTimeRange'
text: text:
description: total time logged since account created as human readable string> description: total time logged since account created as human readable string>
type: string type: string
@ -83,6 +88,19 @@ definitions:
description: total number of seconds logged since account created description: total number of seconds logged since account created
type: number type: number
type: object type: object
v1.AllTimeRange:
properties:
end:
type: string
end_date:
type: string
start:
type: string
start_date:
type: string
timezone:
type: string
type: object
v1.AllTimeViewModel: v1.AllTimeViewModel:
properties: properties:
data: data:
@ -99,6 +117,22 @@ definitions:
schemaVersion: schemaVersion:
type: integer type: integer
type: object type: object
v1.Project:
properties:
id:
type: string
name:
type: string
repository:
type: string
type: object
v1.ProjectsViewModel:
properties:
data:
items:
$ref: '#/definitions/v1.Project'
type: array
type: object
v1.StatsData: v1.StatsData:
properties: properties:
daily_average: daily_average:
@ -232,6 +266,42 @@ definitions:
start: start:
type: string type: string
type: object type: object
v1.User:
properties:
created_at:
type: string
display_name:
type: string
email:
type: string
full_name:
type: string
id:
type: string
is_email_confirmed:
type: boolean
is_email_public:
type: boolean
last_heartbeat_at:
type: string
last_plugin_name:
type: string
last_project:
type: string
modified_at:
type: string
timezone:
type: string
username:
type: string
website:
type: string
type: object
v1.UserViewModel:
properties:
data:
$ref: '#/definitions/v1.User'
type: object
info: info:
contact: contact:
email: ferdinand@muetsch.io email: ferdinand@muetsch.io
@ -294,6 +364,28 @@ paths:
summary: Get badge data summary: Get badge data
tags: tags:
- badges - badges
/compat/wakatime/v1/users/{user}:
get:
description: Mimics https://wakatime.com/developers#users
operationId: get-wakatime-user
parameters:
- description: User ID to fetch (or 'current')
in: path
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.UserViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve the given user
tags:
- wakatime
/compat/wakatime/v1/users/{user}/all_time_since_today: /compat/wakatime/v1/users/{user}/all_time_since_today:
get: get:
description: Mimics https://wakatime.com/developers#all_time_since_today description: Mimics https://wakatime.com/developers#all_time_since_today
@ -316,6 +408,72 @@ paths:
summary: Retrieve summary for all time summary: Retrieve summary for all time
tags: tags:
- wakatime - wakatime
/compat/wakatime/v1/users/{user}/projects:
get:
description: Mimics https://wakatime.com/developers#projects
operationId: get-wakatime-projects
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Query to filter projects by
in: query
name: q
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.ProjectsViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve and fitler the user's projects
tags:
- wakatime
/compat/wakatime/v1/users/{user}/stats/{range}:
get:
description: Mimics https://wakatime.com/developers#stats
operationId: get-wakatimes-tats
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Range interval identifier
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: query
name: range
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.StatsViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve statistics for a given user
tags:
- wakatime
/compat/wakatime/v1/users/{user}/summaries: /compat/wakatime/v1/users/{user}/summaries:
get: get:
description: Mimics https://wakatime.com/developers#summaries. description: Mimics https://wakatime.com/developers#summaries.
@ -441,45 +599,6 @@ paths:
summary: Retrieve a summary summary: Retrieve a summary
tags: tags:
- summary - summary
/v1/users/{user}/stats/{range}:
get:
description: Mimics https://wakatime.com/developers#stats. Requires public data
access to be allowed.
operationId: get-stats
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Range interval identifier
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: path
name: range
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.StatsViewModel'
summary: Retrieve stats
tags:
- wakatime
securityDefinitions: securityDefinitions:
ApiKeyAuth: ApiKeyAuth:
in: header in: header

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
env: production
server:
listen_ipv4: 127.0.0.1
listen_ipv6:
tls_cert_path:
tls_key_path:
port: 3000
base_path: /
public_url: http://localhost:3000
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
inactive_days: 7
custom_languages:
vue: Vue
jsx: JSX
svelte: Svelte
db:
host:
port:
user:
password:
name: wakapi_testing.db
dialect: sqlite3
charset:
max_conn: 2
ssl: false
automgirate_fail_silently: false
security:
password_salt:
insecure_cookies: true
cookie_max_age: 172800
allow_signup: true
expose_metrics: true
sentry:
dsn:
enable_tracing: false
sample_rate:
sample_rate_heartbeats:
mail:
enabled: false
provider: smtp
sender: Wakapi <noreply@wakapi.dev>
smtp:
host:
port:
username:
password:
tls:
mailwhale:
url:
client_id:
client_secret:

14
testing/data.sql Normal file
View File

@ -0,0 +1,14 @@
BEGIN TRANSACTION;
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
COMMIT;

40
testing/run_api_tests.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/bash
if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Compiling."
go build
fi
if ! command -v newman &> /dev/null
then
echo "Newman could not be found. Run 'npm install -g newman' first."
exit 1
fi
cd "$(dirname "$0")"
echo "Creating database and schema ..."
sqlite3 wakapi_testing.db < schema.sql
echo "Importing seed data ..."
sqlite3 wakapi_testing.db < data.sql
echo "Running Wakapi testing instance in background ..."
screen -S wakapi_testing -dm bash -c "../wakapi -config config.testing.yml"
echo "Waiting for Wakapi to come up ..."
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
printf '.'
sleep 1
done
echo ""
echo "Running test collection ..."
newman run "Wakapi API Tests.postman_collection.json"
echo "Shutting down Wakapi ..."
screen -S wakapi_testing -X quit
echo "Deleting database ..."
rm wakapi_testing.db

147
testing/schema.sql Normal file
View File

@ -0,0 +1,147 @@
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "users";
CREATE TABLE IF NOT EXISTS "users" (
"id" text,
"api_key" text UNIQUE,
"email" text,
"password" text,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"last_logged_in_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"share_data_max_days" integer DEFAULT 0,
"share_editors" numeric DEFAULT false,
"share_languages" numeric DEFAULT false,
"share_projects" numeric DEFAULT false,
"share_oss" numeric DEFAULT false,
"share_machines" numeric DEFAULT false,
"is_admin" numeric DEFAULT false,
"has_data" numeric DEFAULT false,
"wakatime_api_key" text,
"reset_token" text,
"location" text,
"reports_weekly" numeric DEFAULT false,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "key_string_values";
CREATE TABLE IF NOT EXISTS "key_string_values" (
"key" text,
"value" text,
PRIMARY KEY("key")
);
DROP TABLE IF EXISTS "summary_items";
CREATE TABLE IF NOT EXISTS "summary_items" (
"id" integer,
"summary_id" integer,
"type" integer,
"key" text,
"total" integer,
CONSTRAINT "fk_summaries_languages" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summary_items_summary" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_machines" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_projects" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_editors" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "aliases";
CREATE TABLE IF NOT EXISTS "aliases" (
"id" integer,
"type" integer NOT NULL,
"user_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
CONSTRAINT "fk_aliases_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "heartbeats";
CREATE TABLE IF NOT EXISTS "heartbeats" (
"id" integer,
"user_id" text NOT NULL,
"entity" text NOT NULL,
"type" text,
"category" text,
"project" text,
"branch" text,
"language" text,
"is_write" numeric,
"editor" text,
"operating_system" text,
"machine" text,
"time" timestamp,
"hash" varchar(17),
"origin" text,
"origin_id" text,
"created_at" timestamp,
CONSTRAINT "fk_heartbeats_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "summaries";
CREATE TABLE IF NOT EXISTS "summaries" (
"id" integer,
"user_id" text NOT NULL,
"from_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"to_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_summaries_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "language_mappings";
CREATE TABLE IF NOT EXISTS "language_mappings" (
"id" integer,
"user_id" text NOT NULL,
"extension" varchar(16),
"language" varchar(64),
CONSTRAINT "fk_language_mappings_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX IF NOT EXISTS "idx_user_email" ON "users" (
"email"
);
DROP INDEX IF EXISTS "idx_type";
CREATE INDEX IF NOT EXISTS "idx_type" ON "summary_items" (
"type"
);
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX IF NOT EXISTS "idx_alias_type_key" ON "aliases" (
"type",
"key"
);
DROP INDEX IF EXISTS "idx_alias_user";
CREATE INDEX IF NOT EXISTS "idx_alias_user" ON "aliases" (
"user_id"
);
DROP INDEX IF EXISTS "idx_time";
CREATE INDEX IF NOT EXISTS "idx_time" ON "heartbeats" (
"time"
);
DROP INDEX IF EXISTS "idx_heartbeats_hash";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_heartbeats_hash" ON "heartbeats" (
"hash"
);
DROP INDEX IF EXISTS "idx_time_user";
CREATE INDEX IF NOT EXISTS "idx_time_user" ON "heartbeats" (
"user_id"
);
DROP INDEX IF EXISTS "idx_entity";
CREATE INDEX IF NOT EXISTS "idx_entity" ON "heartbeats" (
"entity"
);
DROP INDEX IF EXISTS "idx_language";
CREATE INDEX IF NOT EXISTS "idx_language" ON "heartbeats" (
"language"
);
DROP INDEX IF EXISTS "idx_time_summary_user";
CREATE INDEX IF NOT EXISTS "idx_time_summary_user" ON "summaries" (
"user_id",
"from_time",
"to_time"
);
DROP INDEX IF EXISTS "idx_language_mapping_composite";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_language_mapping_composite" ON "language_mappings" (
"user_id",
"extension"
);
DROP INDEX IF EXISTS "idx_language_mapping_user";
CREATE INDEX IF NOT EXISTS "idx_language_mapping_user" ON "language_mappings" (
"user_id"
);
COMMIT;

9
utils/collection.go Normal file
View File

@ -0,0 +1,9 @@
package utils
func GetMapValues(m map[string]interface{}) []interface{} {
values := make([]interface{}, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}

View File

@ -7,12 +7,22 @@ import (
"time" "time"
) )
func ParseDate(date string) (time.Time, error) { // ParseDateTimeTZ attempts to parse the given date string from multiple formats.
return time.Parse(config.SimpleDateFormat, date) // First, a time-zoned date-time string (e.g. 2006-01-02T15:04:05+02:00) is tried
} // Second, a non-time-zoned date-time string (e.g. 2006-01-02 15:04:05) is tried at the given zone
// Third, a non-time-zoned date string (e.g. 2006-01-02) is tried at the given zone
func ParseDateTime(date string) (time.Time, error) { // Example:
return time.Parse(config.SimpleDateTimeFormat, date) // - Server runs in CEST (UTC+2), requesting user lives in PDT (UTC-7).
// - 2021-04-25T10:30:00Z, 2021-04-25T3:30:00-0100 and 2021-04-25T12:30:00+0200 are equivalent, they represent the same point in time
// - When user requests non-time-zoned range (e.g. 2021-04-25T00:00:00), but has their time zone properly configured, this will resolve to 2021-04-25T09:00:00
func ParseDateTimeTZ(date string, tz *time.Location) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, date); err == nil {
return t, nil
}
if t, err := time.ParseInLocation(config.SimpleDateTimeFormat, date, tz); err == nil {
return t, nil
}
return time.ParseInLocation(config.SimpleDateFormat, date, tz)
} }
func FormatDate(date time.Time) string { func FormatDate(date time.Time) string {
@ -23,10 +33,14 @@ func FormatDateTime(date time.Time) string {
return date.Format(config.SimpleDateTimeFormat) return date.Format(config.SimpleDateTimeFormat)
} }
func FormatDateHuman(date time.Time) string { func FormatDateTimeHuman(date time.Time) string {
return date.Format("Mon, 02 Jan 2006 15:04") return date.Format("Mon, 02 Jan 2006 15:04")
} }
func FormatDateHuman(date time.Time) string {
return date.Format("Mon, 02 Jan 2006")
}
func Add(i, j int) int { func Add(i, j int) int {
return i + j return i + j
} }

View File

@ -5,30 +5,80 @@ import (
"time" "time"
) )
func StartOfToday() time.Time { func StartOfDay(date time.Time) time.Time {
return StartOfDay(time.Now()) return FloorDate(date)
} }
func StartOfDay(date time.Time) time.Time { func StartOfToday(tz *time.Location) time.Time {
return StartOfDay(FloorDate(time.Now().In(tz)))
}
func EndOfDay(date time.Time) time.Time {
floored := FloorDate(date)
if floored == date {
date = date.Add(1 * time.Second)
}
return CeilDate(date)
}
func EndOfToday(tz *time.Location) time.Time {
return EndOfDay(time.Now().In(tz))
}
func StartOfThisWeek(tz *time.Location) time.Time {
return StartOfWeek(time.Now().In(tz))
}
func StartOfWeek(date time.Time) time.Time {
year, week := date.ISOWeek()
return firstDayOfISOWeek(year, week, date.Location())
}
func StartOfThisMonth(tz *time.Location) time.Time {
return StartOfMonth(time.Now().In(tz))
}
func StartOfMonth(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
}
func StartOfThisYear(tz *time.Location) time.Time {
return StartOfYear(time.Now().In(tz))
}
func StartOfYear(date time.Time) time.Time {
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
}
// FloorDate rounds date down to the start of the day and keeps the time zone
func FloorDate(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
} }
func StartOfWeek() time.Time { // CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
ref := time.Now() func CeilDate(date time.Time) time.Time {
year, week := ref.ISOWeek() floored := FloorDate(date)
return firstDayOfISOWeek(year, week, ref.Location()) if floored == date {
return floored
}
return floored.Add(24 * time.Hour)
} }
func StartOfMonth() time.Time { // SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
ref := time.Now() func SetLocation(date time.Time, tz *time.Location) time.Time {
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location()) return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
} }
func StartOfYear() time.Time { // WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
ref := time.Now() func WithOffset(date time.Time, tz *time.Location) time.Time {
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) now := time.Now()
_, localOffset := now.Zone()
_, targetOffset := now.In(tz).Zone()
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
} }
// SplitRangeByDays creates a slice of intervals between from and to, each of which is at max of 24 hours length and has its split at midnight
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time { func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0) intervals := make([][]time.Time, 0)

148
utils/date_test.go Normal file
View File

@ -0,0 +1,148 @@
package utils
import (
"github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
var (
tzLocal *time.Location
tzUtc *time.Location
tzCet *time.Location
tzPst *time.Location
)
func init() {
tzLocal = time.Local
tzUtc, _ = time.LoadLocation("UTC")
tzCet, _ = time.LoadLocation("Europe/Berlin")
tzPst, _ = time.LoadLocation("America/Los_Angeles")
}
func TestDate_Ceil(t *testing.T) {
tests := []struct {
in string
out string
}{
{
"02 Jan 06 15:04 MST",
"03 Jan 06 00:00 MST",
},
{
"03 Jan 06 00:00 MST",
"03 Jan 06 00:00 MST",
},
}
for _, test := range tests {
inDate, _ := time.Parse(time.RFC822, test.in)
outDate, _ := time.Parse(time.RFC822, test.out)
out := CeilDate(inDate)
assert.Equal(t, outDate, out)
}
}
func TestDate_StartOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzCet)
assert.Equal(t, t1, StartOfDay(d1))
assert.Equal(t, t2, StartOfDay(d2))
assert.Equal(t, t3, StartOfDay(d3))
assert.Equal(t, t4, StartOfDay(d4))
assert.Equal(t, tzLocal, StartOfDay(d1).Location())
assert.Equal(t, tzUtc, StartOfDay(d2).Location())
assert.Equal(t, tzPst, StartOfDay(d3).Location())
assert.Equal(t, tzCet, StartOfDay(d4).Location())
}
func TestDate_EndOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzCet)
assert.Equal(t, t1, EndOfDay(d1))
assert.Equal(t, t2, EndOfDay(d2))
assert.Equal(t, t3, EndOfDay(d3))
assert.Equal(t, t4, EndOfDay(d4))
assert.Equal(t, tzLocal, EndOfDay(d1).Location())
assert.Equal(t, tzUtc, EndOfDay(d2).Location())
assert.Equal(t, tzPst, EndOfDay(d3).Location())
assert.Equal(t, tzCet, EndOfDay(d4).Location())
}
func TestDate_StartOfWeek(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzCet)
assert.Equal(t, t1, StartOfWeek(d1))
assert.Equal(t, t2, StartOfWeek(d2))
assert.Equal(t, t3, StartOfWeek(d3))
assert.Equal(t, t4, StartOfWeek(d4))
assert.Equal(t, tzLocal, StartOfWeek(d1).Location())
assert.Equal(t, tzUtc, StartOfWeek(d2).Location())
assert.Equal(t, tzPst, StartOfWeek(d3).Location())
assert.Equal(t, tzCet, StartOfWeek(d4).Location())
}
func TestDate_SplitRangeByDays(t *testing.T) {
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
df2 := df1
dt2 := CeilDate(df1)
df3 := df1
dt3 := df1.Add(10 * time.Second)
df4 := df1
dt4 := df4
result1 := SplitRangeByDays(df1, dt1)
result2 := SplitRangeByDays(df2, dt2)
result3 := SplitRangeByDays(df3, dt3)
result4 := SplitRangeByDays(df4, dt4)
assert.Len(t, result1, 4)
assert.Len(t, result1[0], 2)
assert.Equal(t, result1[0][0], df1)
assert.Equal(t, result1[3][1], dt1)
assert.Equal(t, result1[1][0].Hour()+result1[1][0].Minute()+result1[1][0].Second(), 0)
assert.Equal(t, result1[2][0].Hour()+result1[2][0].Minute()+result1[2][0].Second(), 0)
assert.Equal(t, result1[3][0].Hour()+result1[3][0].Minute()+result1[3][0].Second(), 0)
assert.Equal(t, result1[1][0], result1[0][1])
assert.Equal(t, result1[2][0], result1[1][1])
assert.Equal(t, result1[3][0], result1[2][1])
assert.Len(t, result2, 1)
assert.Equal(t, result2[0][0], df2)
assert.Equal(t, result2[0][1], dt2)
assert.Len(t, result3, 1)
assert.Equal(t, result3[0][0], df3)
assert.Equal(t, result3[0][1], dt3)
assert.Len(t, result4, 0)
}

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