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

Compare commits

...

136 Commits

Author SHA1 Message Date
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
a22836a644 fix: remove uniqueness constraint for email 2021-04-14 00:17:02 +02:00
c8e7fb461a Merge remote-tracking branch 'origin/master' 2021-04-13 23:50:10 +02:00
c2b099378a chore: add contribute.json (resolve #170) 2021-04-13 23:49:54 +02:00
20dd4cf0ab fix: precedence in case of multiple matching language mappings (fix #172) 2021-04-13 23:39:31 +02:00
f8e1453754 fix: failing auto migration of users table (resolve #171) 2021-04-13 23:23:57 +02:00
fbd90d2cc1 ci: adapt go version in github build action 2021-04-13 10:34:35 +02:00
129e208169 fix: very basic sentry error logging 2021-04-13 00:02:55 +02:00
9fd9ffbb3d fix: missing summary aggregation after days without heartbeats (see #168) 2021-04-12 23:36:22 +02:00
0884f620f1 chore: increment version 2021-04-12 22:58:52 +02:00
7ab9c45f4f fix: table drop in migration 2021-04-12 22:58:40 +02:00
915436822b fix: make mail provider configs non-nullable 2021-04-12 22:57:52 +02:00
0f1d1bce4d fix: summary missing interval calculation (fix #168) 2021-04-12 22:57:15 +02:00
6256c8e10a ref: embed files, bump to go 1.16 (#167)
* ref: embed portion of files
* fix: readd pkger
* ref: embed version.txt
* fix: wrong mail template import path
* refactor: get rid of sql-migrate
refactor: get rid of pkger in favor of go embed (resolve #164)
* chore: remove unused var [ci-skip]

Co-authored-by: Ferdinand Mütsch <ferdinand@muetsch.io>
2021-04-11 10:42:43 +00:00
2a9fbfdfd7 chore: send notification on successful import 2021-04-10 10:48:06 +02:00
56247b4e1e fix: throttle wakatime api requests (attempt to fix #152) 2021-04-10 10:18:09 +02:00
9d7afde6a9 chore: version 2021-04-10 00:34:37 +02:00
0df0168584 Merge branch '133-password-resets' 2021-04-10 00:34:20 +02:00
a6fe15d69b chore: add support button 2021-04-10 00:27:01 +02:00
ae363c1c82 Merge remote-tracking branch 'origin/master' 2021-04-10 00:17:13 +02:00
127a614190 Merge pull request #163 from notarock/master
Add Open Graph meta tags
2021-04-10 00:16:07 +02:00
b8cefeb595 chore: add html lang 2021-04-10 00:15:20 +02:00
ae97095688 chore: exclude health endpoint from sentry tracing 2021-04-10 00:10:16 +02:00
4706809170 feat: smtp mail provider implementation 2021-04-10 00:07:13 +02:00
ddc29f0414 chore: log mailwhale error 2021-04-09 22:51:03 +02:00
f4af787ecf Add Open Graph meta tags 2021-04-09 00:37:14 -04:00
da6a00fec5 fix: adapt tests 2021-04-05 23:00:21 +02:00
6ad33e3c3b feat: password resets (resolve #133) 2021-04-05 22:57:57 +02:00
e6e134678a wip: password resets 2021-04-05 16:25:13 +02:00
1783858854 fix: minor fixes (resolve #151) (resolve #154) 2021-04-04 10:42:27 +02:00
e1d040bd55 docs: mention wiki in docs 2021-04-04 10:23:35 +02:00
7f3a654b26 fix: import bug with small number of heartbeats (fix #160) 2021-04-04 09:45:32 +02:00
2b57da224c chore: write authenticated user to logs and sentry 2021-03-26 13:10:10 +01:00
01d51b78b1 chore: include postman collection (resolve #148) [ci-skip] 2021-03-25 15:50:47 +01:00
6b83600acc fix: responsiveness in firefox mobile (resolve #137) 2021-03-25 10:51:36 +01:00
65bbd744b5 chore: exclude static assets from sonarqube [ci skip] 2021-03-24 23:38:38 +01:00
81ca703501 fix: disable recompute caching (resolve #149) 2021-03-24 23:31:04 +01:00
2d1010e9d9 chore: add caching to badge endpoint
chore: add type index for summary items
2021-03-24 21:49:42 +01:00
5ca9a6a8be test: add further tests for heartbeat augmentation (resolve #144) 2021-03-24 20:43:49 +01:00
caf87de887 fix: allow custom mappings with dot (resolve #144) 2021-03-24 19:25:36 +01:00
9fc3c65efe feat: sentry integration (resolve #142) 2021-03-23 22:12:15 +01:00
f73285160d fix: delete language mapping (resolve #143) 2021-03-22 21:20:23 +01:00
1f557d562f chore: remove integrity hashes from assets files since served locally anyway (resolve #141) 2021-03-20 09:55:21 +01:00
3685f3a156 chore: add svelte language mapping 2021-03-13 21:01:24 +01:00
b3afe9bfa2 feat: gui for sample data generator script 2021-03-11 23:54:35 +01:00
9de2c20885 fix: enable strict slash mode for api router 2021-03-09 23:56:38 +01:00
2846748b26 hotfix: remove user agent check for shields.io 2021-03-09 23:15:35 +01:00
f2f6fe1483 Merge pull request #140 from notarock/master
Prevent invalid dates ranges in summary page
2021-03-06 09:05:11 +01:00
17ddd7ca76 Prevent invalid dates ranges in summary page
It is currently possible to enter a "End" date that is before the start
date, or a "start" date that is after the end date. This commit prevent the
user from directly entering an invalid date by setting a "min" and "max"
value on those two date picker.

There are no validation or the server's side, but that shouldn't be a
problem since the invalid date will not create an error, it will simply
not return any data.
2021-03-06 02:38:28 -05:00
292ae41c58 fix: batch insert conflict bug (resolve #139) 2021-03-05 21:39:21 +01:00
4f86f67716 chore: fix success messages 2021-02-21 13:12:29 +01:00
017530ac4a chore: introduce user email addresses (resolve #132) 2021-02-21 13:02:11 +01:00
81d3251856 fix: use compatible tailwind version 2021-02-21 13:02:01 +01:00
16af17fc37 chore: save heartbeats created date 2021-02-21 12:02:19 +01:00
701ed0a3e1 chore: purge tailwind classes (resolve #22) 2021-02-21 11:14:55 +01:00
218c93e975 Merge pull request #136 from notarock/master
Fixed scale label to show seconds instead of minutes
2021-02-21 10:38:52 +01:00
44de057022 Fixed scale label to show seconds instead of minutes 2021-02-21 04:27:45 -05:00
e55adf6287 fix: mysql character encoding (resolve #131) 2021-02-17 21:04:22 +01:00
56800be8e8 chore: change info message in migration (resolve #128) 2021-02-16 17:18:56 +01:00
c149766ecc fix: drop_badges_column migration for sqlite 2021-02-16 22:07:41 +11:00
759e8e4dfd chore: change logging middleware to use different output 2021-02-14 16:41:02 +01:00
708863fd33 fix: broken migration on postgres (resolve #127) 2021-02-14 16:02:05 +01:00
e2f046a83d fix: add migration for newly introduced has data field 2021-02-13 13:04:47 +01:00
30510591eb feat: custom time intervals (resolve #115) 2021-02-13 12:59:59 +01:00
daf67b844a refctor: change active users query 2021-02-13 11:23:58 +01:00
6b0b3bddda fix: include overall total number of heartbeats again 2021-02-12 23:16:20 +01:00
ef17d06763 fix: tests [ci skip] 2021-02-12 23:10:25 +01:00
301cab4be4 feat: per-user heartbeats count metrics 2021-02-12 23:06:48 +01:00
703805412b chore: code smell [ci skip] 2021-02-12 19:26:23 +01:00
88eb68b1a9 feat: add prometheus metrics without external standalone exporter 2021-02-12 18:50:13 +01:00
8191a52ce1 chore: make very first user have admin privileges 2021-02-12 18:49:47 +01:00
5b3e88247e chore: introduce user admin flag 2021-02-12 18:13:49 +01:00
59b85863cc chore: accept bearer prefix in auth header 2021-02-12 18:12:46 +01:00
22fbfceca2 fix: support default range for stats endpoint (resolve #125) 2021-02-12 11:25:21 +01:00
4d7fc6bff9 fix: commit missing asset 2021-02-12 10:28:31 +01:00
218c571859 feat: display setup instructions on startup (resolve #120) 2021-02-12 10:10:44 +01:00
e4c413a33c fix: include machine names when importing wakatime data 2021-02-10 22:08:00 +01:00
66b01c2797 docs: update readme toc 2021-02-07 17:30:14 +01:00
0cee7496e0 fix(ui): convert logo text to path (resolve #121) 2021-02-07 17:29:26 +01:00
e571e5266d fix: dockerfile syntax error 2021-02-07 12:58:24 +01:00
149 changed files with 114497 additions and 1736 deletions

View File

@ -17,7 +17,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
@ -25,9 +25,7 @@ jobs:
- name: Get dependencies
run: |
go get github.com/markbates/pkger/cmd/pkger
go get
go generate
- name: Build
run: GO111MODULE=on go build -v .

View File

@ -17,7 +17,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
@ -25,9 +25,7 @@ jobs:
- name: Get dependencies
run: |
go get github.com/markbates/pkger/cmd/pkger
go get
go generate
- name: Enable Go 1.11 modules
run: cmd /c "set GO111MODULE=on"

4
.gitignore vendored
View File

@ -8,3 +8,7 @@ build
config*.yml
!config.default.yml
pkged.go
package.json
yarn.lock
package-lock.json
node_modules

View File

@ -1,16 +1,16 @@
# Build Stage
FROM golang:1.15 AS build-env
FROM golang:1.16 AS build-env
WORKDIR /src
ADD ./go.mod .
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
RUN go mod download
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
chmod +x wait-for-it.sh
ADD . .
RUN go generate && go build -o wakapi
RUN go build -o wakapi
WORKDIR /app
RUN cp /src/wakapi . && \
@ -31,6 +31,7 @@ WORKDIR /app
RUN apt update && \
apt install -y ca-certificates
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
@ -40,7 +41,7 @@ ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
ENV WAKAPI_ALLOW_SIGNUP 'true
ENV WAKAPI_ALLOW_SIGNUP 'true'
COPY --from=build-env /app .

150
README.md
View File

@ -44,12 +44,13 @@
* [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations)
* [WakaTime Integration](#%EF%B8%8F-wakatime-integration)
* [Best Practices](#-best-practices)
* [Developer Notes](#-developer-notes)
* [Support](#-support)
* [FAQs](#-faqs)
Further instructions can be found in the [Wiki](https://github.com/muety/wakapi/wiki).
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
@ -62,6 +63,7 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Partially compatible with WakaTime
* ✅ WakaTime integration
* ✅ Support for Prometheus exports
* ✅ Lightning fast
* ✅ Self-hosted
## 🚧 Roadmap
@ -75,7 +77,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 ❕
### 🐳 Option 2: Use Docker
### 📦 Option 2: Quick-run a Release
```bash
$ curl -L https://wakapi.dev/get | bash
```
### 🐳 Option 3: Use Docker
```bash
# Create a persistent volume
$ docker volume create wakapi-data
@ -90,22 +97,11 @@ $ docker run -d \
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
### 📦 Option 3: Run a release
```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
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.
# Optionally adapt config to your needs
$ vi config.yml
# Run it
$ ./wakapi
```
### 🧑‍💻 Option 4: Run from source
### 🧑‍💻 Option 4: Compile and run from source
#### Prerequisites
* Go >= 1.13 (with `$GOPATH` properly set)
* Go >= 1.16 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
* Fedora / RHEL: `dnf install @development-tools`
* Ubuntu / Debian: `apt install build-essential`
@ -113,23 +109,18 @@ $ ./wakapi
#### Compile & Run
```bash
# Build the executable
$ go build -o wakapi
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
# Install packaging tool
$ export GO111MODULE=on
$ go get github.com/markbates/pkger/cmd/pkger
# Build the executable
$ go generate
$ go build -o wakapi
# Run it
$ ./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
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.
@ -140,14 +131,14 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
```
Optionally, you can set up a [client-side proxy](docs/advanced_setup.md) in addition.
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
## 🔧 Configuration Options
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
@ -164,15 +155,28 @@ You can specify configuration options either via a config file (default: `config
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `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.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.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.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 |
| `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.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
@ -196,13 +200,37 @@ $ swag init -o static/docs
## 🤝 Integrations
### Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff)](https://github.com/MacroPower/wakatime_exporter)
```bash
# 1. Start Wakapi with the feature enabled
$ export WAKAPI_EXPOSE_METRICS=true
$ ./wakapi
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
# 2. Get your API key and hash it
$ echo "<YOUR_API_KEY>" | base64
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
# 3. Add a Prometheus scrape config to your prometheus.yml (see below)
```
#### Scrape config example
```yml
# prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000)
scrape_configs:
- job_name: 'wakapi'
scrape_interval: 1m
metrics_path: '/api/metrics'
bearer_token: '<YOUR_BASE64_HASHED_TOKEN>'
static_configs:
- targets: ['localhost:3000']
```
#### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image)
### WakaTime Integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
@ -212,6 +240,44 @@ 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)
<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
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`
@ -222,6 +288,22 @@ However, if you want to expose your wakapi instance to the public anyway, you ne
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
### 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
$ 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
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,32 +1,63 @@
env: development
env: production
server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
port: 3000
base_path: /
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
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
custom_languages:
vue: Vue
jsx: JSX
svelte: Svelte
db:
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
user: # 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)
dialect: sqlite3 # mysql, postgres, sqlite3
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)
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
user: # 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)
dialect: sqlite3 # mysql, postgres, sqlite3
charset: utf8mb4 # only used for mysql connections
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)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security:
password_salt: # CHANGE !
insecure_cookies: false
password_salt: # change this
insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
cookie_max_age: 172800
allow_signup: true
allow_signup: true
expose_metrics: false
sentry:
dsn: # leave blank to disable sentry integration
enable_tracing: true # whether to use performance monitoring
sample_rate: 0.75 # probability of tracing a request
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
sender: Wakapi <noreply@wakapi.dev> # ignored for mailwhale
# smtp settings when sending mails via smtp
smtp:
host:
port:
username:
password:
tls:
# mailwhale.dev settings when using mailwhale as sending service
mailwhale:
url:
client_id:
client_secret:

View File

@ -4,20 +4,18 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/markbates/pkger"
"github.com/muety/wakapi/models"
migrate "github.com/rubenv/sql-migrate"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"io/ioutil"
"net/http"
"os"
"strings"
"time"
"github.com/emvi/logbuch"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/data"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
const (
@ -30,6 +28,12 @@ const (
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
ErrUnauthorized = "401 unauthorized"
ErrInternalServerError = "500 internal server error"
)
const (
@ -39,22 +43,36 @@ const (
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
)
const (
MailProviderSmtp = "smtp"
MailProviderMailWhale = "mailwhale"
)
var emailProviders = []string{
MailProviderSmtp,
MailProviderMailWhale,
}
var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_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"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
@ -63,15 +81,17 @@ type securityConfig struct {
}
type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"`
Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `yaml:"-"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
Host string `env:"WAKAPI_DB_HOST"`
Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
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 {
@ -79,10 +99,40 @@ type serverConfig struct {
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type sentryConfig struct {
Dsn string `env:"WAKAPI_SENTRY_DSN"`
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
SampleRate float32 `yaml:"sample_rate" default:"0.75" env:"WAKAPI_SENTRY_SAMPLE_RATE"`
SampleRateHeartbeats float32 `yaml:"sample_rate_heartbeats" default:"0.1" env:"WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS"`
}
type mailConfig struct {
Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"`
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
Smtp SMTPMailConfig `yaml:"smtp"`
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
}
type MailwhaleMailConfig struct {
Url string `env:"WAKAPI_MAIL_MAILWHALE_URL"`
ClientId string `yaml:"client_id" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_ID"`
ClientSecret string `yaml:"client_secret" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET"`
}
type SMTPMailConfig struct {
Host string `env:"WAKAPI_MAIL_SMTP_HOST"`
Port uint `env:"WAKAPI_MAIL_SMTP_PORT"`
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
}
type Config struct {
Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"`
@ -90,6 +140,8 @@ type Config struct {
Security securityConfig
Db dbConfig
Server serverConfig
Sentry sentryConfig
Mail mailConfig
}
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
@ -124,85 +176,32 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.Alias{})
db.AutoMigrate(&models.Heartbeat{})
db.AutoMigrate(&models.Summary{})
db.AutoMigrate(&models.SummaryItem{})
db.AutoMigrate(&models.LanguageMapping{})
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
}
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.HttpFileSystemMigrationSource{
FileSystem: pkger.Dir("/migrations"),
}
migrate.SetIgnoreUnknown(true)
sqlDb, _ := db.DB()
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
if err != nil {
return err
}
logbuch.Info("applied %d fixtures", n)
return nil
}
}
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=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
"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 {
return cloneStringMap(c.CustomLanguages, false)
}
@ -219,23 +218,25 @@ func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true)
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
s := strings.Split(c.ReportTimeWeekly, ",")[0]
return parseWeekday(s)
}
func readVersion() string {
file, err := pkger.Open("/version.txt")
if err != nil {
logbuch.Fatal(err.Error())
}
defer file.Close()
func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
}
bytes, err := ioutil.ReadAll(file)
if err != nil {
logbuch.Fatal(err.Error())
}
func (c *serverConfig) GetPublicUrl() string {
return strings.TrimSuffix(c.PublicUrl, "/")
}
return strings.TrimSpace(string(bytes))
func (c *SMTPMailConfig) ConnStr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func readColors() map[string]map[string]string {
@ -247,19 +248,14 @@ func readColors() map[string]map[string]string {
// 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"]/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)
file, err := pkger.Open("/data/colors.json")
if err != nil {
logbuch.Fatal(err.Error())
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := json.Unmarshal(bytes, &colors); err != nil {
if err := json.Unmarshal(raw, &colors); err != nil {
logbuch.Fatal(err.Error())
}
@ -280,6 +276,35 @@ func resolveDbDialect(dbType string) string {
return dbType
}
func findString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
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) {
cfg = config
}
@ -288,7 +313,7 @@ func Get() *Config {
return cfg
}
func Load() *Config {
func Load(version string) *Config {
config := &Config{}
flag.Parse()
@ -297,7 +322,8 @@ func Load() *Config {
logbuch.Fatal("failed to read config: %v", err)
}
config.Version = readVersion()
env = config.Env
config.Version = strings.TrimSpace(version)
config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
config.Security.SecureCookie = securecookie.New(
@ -315,13 +341,27 @@ func Load() *Config {
}
}
if config.Sentry.Dsn != "" {
logbuch.Info("enabling sentry integration")
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, "") == "" {
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)
return Get()

View File

@ -22,11 +22,12 @@ func Test_mysqlConnectionString(t *testing.T) {
Password: "test_password",
Name: "test_name",
Dialect: "mysql",
Charset: "utf8mb4",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Host,

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
}

155
config/sentry.go Normal file
View File

@ -0,0 +1,155 @@
package config
import (
"github.com/emvi/logbuch"
"github.com/getsentry/sentry-go"
"github.com/muety/wakapi/models"
"io"
"net/http"
"os"
"strings"
)
// 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
type capturingWriter struct {
Writer io.Writer
Message string
}
func (c *capturingWriter) Clear() {
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) {
if err := sentry.Init(sentry.ClientOptions{
Dsn: config.Dsn,
Debug: debug,
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
if !config.EnableTracing {
return sentry.SampledFalse
}
hub := sentry.GetHubFromContext(ctx.Span.Context())
txName := hub.Scope().Transaction()
for _, ex := range excludedRoutes {
if strings.HasPrefix(txName, ex) {
return sentry.SampledFalse
}
}
if txName == "POST /api/heartbeat" {
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
}
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
}),
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
if hint.Context != nil {
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
if u := getPrincipal(req); u != nil {
event.User.ID = u.ID
}
}
}
return event
},
}); err != nil {
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
}

View File

@ -1,10 +1,12 @@
package config
const (
IndexTemplate = "index.tpl.html"
LoginTemplate = "login.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html"
IndexTemplate = "index.tpl.html"
LoginTemplate = "login.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SetPasswordTemplate = "set-password.tpl.html"
ResetPasswordTemplate = "reset-password.tpl.html"
SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html"
)

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,7 @@
"DataWeave": "#003a52",
"DM": "#447265",
"Dockerfile": "#384d54",
"Docker": "#384d54",
"Dogescript": "#cca760",
"Dylan": "#6c616e",
"E": "#ccce35",
@ -209,6 +210,7 @@
"Stan": "#b2011d",
"Standard ML": "#dc566d",
"SuperCollider": "#46390b",
"Svelte": "#ff3e00",
"Swift": "#ffac45",
"SystemVerilog": "#DAE1C2",
"Tcl": "#e4cc98",

6
data/data.go Normal file
View File

@ -0,0 +1,6 @@
package data
import _ "embed"
//go:embed colors.json
var ColorsFile []byte

View File

@ -7,10 +7,11 @@ services:
- 3000:3000
restart: always
environment:
# See README.md and config.default.yml for all config options
WAKAPI_DB_TYPE: "postgres"
WAKAPI_DB_NAME: "wakapi"
WAKAPI_DB_USER: "wakapi"
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
WAKAPI_DB_PASSWORD: "choose-a-password"
WAKAPI_DB_HOST: "db"
WAKAPI_DB_PORT: "5432"
ENVIRONMENT: "prod"
@ -19,5 +20,5 @@ services:
image: postgres:12.3
environment:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "CHANGE_ME!!!"
POSTGRES_PASSWORD: "choose-a-password"
POSTGRES_DB: "wakapi"

View File

@ -1,47 +0,0 @@
# Advanced Setup
This page contains instructions for additional setup options, none of which are mandatory.
## Optional: Client-side proxy
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API Wakapi in this case.
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
### Option 1: [tinyproxy](https://tinyproxy.github.io) forward proxy (`Linux`, `Mac` only)
In this example we use _tinyproxy_ as a small, easy-to-install proxy server, written in C, that runs on your local machine.
1. Install [tinyproxy](https://tinyproxy.github.io)
* Fedora / RHEL: `dnf install tinyproxy`
* Debian / Ubuntu: `apt install tinyproxy`
* MacOS: Install from [MacPorts](https://ports.macports.org/port/tinyproxy/summary)
1. Enable and start it
* Linux: `sudo systemctl start tinyproxy && sudo systemctl enable tinyproxy`
* Mac: Not sure, sorry ¯\_(ツ)_/¯
1. Update `~/.wakatime.cfg`
* Set `proxy = http://localhost:8888`
1. Done
* All Wakapi requests are passed through tinyproxy now, which keeps a TCP connection with the server open for some time
### Option 2: [Caddy](https://caddyserver.com) reverse proxy (`Win`, `Linux`, `Mac`)
In this example, we misuse Caddy, which is a web server and reverse proxy, to fulfil the above scenario.
1. [Install Caddy](https://caddyserver.com/)
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
1. Create a Caddyfile
```
# /etc/caddy/Caddyfile
http://localhost:8070 {
reverse_proxy * {
to https://wakapi.dev # <-- substitute your own Wakapi host here
header_up Host {http.reverse_proxy.upstream.host}
header_down -Server
}
}
```
1. Restart Caddy
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
1. Update `~/.wakatime.cfg`
* Set `api_url = http://localhost:8070/api/heartbeat`
1. Done
* All Wakapi requests are passed through Caddy now, which keeps a TCP connection with the server open for some time

40
go.mod
View File

@ -1,33 +1,39 @@
module github.com/muety/wakapi
go 1.13
go 1.16
require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emvi/logbuch v1.1.1
github.com/go-co-op/gocron v0.3.3
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0
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/go-co-op/gocron v1.5.0
github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
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/markbates/pkger v0.17.1
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mitchellh/hashstructure/v2 v2.0.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
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
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
go.uber.org/atomic v1.7.0
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
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
gorm.io/driver/mysql v1.0.3
gorm.io/driver/postgres v1.0.5
gorm.io/driver/sqlite v1.1.3
gorm.io/gorm v1.20.11
gorm.io/driver/mysql v1.0.6
gorm.io/driver/postgres v1.0.8
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.21.9
)

273
go.sum
View File

@ -1,18 +1,26 @@
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/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
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/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
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/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/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/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
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/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/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@ -28,6 +36,7 @@ github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6l
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/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=
@ -42,6 +51,7 @@ 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/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/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-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@ -49,83 +59,86 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
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/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
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/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/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/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
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/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
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/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/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/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
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/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
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/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v1.5.0 h1:tIiwAPwKGcazVFJTNmGe0wE73UpZSEHovoahqGGx9+c=
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/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-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w=
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.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
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/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.4.1/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.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
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/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/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-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -134,28 +147,32 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
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/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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/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 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
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/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 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
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/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
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/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/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=
@ -182,8 +199,14 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
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/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/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/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/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@ -195,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.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.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
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/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
@ -210,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.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.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/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-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@ -221,43 +246,52 @@ 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.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.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
github.com/jackc/pgtype v1.6.2/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-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.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.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
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-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.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
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/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.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/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
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/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.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
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/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/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/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
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/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/klauspost/compress v1.8.2/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/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 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
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=
@ -266,6 +300,10 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/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/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.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -276,13 +314,10 @@ github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0U
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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8=
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.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -294,14 +329,14 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
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/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
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/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=
@ -317,6 +352,7 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
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 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
@ -330,13 +366,11 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
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/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
github.com/onsi/ginkgo v1.6.0/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.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
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=
@ -355,6 +389,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9
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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -379,38 +415,31 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
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.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
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/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/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
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/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/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
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.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
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=
@ -433,23 +462,35 @@ 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.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.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
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/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.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 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 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
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/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
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/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/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/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
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/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
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=
@ -458,8 +499,9 @@ 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.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
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.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
@ -471,23 +513,23 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/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-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-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 h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@ -504,18 +546,20 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r
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-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-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-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-20200226121028-0de0cce0169b/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
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-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=
@ -523,10 +567,10 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
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-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=
@ -542,50 +586,46 @@ golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/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-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-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
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.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-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-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-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-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-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
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/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
@ -593,7 +633,6 @@ golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -601,7 +640,6 @@ google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMt
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/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -625,9 +663,11 @@ gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qS
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/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
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/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/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/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
@ -635,26 +675,25 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
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.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
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-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
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=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.6 h1:mA0XRPjIKi4bkE9nv+NKs6qj6QWOchqUSdWOcpd3x1E=
gorm.io/driver/mysql v1.0.6/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
gorm.io/driver/postgres v1.0.8 h1:PAgM+PaHOSAeroTjHkCHCBIHHoBIf9RgPWGo8dF2DA8=
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.7/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=

83
main.go
View File

@ -1,23 +1,24 @@
package main
//go:generate $GOPATH/bin/pkger
import (
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
"github.com/markbates/pkger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/utils"
"gorm.io/gorm/logger"
"embed"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/services/mail"
"github.com/muety/wakapi/utils"
"gorm.io/gorm/logger"
"github.com/gorilla/mux"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/routes"
@ -30,6 +31,14 @@ import (
"gorm.io/gorm"
)
// Embed version.txt
//go:embed version.txt
var version string
// Embed static files
//go:embed static
var staticFiles embed.FS
var (
db *gorm.DB
config *conf.Config
@ -51,7 +60,9 @@ var (
languageMappingService services.ILanguageMappingService
summaryService services.ISummaryService
aggregationService services.IAggregationService
mailService services.IMailService
keyValueService services.IKeyValueService
reportService services.IReportService
miscService services.IMiscService
)
@ -78,7 +89,7 @@ var (
// @BasePath /api
func main() {
config = conf.Load()
config = conf.Load(version)
// Set log level
if config.IsDev() {
@ -107,7 +118,7 @@ func main() {
if config.IsDev() {
db = db.Debug()
}
sqlDb, _ := db.DB()
sqlDb, err := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
@ -117,9 +128,7 @@ func main() {
defer sqlDb.Close()
// Migrate database schema
migrations.RunPreMigrations(db, config)
runDatabaseMigrations()
migrations.RunCustomPostMigrations(db, config)
migrations.Run(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
@ -136,12 +145,15 @@ func main() {
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
go reportService.Schedule()
routes.Init()
@ -149,34 +161,36 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// Setup Routers
router := mux.NewRouter()
rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
// Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware(
log.New(os.Stdout, "", log.LstdFlags),
[]string{"/assets"},
)
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
router.Use(middlewares.NewPrincipalMiddleware())
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
router.Use(handlers.RecoveryHandler())
if config.Sentry.Dsn != "" {
router.Use(middlewares.NewSentryMiddleware())
}
rootRouter.Use(middlewares.NewSecurityMiddleware())
// Route registrations
homeHandler.RegisterRoutes(rootRouter)
@ -189,13 +203,20 @@ func main() {
summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: pkger.Dir("/static")})
// https://github.com/golang/go/issues/43431
embeddedStatic, _ := fs.Sub(staticFiles, "static")
static := conf.ChooseFS("static", embeddedStatic)
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
router.PathPrefix("/contribute.json").Handler(fileServer)
router.PathPrefix("/assets").Handler(fileServer)
router.PathPrefix("/swagger-ui").Handler(fileServer)
router.PathPrefix("/docs").Handler(
@ -269,9 +290,3 @@ func listen(handler http.Handler) {
<-make(chan interface{}, 1)
}
func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
logbuch.Fatal(err.Error())
}
}

View File

@ -1,8 +1,6 @@
package middlewares
import (
"context"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
@ -15,6 +13,7 @@ type AuthenticateMiddleware struct {
config *conf.Config
userSrvc services.IUserService
optionalForPaths []string
redirectTarget string // optional
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
@ -30,6 +29,11 @@ func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMi
return m
}
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
m.redirectTarget = path
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
@ -50,17 +54,18 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
if strings.HasPrefix(r.URL.Path, "/api") {
if m.redirectTarget == "" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
} else {
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
}
return
}
ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx))
SetPrincipal(r, user)
next(w, r)
}
func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {

View File

@ -6,7 +6,7 @@ import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/middlewares"
"io"
"io/ioutil"
"net/http"
@ -39,7 +39,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
return
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
if user == nil || user.WakatimeApiKey == "" {
return
}

View File

@ -4,23 +4,24 @@ package middlewares
import (
"io"
"log"
"net/http"
"strings"
"time"
)
type logFunc func(string, ...interface{})
type LoggingMiddleware struct {
handler http.Handler
output *log.Logger
logFunc logFunc
excludePrefixes []string
}
func NewLoggingMiddleware(output *log.Logger, excludePrefixes []string) func(http.Handler) http.Handler {
func NewLoggingMiddleware(logFunc logFunc, excludePrefixes []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &LoggingMiddleware{
handler: h,
output: output,
logFunc: logFunc,
excludePrefixes: excludePrefixes,
}
}
@ -41,15 +42,15 @@ func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
lg.output.Printf(
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n",
time.Now().Format(time.RFC3339Nano),
lg.logFunc(
"[request] status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s, user=%s",
ww.Status(),
r.Method,
r.URL.String(),
duration,
ww.BytesWritten(),
readUserIP(r),
readUserID(r),
)
}
@ -64,6 +65,13 @@ func readUserIP(r *http.Request) string {
return ip
}
func readUserID(r *http.Request) string {
if user := GetPrincipal(r); user != nil {
return user.ID
}
return "-"
}
// The below writer-wrapping code has been lifted from
// https://github.com/zenazn/goji/blob/master/web/middleware/logger.go - because
// it does exactly what is needed, and it's unlikely to change in any

64
middlewares/principal.go Normal file
View File

@ -0,0 +1,64 @@
package middlewares
import (
"context"
"github.com/muety/wakapi/models"
"net/http"
)
const keyPrincipal = "principal"
type PrincipalContainer struct {
principal *models.User
}
func (c *PrincipalContainer) SetPrincipal(user *models.User) {
c.principal = user
}
func (c *PrincipalContainer) GetPrincipal() *models.User {
return c.principal
}
// This middleware is a bit of a dirty workaround to the fact that a http.Request's context
// does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
// handler 1 will not have access to values handler 2 writes to its context. In addition, Context.WithValue
// returns a new context with the old context as a parent.
//
// As a concrete example, SentryMiddleware as well as LoggingMiddleware should be quite the outer layers,
// while AuthenticationMiddleware is on the very inside of the chain. However, we still want sentry or the
// logger to have access to the user object populated by the auth. middleware, if present.
//
// This middleware shall be included as the outermost layers and it injects a stateful container that does
// nothing but conditionally hold a reference to an authenticated user object.
//
// Other reference: https://stackoverflow.com/questions/55972869/send-errors-to-sentry-with-golang-and-mux
type PrincipalMiddleware struct {
handler http.Handler
}
func NewPrincipalMiddleware() func(handler http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &PrincipalMiddleware{handler: h}
}
}
func (p *PrincipalMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), keyPrincipal, &PrincipalContainer{})
p.handler.ServeHTTP(w, r.WithContext(ctx))
}
func SetPrincipal(r *http.Request, user *models.User) {
if p := r.Context().Value(keyPrincipal); p != nil {
p.(*PrincipalContainer).SetPrincipal(user)
}
}
func GetPrincipal(r *http.Request) *models.User {
if p := r.Context().Value(keyPrincipal); p != nil {
return p.(*PrincipalContainer).GetPrincipal()
}
return nil
}

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

31
middlewares/sentry.go Normal file
View File

@ -0,0 +1,31 @@
package middlewares
import (
"context"
"github.com/getsentry/sentry-go"
sentryhttp "github.com/getsentry/sentry-go/http"
"net/http"
)
// SentryMiddleware is a wrapper around sentryhttp to include user information to traces
type SentryMiddleware struct {
handler http.Handler
}
func NewSentryMiddleware() func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return sentryhttp.New(sentryhttp.Options{
Repanic: true,
}).Handle(&SentryMiddleware{handler: h})
}
}
func (h *SentryMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "-", "-")
h.handler.ServeHTTP(w, r.WithContext(ctx))
if hub := sentry.GetHubFromContext(ctx); hub != nil {
if user := GetPrincipal(r); user != nil {
hub.Scope().SetUser(sentry.User{ID: user.ID})
}
}
}

View File

@ -1,17 +0,0 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "000-apply_fixtures",
f: func(db *gorm.DB, cfg *config.Config) error {
return cfg.GetFixturesFunc(cfg.Db.Dialect)(db)
},
}
registerPostMigration(f)
}

View File

@ -14,7 +14,7 @@ func init() {
migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database, nothing to migrate
// empty database or already migrated, nothing to migrate
return nil
}
@ -37,11 +37,15 @@ func init() {
return err
}
if cfg.Db.Dialect == config.SQLDialectSqlite {
logbuch.Info("not attempting to drop column 'badges_enabled' on sqlite")
return nil
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
} else {
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
}
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
return nil
},

View File

@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20210213-add_has_data_field"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
if err := db.Exec("UPDATE users SET has_data = TRUE WHERE TRUE").Error; err != nil {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20210221-add_created_date_column"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
if err := db.Exec("UPDATE heartbeats SET created_at = time WHERE TRUE").Error; err != nil {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,47 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func init() {
const name = "20210411-add_imprint_content"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
if err := db.
Clauses(clause.OnConflict{UpdateAll: false, DoNothing: true}).
Where(condition, imprintKv.Key).
Assign(imprintKv).
Create(imprintKv).Error; err != nil {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,22 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20210411-drop_migrations_table"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if err := db.Migrator().DropTable("gorp_migrations"); err != nil {
logbuch.Info("dropped table 'gorp_migrations'")
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -1,8 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
SET SQL_MODE=ANSI_QUOTES;
delete from key_string_values where key = 'imprint';

View File

@ -28,9 +28,17 @@ func registerPostMigration(f migrationFunc) {
postMigrations = append(postMigrations, f)
}
// NOTE: Currently, migrations themselves keep track
// of whether they have run, yet or not, because some
// simply run on every start.
func Run(db *gorm.DB, cfg *config.Config) {
RunPreMigrations(db, cfg)
RunSchemaMigrations(db, cfg)
RunPostMigrations(db, cfg)
}
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
if err := cfg.GetMigrationFunc(cfg.Db.Dialect)(db); err != nil {
logbuch.Fatal(err.Error())
}
}
func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(preMigrations)
@ -43,7 +51,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
}
}
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(postMigrations)
for _, m := range postMigrations {

View File

@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
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) {
args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1)

View File

@ -20,11 +20,21 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
return args.Error(0)
}
func (m *HeartbeatServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
args := m.Called(user)
return args.Get(0).(int64), args.Error(0)
}
func (m *HeartbeatServiceMock) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
args := m.Called(users)
return args.Get(0).([]*models.CountByUser), args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
@ -35,11 +45,21 @@ func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
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) {
args := m.Called(s, user)
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 {
args := m.Called(time)
return args.Error(0)

View File

@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
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) {
args := m.Called(user, time, time2)
return args.Get(0).([]*models.Summary), args.Error(1)

View File

@ -19,13 +19,38 @@ func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByEmail(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByResetToken(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
args := m.Called(signup)
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) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
args := m.Called(signup, isAdmin)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
@ -59,6 +84,11 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

@ -13,9 +13,18 @@ type AllTimeViewModel struct {
}
type AllTimeData struct {
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
Range *AllTimeRange `json:"range"`
}
type AllTimeRange struct {
End string `json:"end"`
EndDate string `json:"end_date"`
Start string `json:"start"`
StartDate string `json:"start_date"`
Timezone string `json:"timezone"`
}
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {

View File

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

View File

@ -0,0 +1,12 @@
package v1
// https://wakatime.com/api/v1/users/current/machine_names
type MachineViewModel struct {
Data []*MachineEntry `json:"data"`
}
type MachineEntry struct {
Id string `json:"id"`
Value string `json:"value"`
}

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 (
"github.com/muety/wakapi/models"
"math"
"time"
)
@ -41,6 +42,10 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
DaysIncludingHolidays: numDays,
}
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
data.DailyAverage = 0
}
editors := make([]*SummariesEntry, len(summary.Editors))
for i, e := range summary.Editors {
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 {
// TODO: implement filtering (https://github.com/muety/wakapi/issues/58)
data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
@ -129,7 +130,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
defer wg.Done()
for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
}
}(data)
@ -152,9 +152,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
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
// TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second
total := e.TotalFixed()
hrs := int(total.Hours())
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
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

@ -4,29 +4,29 @@ import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"regexp"
"strings"
"time"
)
type Heartbeat struct {
ID uint `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project"`
Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`
OriginId string `json:"-" hash:"ignore"`
languageRegex *regexp.Regexp `hash:"ignore"`
ID uint `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project"`
Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`
OriginId string `json:"-" hash:"ignore"`
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
}
func (h *Heartbeat) Valid() bool {
@ -34,18 +34,13 @@ func (h *Heartbeat) Valid() bool {
}
func (h *Heartbeat) Augment(languageMappings map[string]string) {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
for ending, value := range languageMappings {
if ok, prec := strings.HasSuffix(h.Entity, "."+ending), strings.Count(ending, "."); ok && prec > maxPrec {
h.Language = value
maxPrec = prec
}
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := languageMappings[ending]; !ok {
return
}
h.Language, _ = languageMappings[ending]
}
func (h *Heartbeat) GetKey(t uint8) (key string) {

View File

@ -26,17 +26,30 @@ func TestHeartbeat_Valid_MissingUser(t *testing.T) {
func TestHeartbeat_Augment(t *testing.T) {
testMappings := map[string]string{
"py": "Python3",
"py": "Python3",
"foo": "Foo Script",
"php": "PHP 8",
"blade.php": "Blade",
}
sut := &Heartbeat{
sut1, sut2, sut3 := &Heartbeat{
Entity: "~/dev/file.py",
Language: "Python",
}, &Heartbeat{
Entity: "~/dev/file.blade.php",
Language: "unknown",
}, &Heartbeat{
Entity: "~/dev/file.php",
Language: "PHP",
}
sut.Augment(testMappings)
sut1.Augment(testMappings)
sut2.Augment(testMappings)
sut3.Augment(testMappings)
assert.Equal(t, "Python3", sut.Language)
assert.Equal(t, "Python3", sut1.Language)
assert.Equal(t, "Blade", sut2.Language)
assert.Equal(t, "PHP 8", sut3.Language)
}
func TestHeartbeat_GetKey(t *testing.T) {

48
models/mail.go Normal file
View File

@ -0,0 +1,48 @@
package models
import (
"fmt"
"strings"
)
const HtmlType = "text/html; charset=UTF-8"
const PlainType = "text/html; charset=UTF-8"
type Mail struct {
From MailAddress
To MailAddresses
Subject string
Body string
Type string
}
func (m *Mail) WithText(text string) *Mail {
m.Body = text
m.Type = PlainType
return m
}
func (m *Mail) WithHTML(html string) *Mail {
m.Body = html
m.Type = HtmlType
return m
}
func (m *Mail) String() string {
return fmt.Sprintf("To: %s\r\n"+
"From: %s\r\n"+
"Subject: %s\r\n"+
"Content-Type: %s\r\n"+
"\r\n"+
"%s\r\n",
strings.Join(m.To.RawStrings(), ", "),
m.From.String(),
m.Subject,
m.Type,
m.Body,
)
}
func (m *Mail) Reader() *strings.Reader {
return strings.NewReader(m.String())
}

66
models/mail_address.go Normal file
View File

@ -0,0 +1,66 @@
package models
import "regexp"
const (
MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
EmailAddrPattern = ".*\\s<(" + MailPattern + ")>|(" + MailPattern + ")"
)
var (
mailRegex *regexp.Regexp
emailAddrRegex *regexp.Regexp
)
func init() {
mailRegex = regexp.MustCompile(MailPattern)
emailAddrRegex = regexp.MustCompile(EmailAddrPattern)
}
type MailAddress string
type MailAddresses []MailAddress
func (m MailAddress) String() string {
return string(m)
}
func (m MailAddress) Raw() string {
match := emailAddrRegex.FindStringSubmatch(string(m))
if len(match) == 3 {
if match[2] != "" {
return match[2]
}
return match[1]
}
return ""
}
func (m MailAddress) Valid() bool {
return emailAddrRegex.Match([]byte(m))
}
func (m MailAddresses) Strings() []string {
out := make([]string, len(m))
for i, s := range m {
out[i] = s.String()
}
return out
}
func (m MailAddresses) RawStrings() []string {
out := make([]string, len(m))
for i, s := range m {
out[i] = s.Raw()
}
return out
}
func (m MailAddresses) AllValid() bool {
for _, a := range m {
if !a.Valid() {
return false
}
}
return true
}

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
}

View File

@ -0,0 +1,22 @@
package metrics
import "fmt"
type CounterMetric struct {
Name string
Value int
Desc string
Labels Labels
}
func (c CounterMetric) Key() string {
return c.Name
}
func (c CounterMetric) Print() string {
return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value)
}
func (c CounterMetric) Header() string {
return fmt.Sprintf("# HELP %s %s\n# TYPE %s counter", c.Name, c.Desc, c.Name)
}

28
models/metrics/label.go Normal file
View File

@ -0,0 +1,28 @@
package metrics
import (
"fmt"
"strings"
)
type Labels []Label
type Label struct {
Key string
Value string
}
func (l Labels) Print() string {
printedLabels := make([]string, len(l))
for i, e := range l {
printedLabels[i] = e.Print()
}
if len(l) == 0 {
return ""
}
return fmt.Sprintf("{%s}", strings.Join(printedLabels, ","))
}
func (l Label) Print() string {
return fmt.Sprintf("%s=\"%s\"", l.Key, l.Value)
}

43
models/metrics/metric.go Normal file
View File

@ -0,0 +1,43 @@
package metrics
import (
"fmt"
"strings"
)
// Hand-crafted Prometheus metrics
// Since we're only using very simple counters in this application,
// we don't actually need the official client SDK as a dependency
type Metrics []Metric
func (m Metrics) Print() (output string) {
printedMetrics := make(map[string]bool)
for _, m := range m {
if _, ok := printedMetrics[m.Key()]; !ok {
output += fmt.Sprintf("%s\n", m.Header())
printedMetrics[m.Key()] = true
}
output += fmt.Sprintf("%s\n", m.Print())
}
return output
}
func (m Metrics) Len() int {
return len(m)
}
func (m Metrics) Less(i, j int) bool {
return strings.Compare(m[i].Key(), m[j].Key()) < 0
}
func (m Metrics) Swap(i, j int) {
m[i], m[j] = m[j], m[i]
}
type Metric interface {
Key() string
Header() string
Print() string
}

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"
"fmt"
"gorm.io/gorm"
"math"
"strconv"
"strings"
"time"
@ -30,24 +29,24 @@ type Interval struct {
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
func (j *CustomTime) MarshalJSON() ([]byte, error) {
return json.Marshal(j.String())
return json.Marshal(j.T())
}
func (j *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
i, err := strconv.ParseInt(s, 10, 64)
s := strings.Trim(string(b), "\"")
ts, err := strconv.ParseFloat(s, 64)
if err != nil {
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)
return nil
}
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
func (j *CustomTime) Scan(value interface{}) error {
var (
t time.Time
@ -56,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
switch value.(type) {
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))
if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
}
case int64:
t = time.Unix(0, value.(int64))
break
case time.Time:
t = value.(time.Time)
break
@ -76,18 +74,17 @@ func (j *CustomTime) Scan(value interface{}) error {
return nil
}
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) Value() (driver.Value, error) {
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil
}
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) String() string {
t := time.Time(j)
return t.Format("2006-01-02 15:04:05.000")
return j.T().String()
}
func (j CustomTime) T() time.Time {

View File

@ -35,7 +35,7 @@ type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-"`
Type uint8 `json:"-"`
Type uint8 `json:"-" gorm:"index:idx_type"`
Key string `json:"key"`
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
}
@ -47,12 +47,15 @@ type SummaryItemContainer struct {
type SummaryViewModel struct {
*Summary
*SummaryParams
User *User
LanguageColors map[string]string
EditorColors map[string]string
OSColors map[string]string
Error string
Success string
ApiKey string
RawQuery string
}
type SummaryParams struct {
@ -91,6 +94,10 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
}
}
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
}
/* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
@ -228,6 +235,12 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
return s
}
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 {
return len(s)
}

View File

@ -1,8 +1,19 @@
package models
import (
"regexp"
"time"
)
func init() {
mailRegex = regexp.MustCompile(MailPattern)
}
type User struct {
ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"`
Password string `json:"-"`
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"`
@ -12,7 +23,11 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`
ResetToken string `json:"-"`
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
}
type Login struct {
@ -22,8 +37,20 @@ type Login struct {
type Signup struct {
Username string `schema:"username"`
Email string `schema:"email"`
Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"`
Location string `schema:"location"`
}
type SetPasswordRequest struct {
Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"`
Token string `schema:"token"`
}
type ResetPasswordRequest struct {
Email string `schema:"email"`
}
type CredentialsReset struct {
@ -32,26 +59,72 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"`
}
type UserDataUpdate struct {
Email string `schema:"email"`
Location string `schema:"location"`
ReportsWeekly bool `schema:"reports_weekly"`
}
type TimeByUser struct {
User string
Time CustomTime
}
type CountByUser struct {
User string
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 {
return validatePassword(c.PasswordNew) &&
return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat
}
func (c *SetPasswordRequest) IsValid() bool {
return ValidatePassword(c.Password) &&
c.Password == c.PasswordRepeat
}
func (s *Signup) IsValid() bool {
return validateUsername(s.Username) &&
validatePassword(s.Password) &&
return ValidateUsername(s.Username) &&
ValidateEmail(s.Email) &&
ValidatePassword(s.Password) &&
s.Password == s.PasswordRepeat
}
func validateUsername(username string) bool {
func (r *UserDataUpdate) IsValid() bool {
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
}
func ValidateUsername(username string) bool {
return len(username) >= 1 && username != "current"
}
func validatePassword(password string) bool {
func ValidatePassword(password string) bool {
return len(password) >= 6
}
func ValidateEmail(email string) bool {
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

@ -1,8 +1,14 @@
package view
type LoginViewModel struct {
Success string
Error string
Success string
Error string
TotalUsers int
}
type SetPasswordViewModel struct {
LoginViewModel
Token string
}
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {

View File

@ -0,0 +1,346 @@
{
"info": {
"_postman_id": "3dcc346d-a9a8-4699-8a52-459eb978b382",
"name": "Wakapi",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Misc",
"item": [
{
"name": "Get health",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/health",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"health"
]
}
},
"response": []
},
{
"name": "Get metrics",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/metrics",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"metrics"
]
}
},
"response": []
}
]
},
{
"name": "Heartbeats",
"item": [
{
"name": "Create heartbeat",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
},
{
"key": "X-Machine-Name",
"value": "devmachine",
"type": "text"
},
{
"key": "User-Agent",
"value": "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",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/proejct1/main.go\",\n \"project\": \"Project 1\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1616680499.113417\n}]",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/heartbeat",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"heartbeat"
]
}
},
"response": []
}
]
},
{
"name": "Summary",
"item": [
{
"name": "Get summary",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/summary?interval=last_7_days",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"summary"
],
"query": [
{
"key": "interval",
"value": "last_7_days"
}
]
}
},
"response": []
}
]
},
{
"name": "Shields",
"item": [
{
"name": "Get Shields data",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/shields/v1/n1try/interval:today/language:Go",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"shields",
"v1",
"n1try",
"interval:today",
"language:Go"
]
}
},
"response": []
}
]
},
{
"name": "WakaTime",
"item": [
{
"name": "Get all time",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/all_time_since_today",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"all_time_since_today"
]
}
},
"response": []
},
{
"name": "Get stats",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/stats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"stats"
]
}
},
"response": []
},
{
"name": "Get stats with range",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/stats/last_7_days",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"stats",
"last_7_days"
]
}
},
"response": []
},
{
"name": "Get summaries",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/summaries?start=2020-03-01T15:04:05Z&end=2020-03-31T15:04:05Z",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"summaries"
],
"query": [
{
"key": "start",
"value": "2020-03-01T15:04:05Z"
},
{
"key": "end",
"value": "2020-03-31T15:04:05Z"
}
]
}
},
"response": []
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const apiKey = pm.variables.get('API_KEY')",
"",
"if (!apiKey) {",
" throw new Error('no api key given')",
"}",
"",
"const token = base64encode(apiKey)",
"pm.variables.set('TOKEN', token)",
"",
"function base64encode(str) {",
" return Buffer.from(str, 'utf-8').toString('base64')",
"}"
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "BASE_URL",
"value": "http://localhost:3000"
},
{
"key": "API_KEY",
"value": ""
}
]
}

View File

@ -14,6 +14,14 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
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) {
var aliases []*models.Alias
if err := r.db.

View File

@ -1,6 +1,7 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -15,6 +16,15 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
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 {
if err := r.db.
Clauses(clause.OnConflict{
@ -26,15 +36,16 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
return nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
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}).
Count(&count).Error; err != nil {
return 0, err
Order("time desc").
First(&heartbeat).Error; err != nil {
return nil, err
}
return count, nil
return &heartbeat, nil
}
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
@ -53,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) {
// https://stackoverflow.com/a/20765152/3112139
var heartbeats []*models.Heartbeat
if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time < ?", to).
Where("time >= ?", from.Local()).
Where("time < ?", to.Local()).
Order("time asc").
Find(&heartbeats).Error; err != nil {
return nil, err
@ -75,9 +87,77 @@ func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
return result, nil
}
func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
var counts []*models.CountByUser
userIds := make([]string, len(users))
for i, u := range users {
userIds[i] = u.ID
}
if err := r.db.
Model(&models.Heartbeat{}).
Select("user_id as user, count(id) as count").
Where("user_id in ?", userIds).
Group("user").
Find(&counts).Error; err != nil {
return counts, err
}
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 {
if err := r.db.
Where("time <= ?", t).
Where("time <= ?", t.Local()).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}

View File

@ -2,7 +2,6 @@ package repositories
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -16,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
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) {
kv := &models.KeyStringValue{}
if err := r.db.
@ -40,10 +47,6 @@ func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
return err
}
if result.RowsAffected != 1 {
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
}
return nil
}

View File

@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
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) {
mapping := &models.LanguageMapping{}
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {

View File

@ -9,6 +9,7 @@ type IAliasRepository interface {
Insert(*models.Alias) (*models.Alias, error)
Delete(uint) error
DeleteBatch([]uint) error
GetAll() ([]*models.Alias, error)
GetByUser(string) ([]*models.Alias, error)
GetByUserAndKey(string, string) ([]*models.Alias, error)
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
@ -17,20 +18,28 @@ type IAliasRepository interface {
type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
CountByUser(*models.User) (int64, error)
GetAll() ([]*models.Heartbeat, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
}
type IKeyValueRepository interface {
GetAll() ([]*models.KeyStringValue, error)
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingRepository interface {
GetAll() ([]*models.LanguageMapping, error)
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
@ -39,6 +48,7 @@ type ILanguageMappingRepository interface {
type ISummaryRepository interface {
Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
@ -46,8 +56,15 @@ type ISummaryRepository interface {
type IUserRepository interface {
GetById(string) (*models.User, error)
GetByIds([]string) ([]*models.User, error)
GetByApiKey(string) (*models.User, error)
GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error)
GetByLastActiveAfter(time.Time) ([]*models.User, error)
Count() (int64, error)
InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error)

View File

@ -14,6 +14,21 @@ func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
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).
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil {
return err
@ -25,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Where("from_time >= ?", from.Local()).
Where("to_time <= ?", to.Local()).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).

View File

@ -4,6 +4,7 @@ import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type UserRepository struct {
@ -22,6 +23,17 @@ func (r *UserRepository) GetById(userId string) (*models.User, error) {
return u, nil
}
func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Model(&models.User{}).
Where("id in ?", userIds).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
@ -30,6 +42,28 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
return u, nil
}
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
if resetToken == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
if email == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
@ -40,6 +74,54 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
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) {
var users []*models.User
if err := r.db.
Where("last_logged_in_at >= ?", t.Local()).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user")
var userIds []string
if err := r.db.
Select("user as id").
Table("(?) as q", subQuery1).
Where("time >= ?", t.Local()).
Scan(&userIds).Error; err != nil {
return nil, err
}
return r.GetByIds(userIds)
}
func (r *UserRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.User{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
if err := result.Error; err != nil {
@ -57,6 +139,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
updateMap := map[string]interface{}{
"api_key": user.ApiKey,
"password": user.Password,
"email": user.Email,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
@ -65,6 +148,10 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData,
"reset_token": user.ResetToken,
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
}
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) {
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

View File

@ -2,11 +2,11 @@ package api
import (
"encoding/json"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -35,12 +35,14 @@ type heartbeatResponseVm struct {
}
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter()
r := router.PathPrefix("").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).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
@ -52,8 +54,12 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
// @Success 201
// @Router /heartbeat [post]
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
user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name")
@ -73,7 +79,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid heartbeat object."))
w.Write([]byte("invalid heartbeat object"))
return
}
@ -82,11 +88,22 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError)
logbuch.Error(err.Error())
w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to batch-insert heartbeats %v", err)
return
}
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to update user %v", err)
return
}
}
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)

273
routes/api/metrics.go Normal file
View File

@ -0,0 +1,273 @@
package api
import (
"errors"
"github.com/emvi/logbuch"
"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"
mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"sort"
"time"
)
const (
MetricsPrefix = "wakatime"
DescHeartbeats = "Total number of tracked heartbeats."
DescAllTime = "Total seconds (all time)."
DescTotal = "Total seconds."
DescEditors = "Total seconds for each editor."
DescProjects = "Total seconds for each project."
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
DescAdminTotalUsers = "Total number of registered users."
DescAdminActiveUsers = "Number of active users."
)
type MetricsHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService
}
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
return &MetricsHandler{
userSrvc: userService,
summarySrvc: summaryService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
config: conf.Get(),
}
}
func (h *MetricsHandler) RegisterRoutes(router *mux.Router) {
if !h.config.Security.ExposeMetrics {
return
}
logbuch.Info("exposing prometheus metrics under /api/metrics")
r := router.PathPrefix("/metrics").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
reqUser := middlewares.GetPrincipal(r)
if reqUser == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
var metrics mm.Metrics
if userMetrics, err := h.getUserMetrics(reqUser); err != nil {
conf.Log().Request(r).Error("%v", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
return
} else {
for _, m := range *userMetrics {
metrics = append(metrics, m)
}
}
if reqUser.IsAdmin {
if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil {
conf.Log().Request(r).Error("%v", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
return
} else {
for _, m := range *adminMetrics {
metrics = append(metrics, m)
}
}
}
sort.Sort(metrics)
w.Header().Set("content-type", "text/plain; charset=utf-8")
w.Write([]byte(metrics.Print()))
}
func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, false)
if err != nil {
logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID)
return nil, err
}
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
if err != nil {
logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID)
return nil, err
}
heartbeatCount, err := h.heartbeatSrvc.CountByUser(user)
if err != nil {
logbuch.Error("failed to count heartbeats for user '%s' for metric", user.ID)
return nil, err
}
// User Metrics
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats,
Value: int(heartbeatCount),
Labels: []mm.Label{},
})
for _, p := range summaryToday.Projects {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}},
})
}
for _, l := range summaryToday.Languages {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}},
})
}
for _, e := range summaryToday.Editors {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}},
})
}
for _, o := range summaryToday.OperatingSystems {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}},
})
}
for _, m := range summaryToday.Machines {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil
}
func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics
if !user.IsAdmin {
return nil, errors.New("unauthorized")
}
var totalSeconds int
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
if d, err := time.ParseDuration(t.Value); err == nil {
totalSeconds = int(d.Seconds())
}
}
totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count()
activeUsers, err := h.userSrvc.GetActive()
if err != nil {
logbuch.Error("failed to retrieve active users for metric %v", err)
return nil, err
}
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime,
Value: totalSeconds,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats,
Value: int(totalHeartbeats),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers,
Value: int(totalUsers),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers,
Value: len(activeUsers),
Labels: []mm.Label{},
})
// Count per-user heartbeats
userCounts, err := h.heartbeatSrvc.CountByUsers(activeUsers)
if err != nil {
logbuch.Error("failed to count heartbeats for active users", err.Error())
return nil, err
}
for _, uc := range userCounts {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats,
Value: int(uc.Count),
Labels: []mm.Label{{Key: "user", Value: uc.User}},
})
}
return &metrics, nil
}

View File

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

View File

@ -1,15 +1,16 @@
package v1
import (
"fmt"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"net/http"
"regexp"
"strings"
"time"
)
@ -22,12 +23,14 @@ type BadgeHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
cache *cache.Cache
}
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
cache: cache.New(time.Hour, time.Hour),
config: conf.Get(),
}
}
@ -52,11 +55,6 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
if userAgent := r.Header.Get("user-agent"); !strings.HasPrefix(userAgent, "Shields.io/") && !h.config.IsDev() {
w.WriteHeader(http.StatusForbidden)
return
}
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
@ -76,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
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)))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
@ -101,6 +99,12 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
filters = &models.Filters{}
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
return
}
summary, err, status := h.loadUserSummary(user, interval)
if err != nil {
w.WriteHeader(status)
@ -109,11 +113,12 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
}
vm := v1.NewBadgeDataFrom(summary, filters)
utils.RespondJSON(w, http.StatusOK, vm)
h.cache.SetDefault(cacheKey, vm)
utils.RespondJSON(w, r, http.StatusOK, vm)
}
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 {
return nil, err, http.StatusBadRequest
}
@ -129,7 +134,7 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.Inter
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -6,6 +6,7 @@ import (
"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"
@ -32,7 +33,7 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
r.Use(
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
@ -45,18 +46,14 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summary, err, status := h.loadUserSummary(authorizedUser)
summary, err, status := h.loadUserSummary(user)
if err != nil {
w.WriteHeader(status)
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")))
utils.RespondJSON(w, http.StatusOK, vm)
utils.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
@ -80,7 +77,7 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
if err != nil {
return nil, err, http.StatusInternalServerError
}

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

@ -33,18 +33,29 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
)
r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
// Also works without range, see https://github.com/anuraghazra/github-readme-stats/issues/865#issuecomment-776186592
r.Path("/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// 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) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if u := r.Context().Value(models.UserKey); u != nil {
authorizedUser = u.(*models.User)
}
authorizedUser = middlewares.GetPrincipal(r)
if authorizedUser != nil && vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
@ -56,7 +67,12 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(vars["range"])
rangeParam := vars["range"]
if rangeParam == "" {
rangeParam = (*models.IntervalPast7Days)[0]
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
@ -97,7 +113,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
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) {
@ -108,7 +124,7 @@ func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time)
Recompute: false,
}
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve)
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, false)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -7,6 +7,7 @@ import (
"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"
@ -33,10 +34,10 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
r.Use(
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.
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108.
@ -54,13 +55,9 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summaries, err, status := h.loadUserSummaries(r)
@ -76,40 +73,53 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
}
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) {
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
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
if rangeParam != "" {
// 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
} else {
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
start, end = parsedFrom, parsedTo
} else {
// eventually, consider start and end params a date
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 {
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 {
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{
From: start,
To: end,
@ -121,10 +131,13 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, false)
if err != nil {
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
}

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

@ -20,6 +20,7 @@ type HomeHandler struct {
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
var resetPasswordDecoder = schema.NewDecoder()
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
return &HomeHandler{

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -9,18 +10,21 @@ import (
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type LoginHandler struct {
config *conf.Config
userSrvc services.IUserService
mailSrvc services.IMailService
}
func NewLoginHandler(userService services.IUserService) *LoginHandler {
func NewLoginHandler(userService services.IUserService, mailService services.IMailService) *LoginHandler {
return &LoginHandler{
config: conf.Get(),
userSrvc: userService,
mailSrvc: mailService,
}
}
@ -30,6 +34,10 @@ func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword)
router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword)
router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword)
router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword)
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
@ -150,7 +158,9 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
numUsers, _ := h.userSrvc.Count()
_, created, err := h.userSrvc.CreateOrGet(&signup, numUsers == 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
@ -165,9 +175,134 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
}
func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
values, _ := url.ParseQuery(r.URL.RawQuery)
token := values.Get("token")
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token"))
return
}
vm := &view.SetPasswordViewModel{
LoginViewModel: *h.buildViewModel(r),
Token: token,
}
templates[conf.SetPasswordTemplate].Execute(w, vm)
}
func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
var setRequest models.SetPasswordRequest
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token"))
return
}
if !setRequest.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
user.Password = setRequest.Password
user.ResetToken = ""
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
return
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound)
}
func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if !h.config.Mail.Enabled {
w.WriteHeader(http.StatusNotImplemented)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server"))
return
}
var resetRequest models.ResetPasswordRequest
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil {
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
return
} else {
go func(user *models.User) {
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
conf.Log().Request(r).Error("failed to send password reset mail to %s %v", user.ID, err)
} else {
logbuch.Info("sent password reset mail to %s", user.ID)
}
}(u)
}
} else {
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)
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
numUsers, _ := h.userSrvc.Count()
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
TotalUsers: int(numUsers),
}
}

View File

@ -2,15 +2,17 @@ package routes
import (
"fmt"
"github.com/markbates/pkger"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"html/template"
"io/fs"
"io/ioutil"
"net/http"
"path"
"strings"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"github.com/muety/wakapi/views"
)
func Init() {
@ -21,18 +23,26 @@ type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template
func loadTemplates() {
const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"capitalize": utils.Capitalize,
"toRunes": utils.ToRunes,
"entityTypes": models.SummaryTypes,
"typeName": typeName,
func DefaultTemplateFuncs() template.FuncMap {
return template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"datetime": utils.FormatDateTimeHuman,
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"floordate": utils.FloorDate,
"ceildate": utils.CeilDate,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"capitalize": utils.Capitalize,
"toRunes": utils.ToRunes,
"entityTypes": models.SummaryTypes,
"typeName": typeName,
"isDev": func() bool {
return config.Get().IsDev()
},
"getBasePath": func() string {
return config.Get().Server.BasePath
},
@ -45,15 +55,17 @@ func loadTemplates() {
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
},
})
}
}
func loadTemplates() {
tpls := template.New("").Funcs(DefaultTemplateFuncs())
templates = make(map[string]*template.Template)
dir, err := pkger.Open(tplPath)
if err != nil {
panic(err)
}
defer dir.Close()
files, err := dir.Readdir(0)
// 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 {
panic(err)
}
@ -64,7 +76,7 @@ func loadTemplates() {
continue
}
templateFile, err := pkger.Open(fmt.Sprintf("%s/%s", tplPath, tplName))
templateFile, err := templateFs.Open(tplName)
if err != nil {
panic(err)
}
@ -102,3 +114,7 @@ func typeName(t uint8) string {
}
return "unknown"
}
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -27,6 +27,7 @@ type SettingsHandler struct {
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService
httpClient *http.Client
}
@ -40,6 +41,7 @@ func NewSettingsHandler(
aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService,
keyValueService services.IKeyValueService,
mailService services.IMailService,
) *SettingsHandler {
return &SettingsHandler{
config: conf.Get(),
@ -50,6 +52,7 @@ func NewSettingsHandler(
userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
mailSrvc: mailService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
@ -57,7 +60,7 @@ func NewSettingsHandler(
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
@ -117,6 +120,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
switch action {
case "change_password":
return h.actionChangePassword
case "update_user":
return h.actionUpdateUser
case "reset_apikey":
return h.actionResetApiKey
case "delete_alias":
@ -141,12 +146,42 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return nil
}
func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
var payload models.UserDataUpdate
if err := r.ParseForm(); err != nil {
return http.StatusBadRequest, "", "missing parameters"
}
if err := credentialsDecoder.Decode(&payload, r.PostForm); err != nil {
return http.StatusBadRequest, "", "missing parameters"
}
if !payload.IsValid() {
return http.StatusBadRequest, "", "invalid parameters"
}
user.Email = payload.Email
user.Location = payload.Location
user.ReportsWeekly = payload.ReportsWeekly
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
return http.StatusOK, "user updated successfully", ""
}
func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
@ -166,13 +201,13 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
return http.StatusInternalServerError, "", "internal server error"
return http.StatusInternalServerError, "", conf.ErrInternalServerError
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal server error"
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
login := &models.Login{
@ -181,7 +216,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil {
return http.StatusInternalServerError, "", "internal server error"
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
@ -193,9 +228,9 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
return http.StatusInternalServerError, "", "internal server error"
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
@ -208,7 +243,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
}
var err error
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
defer h.userSrvc.FlushCache()
@ -235,7 +270,7 @@ func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Reque
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
aliasKey := r.PostFormValue("key")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
if err != nil {
@ -255,7 +290,7 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
aliasKey := r.PostFormValue("key")
aliasValue := r.PostFormValue("value")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
@ -283,19 +318,20 @@ func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
mapping, err := h.languageMappingSrvc.GetById(uint(id))
if err != nil || mapping == nil {
return http.StatusNotFound, "", "mapping not found"
} else if mapping.UserID != user.ID {
return http.StatusForbidden, "", "not allowed to delete mapping"
}
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
if err := h.languageMappingSrvc.Delete(mapping); err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
@ -306,7 +342,7 @@ func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *htt
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
@ -332,7 +368,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
apiKey := r.PostFormValue("api_key")
// Healthcheck, if a new API key is set, i.e. the feature is activated
@ -341,7 +377,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
}
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
return http.StatusInternalServerError, "", "internal server error"
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
return http.StatusOK, "Wakatime API Key updated successfully", ""
@ -352,7 +388,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
if user.WakatimeApiKey == "" {
return http.StatusForbidden, "", "not connected to wakatime"
}
@ -370,6 +406,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
}
go func(user *models.User) {
start := time.Now()
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
countBefore, err := h.heartbeatSrvc.CountByUser(user)
@ -388,23 +425,38 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
count := 0
batch := make([]*models.Heartbeat, 0)
insert := func(batch []*models.Heartbeat) {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err)
}
}
for hb := range stream {
count++
batch = append(batch, hb)
if len(batch) == h.config.App.ImportBatchSize {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err)
}
insert(batch)
batch = make([]*models.Heartbeat, 0)
}
}
if len(batch) > 0 {
insert(batch)
}
countAfter, _ := h.heartbeatSrvc.CountByUser(user)
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
h.regenerateSummaries(user)
if user.Email != "" {
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
conf.Log().Request(r).Error("failed to send import notification mail to %s %v", user.ID, err)
} else {
logbuch.Info("sent import notification mail to %s", user.ID)
}
}
}(user)
h.keyValueSrvc.PutString(&models.KeyStringValue{
@ -412,7 +464,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
Value: time.Now().Format(time.RFC822),
})
return http.StatusAccepted, "ImportAll started. This may take a few minutes.", ""
return http.StatusAccepted, "Import started. This will take several minutes. Please check back later.", ""
}
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
@ -420,13 +472,13 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
go func(user *models.User) {
if err := h.regenerateSummaries(user); err != nil {
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' %v", user.ID, err)
}
}(middlewares.GetPrincipal(r))
if err := h.regenerateSummaries(user); err != nil {
return http.StatusInternalServerError, "", "failed to regenerate summaries"
}
return http.StatusOK, "summaries are being regenerated this may take a few seconds", ""
return http.StatusAccepted, "summaries are being regenerated this may take a up to a couple of minutes, please come back later", ""
}
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
@ -434,12 +486,12 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
go func(user *models.User) {
logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute)
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 {
logbuch.Info("successfully deleted user '%s'", user.ID)
}
@ -493,7 +545,7 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
aliasMap := make(map[string][]*models.Alias)

View File

@ -29,7 +29,7 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
@ -39,12 +39,14 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
rawQuery := r.URL.RawQuery
q := r.URL.Query()
if q.Get("interval") == "" && q.Get("from") == "" {
q.Set("interval", "today")
r.URL.RawQuery = q.Encode()
}
summaryParams, _ := utils.ParseSummaryParams(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
@ -52,7 +54,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
return
}
user := r.Context().Value(models.UserKey).(*models.User)
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
@ -61,10 +63,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{
Summary: summary,
SummaryParams: summaryParams,
User: user,
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
}
templates[conf.SummaryTemplate].Execute(w, vm)

View File

@ -1,6 +1,7 @@
package utils
import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
@ -8,6 +9,7 @@ import (
)
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
@ -18,10 +20,13 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
retrieveSummary = ss.Summarize
}
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
if err != nil {
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
}

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

@ -1,113 +0,0 @@
#!/usr/bin/python3
import argparse
import base64
import random
import string
import sys
from datetime import datetime, timedelta
from typing import List, Union
import requests
from tqdm import tqdm
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'
LANGUAGES = {
'Go': 'go',
'Java': 'java',
'JavaScript': 'js',
'Python': 'py'
}
class Heartbeat:
def __init__(
self,
entity: str,
project: str,
language: str,
time: float,
is_write: bool = True,
branch: str = 'master',
type: str = 'file'
):
self.entity: str = entity
self.project: str = project
self.language: str = language
self.time: float = time
self.is_write: bool = is_write
self.branch: str = branch
self.type: str = type
self.category: Union[str, None] = None
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
data: List[Heartbeat] = []
now: datetime = datetime.today()
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
languages: List[str] = list(LANGUAGES.keys())
for _ in range(n):
p: str = random.choice(projects)
l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8))
delta: timedelta = timedelta(
hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59),
seconds=random.randint(0, 59),
milliseconds=random.randint(0, 999),
microseconds=random.randint(0, 999)
)
data.append(Heartbeat(
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
project=p,
language=l,
time=(now - delta).timestamp()
))
return data
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')
for h in tqdm(data):
r = requests.post(url, json=[h.__dict__], headers={
'User-Agent': UA,
'Authorization': f'Basic {encoded_key}',
'X-Machine-Name': MACHINE,
})
if r.status_code != 201:
print(r.text)
sys.exit(1)
def randomword(length: int) -> str:
letters = string.ascii_lowercase
return ''.join(random.choice(letters) for _ in range(length))
def parse_arguments():
parser = argparse.ArgumentParser(description='Wakapi test data insertion script.')
parser.add_argument('-n', type=int, default=20, help='total number of random heartbeats to generate and insert')
parser.add_argument('-u', '--url', type=str, default='http://localhost:3000/api/heartbeat',
help='url of your api\'s heartbeats endpoint')
parser.add_argument('-k', '--apikey', type=str, required=True,
help='your api key (to get one, go to the web interface, create a new user, log in and copy the key)')
parser.add_argument('-p', '--projects', type=int, default=5, help='number of different fake projects to generate')
parser.add_argument('-o', '--offset', type=int, default=24,
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,
help='a seed for initializing the pseudo-random number generator')
return parser.parse_args()
if __name__ == '__main__':
args = parse_arguments()
random.seed(args.seed)
data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
post_data_sync(data, args.url, args.apikey)

288
scripts/sample_data.py Normal file
View File

@ -0,0 +1,288 @@
#!/usr/bin/python3
import argparse
import base64
import random
import string
import sys
from datetime import datetime, timedelta
from typing import List, Union, Callable
import requests
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'
LANGUAGES = {
'Go': 'go',
'Java': 'java',
'JavaScript': 'js',
'Python': 'py',
# https://github.com/muety/wakapi/issues/172
'PHP': 'php',
'Blade': 'blade.php'
}
class Heartbeat:
def __init__(
self,
entity: str,
project: str,
language: str,
time: float,
is_write: bool = True,
branch: str = 'master',
type: str = 'file'
):
self.entity: str = entity
self.project: str = project
self.language: str = language
self.time: float = time
self.is_write: bool = is_write
self.branch: str = branch
self.type: str = type
self.category: Union[str, None] = None
class ConfigParams:
def __init__(self):
self.api_url = ''
self.api_key = ''
self.n = 0
self.n_projects = 0
self.offset = 0
self.seed = 0
self.batch = False
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
data: List[Heartbeat] = []
now: datetime = datetime.today()
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
languages: List[str] = list(LANGUAGES.keys())
for _ in range(n):
p: str = random.choice(projects)
l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8))
delta: timedelta = timedelta(
hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59),
seconds=random.randint(0, 59),
milliseconds=random.randint(0, 999),
microseconds=random.randint(0, 999)
)
data.append(Heartbeat(
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
project=p,
language=l,
time=(now - delta).timestamp()
))
return data
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')
r = requests.post(url, json=[h.__dict__ for h in data], headers={
'User-Agent': UA,
'Authorization': f'Basic {encoded_key}',
'X-Machine-Name': MACHINE,
})
if r.status_code != 201:
print(r.text)
sys.exit(1)
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.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \
QLineEdit, QSpinBox, QProgressBar, QPushButton, QCheckBox
# Main app
app = QApplication([])
window = QWidget()
window.setWindowTitle('Wakapi Sample Data Generator')
window.setFixedSize(window.sizeHint())
window.setMinimumWidth(350)
container_layout = QVBoxLayout()
# Top Controls
form_layout_1 = QFormLayout()
url_input_label = QLabel('URL:')
url_input = QLineEdit()
url_input.setPlaceholderText('Wakatime API Url')
url_input.setText('http://localhost:3000/api')
api_key_input_label = QLabel('API Key:')
api_key_input = QLineEdit()
api_key_input.setPlaceholderText(f'{"x"*8}-{"x"*4}-{"x"*4}-{"x"*4}-{"x"*12}')
form_layout_1.addRow(url_input_label, url_input)
form_layout_1.addRow(api_key_input_label, api_key_input)
# Middle controls
form_layout_2 = QFormLayout()
params_container = QGroupBox('Parameters')
params_container.setLayout(form_layout_2)
heartbeats_input_label = QLabel('# Heartbeats')
heartbeats_input = QSpinBox()
heartbeats_input.setMaximum(2147483647)
heartbeats_input.setValue(100)
projects_input_label = QLabel('# Projects:')
projects_input = QSpinBox()
projects_input.setMinimum(1)
projects_input.setValue(5)
offset_input_label = QLabel('Time Offset (hrs):')
offset_input = QSpinBox()
offset_input.setMinimum(-2147483647)
offset_input.setMaximum(0)
offset_input.setValue(-12)
seed_input_label = QLabel('Random Seed:')
seed_input = QSpinBox()
seed_input.setMaximum(2147483647)
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(projects_input_label, projects_input)
form_layout_2.addRow(offset_input_label, offset_input)
form_layout_2.addRow(seed_input_label, seed_input)
form_layout_2.addRow(batch_checkbox)
# Bottom controls
bottom_layout = QHBoxLayout()
start_button = QPushButton('Generate')
progress_bar = QProgressBar()
bottom_layout.addWidget(progress_bar)
bottom_layout.addWidget(start_button)
# Wiring up
container_layout.addLayout(form_layout_1)
container_layout.addWidget(params_container)
container_layout.addLayout(bottom_layout)
container_layout.setStretch(1, 1)
window.setLayout(container_layout)
# Done dialog
done_dialog = QWidget()
done_dialog.setWindowTitle('Done')
done_ok_button = QPushButton('Ok')
done_layout = QVBoxLayout()
done_layout.addWidget(QLabel('Done!'), alignment=Qt.AlignCenter)
done_layout.addWidget(done_ok_button, alignment=Qt.AlignCenter)
done_dialog.setFixedSize(done_dialog.sizeHint())
done_ok_button.clicked.connect(done_dialog.close)
done_dialog.setLayout(done_layout)
# Logic
def parse_params() -> ConfigParams:
params = ConfigParams()
params.api_url = url_input.text()
params.api_key = api_key_input.text()
params.n = heartbeats_input.value()
params.n_projects = projects_input.value()
params.offset = offset_input.value()
params.seed = seed_input.value()
params.batch = batch_checkbox.isChecked()
return params
def update_progress(inc=1):
current = progress_bar.value()
updated = current + inc
if updated == progress_bar.maximum() - 1:
progress_bar.setValue(0)
start_button.setEnabled(True)
done_dialog.show()
return
progress_bar.setValue(updated)
def call_back():
params = parse_params()
progress_bar.setMaximum(params.n)
start_button.setEnabled(False)
callback(params, update_progress)
start_button.clicked.connect(call_back)
return app, window
def parse_arguments():
parser = argparse.ArgumentParser(description='Wakapi test data insertion script.')
parser.add_argument('--headless', default=False, help='do not show a gui', action='store_true')
parser.add_argument('-n', type=int, default=20, help='total number of random heartbeats to generate and insert')
parser.add_argument('-u', '--url', type=str, default='http://localhost:3000/api',
help='url of your api\'s heartbeats endpoint')
parser.add_argument('-k', '--apikey', type=str,
help='your api key (to get one, go to the web interface, create a new user, log in and copy the key)')
parser.add_argument('-p', '--projects', type=int, default=5, help='number of different fake projects to generate')
parser.add_argument('-o', '--offset', type=int, default=24,
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,
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()
def args_to_params(parsed_args: argparse.Namespace) -> (ConfigParams, bool):
params = ConfigParams()
params.n = parsed_args.n
params.n_projects = parsed_args.projects
params.offset = parsed_args.offset
params.seed = parsed_args.seed
params.api_url = parsed_args.url
params.api_key = parsed_args.apikey
params.batch = parsed_args.batch
return params, not parsed_args.headless
def randomword(length: int) -> str:
letters = string.ascii_lowercase + 'äöü💩' # test utf8 and utf8mb4 characters as well
return ''.join(random.choice(letters) for _ in range(length))
def run(params: ConfigParams, update_progress: Callable[[int], None]):
random.seed(params.seed)
data: List[Heartbeat] = generate_data(
params.n,
params.n_projects,
params.offset * -1 if params.offset < 0 else params.offset
)
# batch-mode won't work when using sqlite backend
if params.batch:
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__':
params, show_gui = args_to_params(parse_arguments())
if show_gui:
app, window = make_gui(callback=run)
window.show()
app.exec()
else:
from tqdm import tqdm
pbar = tqdm(total=params.n)
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
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"runtime"
"sync"
"time"
"github.com/go-co-op/gocron"
@ -14,11 +16,14 @@ const (
aggregateIntervalDays int = 1
)
var aggregationLock = sync.Mutex{}
type AggregationService struct {
config *config.Config
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
inProgress map[string]bool
}
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -27,6 +32,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
userService: userService,
summaryService: summaryService,
heartbeatService: heartbeatService,
inProgress: map[string]bool{},
}
}
@ -49,6 +55,11 @@ func (srv *AggregationService) Schedule() {
}
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)
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) {
for job := range jobs {
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 {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary
@ -84,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
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
if allUsers, err := srv.userService.GetAll(); err != nil {
logbuch.Error(err.Error())
config.Log().Error(err.Error())
return err
} else if userIds != nil && len(userIds) > 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
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil {
logbuch.Error(err.Error())
config.Log().Error(err.Error())
return err
}
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
logbuch.Error(err.Error())
config.Log().Error(err.Error())
return err
}
@ -145,11 +156,33 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
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) {
var to time.Time
// Go to next day of either user's first heartbeat or latest aggregation
from.Add(-1 * time.Second)
from = from.Add(-1 * time.Second)
from = time.Date(
from.Year(),
from.Month(),

View File

@ -1,8 +1,11 @@
package services
import (
"fmt"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"time"
"github.com/muety/wakapi/models"
@ -10,6 +13,7 @@ import (
type HeartbeatService struct {
config *config.Config
cache *cache.Cache
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
@ -17,23 +21,45 @@ type HeartbeatService struct {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
}
}
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
srv.updateEntityUserCacheByHeartbeat(heartbeat)
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return srv.repository.InsertBatch(heartbeats)
hashes := make(map[string]bool)
// https://github.com/muety/wakapi/issues/139
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
for _, hb := range heartbeats {
if _, ok := hashes[hb.Hash]; !ok {
filteredHeartbeats = append(filteredHeartbeats, hb)
hashes[hb.Hash] = true
}
srv.updateEntityUserCacheByHeartbeat(hb)
}
return srv.repository.InsertBatch(filteredHeartbeats)
}
func (srv *HeartbeatService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user)
}
func (srv *HeartbeatService) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
return srv.repository.CountByUsers(users)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil {
@ -42,6 +68,10 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
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) {
return srv.repository.GetLatestByOriginAndUser(origin, user)
}
@ -50,6 +80,20 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
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
}
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
return results, nil
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
return srv.repository.DeleteBefore(t)
}
@ -66,3 +110,26 @@ func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId st
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

@ -18,7 +18,13 @@ import (
)
const OriginWakatime = "wakatime"
const maxWorkers = 6
const (
// wakatime api permits a max. rate of 10 req / sec
// https://github.com/wakatime/wakatime/issues/261
// with 5 workers, each sleeping slightly over 1/2 sec after every req., we should stay well below that limit
maxWorkers = 5
throttleDelay = 550 * time.Millisecond
)
type WakatimeHeartbeatImporter struct {
ApiKey string
@ -36,7 +42,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
go func(user *models.User, out chan *models.Heartbeat) {
startDate, endDate, err := w.fetchRange()
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
}
@ -49,7 +55,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
userAgents, err := w.fetchUserAgents()
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
}
machinesNames, err := w.fetchMachineNames()
if err != nil {
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
@ -67,15 +79,16 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
go func(day time.Time) {
defer sem.Release(1)
defer time.Sleep(throttleDelay)
d := day.Format("2006-01-02")
d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d)
if err != nil {
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", day, 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 {
out <- mapHeartbeat(h, userAgents, user)
out <- mapHeartbeat(h, userAgents, machinesNames, user)
}
if c.Dec() == 0 {
@ -108,6 +121,8 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.Hea
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
} else if res.StatusCode >= 400 {
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
}
var heartbeatsData wakatime.HeartbeatsViewModel
@ -134,27 +149,17 @@ func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
return notime, notime, err
}
var allTimeData map[string]interface{}
var allTimeData wakatime.AllTimeViewModel
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
return notime, notime, err
}
data := allTimeData["data"].(map[string]interface{})
if data == nil {
return notime, notime, errors.New("invalid response")
}
dataRange := data["range"].(map[string]interface{})
if dataRange == nil {
return notime, notime, errors.New("invalid response")
}
startDate, err := time.Parse("2006-01-02", dataRange["start_date"].(string))
startDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.StartDate)
if err != nil {
return notime, notime, err
}
endDate, err := time.Parse("2006-01-02", dataRange["end_date"].(string))
endDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.EndDate)
if err != nil {
return notime, notime, err
}
@ -189,6 +194,33 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.User
return userAgents, nil
}
// https://wakatime.com/api/v1/users/current/machine_names
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiMachineNamesUrl, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var machineData wakatime.MachineViewModel
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
return nil, err
}
machines := make(map[string]*wakatime.MachineEntry)
for _, ma := range machineData.Data {
machines[ma.Id] = ma
}
return machines, nil
}
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
return req
@ -197,6 +229,7 @@ func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request
func mapHeartbeat(
entry *wakatime.HeartbeatEntry,
userAgents map[string]*wakatime.UserAgentEntry,
machineNames map[string]*wakatime.MachineEntry,
user *models.User,
) *models.Heartbeat {
ua := userAgents[entry.UserAgentId]
@ -207,6 +240,14 @@ func mapHeartbeat(
}
}
ma := machineNames[entry.MachineNameId]
if ma == nil {
ma = &wakatime.MachineEntry{
Id: entry.MachineNameId,
Value: entry.MachineNameId,
}
}
return (&models.Heartbeat{
User: user,
UserID: user.ID,
@ -219,7 +260,7 @@ func mapHeartbeat(
IsWrite: entry.IsWrite,
Editor: ua.Editor,
OperatingSystem: ua.Os,
Machine: entry.MachineNameId, // TODO
Machine: ma.Value,
Time: entry.Time,
Origin: OriginWakatime,
OriginId: entry.Id,

151
services/mail/mail.go Normal file
View File

@ -0,0 +1,151 @@
package mail
import (
"bytes"
"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"
"time"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/views"
)
const (
tplNamePasswordReset = "reset_password"
tplNameImportNotification = "import_finished"
tplNameReport = "report"
subjectPasswordReset = "Wakapi - Password Reset"
subjectImportNotification = "Wakapi - Data Import Finished"
subjectReport = "Wakapi - Report from %s"
)
type SendingService interface {
Send(*models.Mail) error
}
type MailService struct {
config *conf.Config
sendingService SendingService
}
func NewMailService() services.IMailService {
config := conf.Get()
var sendingService SendingService
sendingService = &NoopSendingService{}
if config.Mail.Enabled {
if config.Mail.Provider == conf.MailProviderMailWhale {
sendingService = NewMailWhaleSendingService(config.Mail.MailWhale)
} else if config.Mail.Provider == conf.MailProviderSmtp {
sendingService = NewSMTPSendingService(config.Mail.Smtp)
}
}
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) {
tpl, err := loadTemplate(tplNamePasswordReset)
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 getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
tpl, err := loadTemplate(tplNameImportNotification)
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 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) {
tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName))
if err != nil {
return nil, err
}
defer tplFile.Close()
tplData, err := ioutil.ReadAll(tplFile)
if err != nil {
return nil, err
}
return template.
New(tplName).
Funcs(routes.DefaultTemplateFuncs()).
Parse(string(tplData))
}

View File

@ -0,0 +1,70 @@
package mail
import (
"bytes"
"encoding/json"
"errors"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"net/http"
"time"
)
type MailWhaleSendingService struct {
config conf.MailwhaleMailConfig
httpClient *http.Client
}
type MailWhaleSendRequest struct {
To []string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
Html string `json:"html"`
TemplateId string `json:"template_id"`
TemplateVars map[string]string `json:"template_vars"`
}
func NewMailWhaleSendingService(config conf.MailwhaleMailConfig) *MailWhaleSendingService {
return &MailWhaleSendingService{
config: config,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (s *MailWhaleSendingService) Send(mail *models.Mail) error {
if len(mail.To) == 0 {
return errors.New("not sending mail as recipient mail address seems to be invalid")
}
sendRequest := &MailWhaleSendRequest{
To: mail.To.Strings(),
Subject: mail.Subject,
}
if mail.Type == models.HtmlType {
sendRequest.Html = mail.Body
} else {
sendRequest.Text = mail.Body
}
payload, _ := json.Marshal(sendRequest)
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/mail", s.config.Url), bytes.NewBuffer(payload))
if err != nil {
return err
}
req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret)
req.Header.Set("Content-Type", "application/json")
res, err := s.httpClient.Do(req)
if err != nil {
return err
}
if res.StatusCode >= 400 {
return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode))
}
return nil
}

13
services/mail/noop.go Normal file
View File

@ -0,0 +1,13 @@
package mail
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/models"
)
type NoopSendingService struct{}
func (n *NoopSendingService) Send(mail *models.Mail) error {
logbuch.Info("noop mail service doing nothing instead of sending password reset mail to [%v]", mail.To.Strings())
return nil
}

77
services/mail/smtp.go Normal file
View File

@ -0,0 +1,77 @@
package mail
import (
"errors"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"io"
)
type SMTPSendingService struct {
config conf.SMTPMailConfig
auth sasl.Client
}
func NewSMTPSendingService(config conf.SMTPMailConfig) *SMTPSendingService {
return &SMTPSendingService{
config: config,
auth: sasl.NewPlainClient(
"",
config.Username,
config.Password,
),
}
}
func (s *SMTPSendingService) Send(mail *models.Mail) error {
dial := smtp.Dial
if s.config.TLS {
dial = func(addr string) (*smtp.Client, error) {
return smtp.DialTLS(addr, nil)
}
}
c, err := dial(s.config.ConnStr())
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(nil); err != nil {
return err
}
}
if s.auth != nil {
if ok, _ := c.Extension("AUTH"); !ok {
return errors.New("smtp: server doesn't support AUTH")
}
if err = c.Auth(s.auth); err != nil {
return err
}
}
if err = c.Mail(mail.From.Raw(), nil); err != nil {
return err
}
for _, addr := range mail.To.RawStrings() {
if err = c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
_, err = io.Copy(w, mail.Reader())
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return c.Quit()
}

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
}

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