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

Compare commits

..

102 Commits

Author SHA1 Message Date
b2a3579be9 Merge pull request #97 from muety/actions
fix(ci): actions release, build on push/pr
2021-01-24 12:23:05 +01:00
42a6e9d923 fix(ci): actions release, build on push/pr 2021-01-24 22:06:04 +11:00
1f44ccadba docs: update readme with new build instructions 2021-01-24 09:54:54 +01:00
6ea72c6d02 chore: increment patch version number 2021-01-24 09:50:04 +01:00
d93348842a fix: delay defer templateFile.Close() 2021-01-24 10:19:20 +11:00
fb92747129 fix: embed of static, views 2021-01-24 10:13:37 +11:00
4e6e665e19 feat: embed assets into binary
Resolves #26
2021-01-23 10:00:15 +11:00
a3d8c4d464 chore: docs and typos 2021-01-22 00:02:56 +01:00
e9eaa9da53 chore: update version 2021-01-21 23:50:27 +01:00
5adb795f59 chore: include integrity hashes (resolve #93) 2021-01-21 23:50:14 +01:00
a552073d18 feat: ui for configuring wakatime integration 2021-01-21 23:26:50 +01:00
de0401d4bb fix: move caching out of authentication middleware 2021-01-21 23:19:17 +01:00
c39538db13 chore: include machine name in sample data script 2021-01-21 22:17:46 +01:00
189a09d91f feat: relay heartbeats to wakatime (resolve #28) 2021-01-21 22:17:32 +01:00
d57c02af7c feat: add ui for managing aliases (resolve #91) 2021-01-21 00:26:52 +01:00
16b683fcbd fix: permissions bug related to deleting language mappings 2021-01-20 20:49:27 +01:00
acda62488d chore: support for cockroachdb (resolve #90) 2021-01-18 21:37:15 +01:00
1aecfc4ca3 chore: wording 2021-01-17 09:40:14 +01:00
cd97976ed5 chore: show total hours on index page (resolve #88) 2021-01-17 09:32:08 +01:00
3a4504d56a Merge pull request #86 from YC/docker
docker: reduce image layers
2021-01-12 12:27:50 +01:00
a018f70c3f Merge pull request #85 from YC/master
Reduce minimum username length to 1
2021-01-12 12:17:36 +01:00
a03e49e7f0 chore: increment version to 1.18.2 2021-01-12 21:57:15 +11:00
ec81d9fe5d docker: reduce image layers 2021-01-12 21:50:20 +11:00
b7a1e2d795 Reduce minimum username length to 1 2021-01-12 21:06:57 +11:00
98b62b33c8 fix: concurrent access to language mappings (resolve #83) 2021-01-07 10:56:00 +01:00
262bee9022 fix: do not attempt to bind on ipv6 in docker (resolve #84) 2021-01-07 10:55:53 +01:00
9766d8e903 feat: ability to choose number of top entities to display (resolve #81) 2021-01-05 12:41:01 +01:00
39c4777fc8 fix: crash on fail to listen 2021-01-05 11:28:51 +01:00
143c80b7b4 docs: update readme 2021-01-04 11:39:02 +01:00
72e42a9c42 feat: add ipv6 and tls support (resolve #79) 2020-12-12 22:07:00 +01:00
439a87dec9 docs: advanced setup instructions for client-side reverse proxy 2020-12-11 23:05:49 +01:00
e8067bb13e fix: crash when running aggregation job on schedule (fix #78)
chore: move from gocron to its maintained fork
2020-12-11 10:05:17 +01:00
219e969957 Merge branch 'master' of github.com:muety/wakapi into master 2020-12-02 23:16:48 +01:00
e610bb3ee3 fix: html footer rendering
chore: update chartjs
2020-12-02 23:16:12 +01:00
889edd7a33 Merge pull request #77 from notarock/master
Added missing closing parens in language mapping description
2020-12-01 07:48:04 +01:00
4161623c24 Added missing closing parens 2020-11-30 21:42:29 -05:00
67fe6eea56 chore: even more code smell 2020-11-28 20:57:13 +01:00
095fef4868 chore: minor code smell 2020-11-28 20:50:35 +01:00
a0e64ca955 chore: show badges on front page 2020-11-28 20:44:39 +01:00
903defca99 fix: commit missing files
chore: add favicon
2020-11-28 20:31:28 +01:00
16b9aa2282 feat: add front page (resolve #34) 2020-11-28 20:23:40 +01:00
4a78f66778 chore: set samesite attributes and configurable max age for cookies (resolve #75)
fix: sort entities by total time descending (resolve #74)
2020-11-21 22:30:56 +01:00
f4328c452f test: add essential unit tests for core functionality (resolve #6) 2020-11-14 12:30:45 +01:00
e806e5455e chore: attempt to exclude test and mock code from analysis 2020-11-08 13:13:48 +01:00
97e1fb27eb chore: attempt to configure coverage for sonar 2020-11-08 13:07:37 +01:00
ad8168801c test: add first few unit tests 2020-11-08 12:46:12 +01:00
35cdc7b485 refactor: define interface types for all services and repositories 2020-11-08 10:12:49 +01:00
664714de8f fix: filters 2020-11-07 18:39:36 +01:00
7befb82814 chore: remove clean up related parameters 2020-11-07 12:34:17 +01:00
2f12d8efde refactor: simplify summary generation (resolve #68) 2020-11-07 12:01:35 +01:00
8ddd9904a0 refactor: alert handling 2020-11-06 21:19:54 +01:00
78874566a4 chore: introduce constants for db dialects
chore: go fmt
2020-11-06 17:20:26 +01:00
e269b37b0e feat: add ability to regenerate summaries
fix: database cascade settings
chore: debug log mode for gorm queries is back
2020-11-06 17:09:41 +01:00
e6a04cc76d chore: remove cleanup functionality
chore: minor code changes
2020-11-06 14:07:07 +01:00
cb8f68df82 chore: add quick start scripts for spinning up dev database container 2020-11-03 10:32:18 +01:00
b4d2ee7d16 fix: not creating language mappings table due to broken type definition in users model (resolve #69)
chore: introduce foreign key constraints
2020-11-03 10:26:32 +01:00
1224024913 fix: postgres connection (resolve #70) 2020-11-03 10:02:59 +01:00
8efc3854ab refactor: migrate to latest gorm version
refactor: language mappings implementation
2020-11-01 20:14:10 +01:00
755cabb5f4 refactor: introduce repositories as an additional layer of abstraction to allow for better testability 2020-11-01 16:56:36 +01:00
96ff490d8d fix: crash when calling badge endpoint with no filters applied (resolve #66) 2020-11-01 16:04:30 +01:00
68e66298b8 fix: postprocessing for alias keys not corresponding to an actual entity 2020-11-01 13:09:39 +01:00
c2d30826f6 fix: critical fixes related to alias resolution 2020-11-01 12:50:59 +01:00
861c81e414 docs: fix endpoint paths in readme (thanks @mlunax) 2020-10-27 21:06:41 +01:00
892d265c4d chore: update version 2020-10-26 23:10:15 +01:00
e19761337f Merge branch 'notarock/62' 2020-10-26 22:34:50 +01:00
3f973a28ea Fixed custom rule settings CSS 2020-10-26 00:27:07 -04:00
86fc751e58 Cache rules per user, invalidate cache on update 2020-10-25 23:19:16 -04:00
178c417757 Keep the old configuration rules behaviour 2020-10-25 23:02:28 -04:00
395d039d41 General cleanup and fixed PR comments 2020-10-25 22:18:33 -04:00
fdf2289f8e MVP for custom rules support 2020-10-25 21:51:06 -04:00
06b3fdd17c Improved Dockerfile and docker-compose for dev 2020-10-25 02:21:41 -04:00
4506493353 docs: fix badges 2020-10-18 13:42:59 +02:00
11728b80ac docs: update url 2020-10-17 13:37:43 +02:00
b7c7817923 docs: display latest release as badge 2020-10-16 17:07:35 +02:00
c78ee5465c chore: display database type 2020-10-16 16:58:16 +02:00
4336d732c9 docs: update readme 2020-10-16 16:51:11 +02:00
177cbb12fc chore: make aggregation time configurable (resolve #60) 2020-10-16 16:21:19 +02:00
a4c344aaa1 chore: minor code restyling 2020-10-16 16:11:14 +02:00
c575b2fd5c fix: json serialization error when percentage is nan 2020-10-16 15:16:43 +02:00
67a59561c8 fix: use custom date for summary model to support sqlite deserialization 2020-10-16 14:49:22 +02:00
f7520b2b4a fix: timestamp precision 2020-10-16 12:49:36 +02:00
54a944ec41 fix: critical summary computation bug (faulty intervals)
fix: doubly included heartbeats
fix: cross-day heartbeats are ignored for consistency
2020-10-16 12:00:20 +02:00
44b6efb6ee chore: add random seed to generator script 2020-10-16 11:59:08 +02:00
efd4764728 fix: sample data script 2020-10-11 09:55:01 +02:00
dd50b4076f docs: include sonarcloud badges 2020-10-09 21:47:18 +02:00
21b822de42 chore: minor code enhancements 2020-10-09 21:37:20 +02:00
4d22756b8a fix: stop tracking legacy config file 2020-10-04 12:20:15 +02:00
c54f2743fd fix: add legacy config again for backwards-compatibility 2020-10-04 12:16:42 +02:00
a8d5d69629 chore: update build workflow to exclude certain directories 2020-10-04 12:08:58 +02:00
0111aa7543 Merge branch 'master' into stable 2020-10-04 11:54:02 +02:00
3bafde7ab1 fix: adapt docker image to new config format 2020-10-04 11:52:52 +02:00
b378597594 fix: define flags on init
chore: remove deprecated config files
2020-10-04 11:47:31 +02:00
29619f09ed docs: update docs for new config format 2020-10-04 11:35:23 +02:00
ff3fea0359 feat: introduce legacy config migration 2020-10-04 11:14:44 +02:00
660fefcca9 refactor: migrate to new config (resolve #54) 2020-10-04 10:37:38 +02:00
2ecbb3ea02 fix: user agent strign parsing (fix #53) 2020-09-29 18:58:10 +02:00
f843be8d12 refactor: move config to separate package
chore: load config from main method
2020-09-29 18:55:07 +02:00
062a9c6f57 Merge remote-tracking branch 'origin/master' 2020-09-12 16:58:31 +02:00
1c0e63e125 chore: restrict badge access by user agent 2020-09-12 16:58:22 +02:00
45f372168d docs: readme 2020-09-12 16:50:51 +02:00
0760be86ff Merge branch 'master' into stable 2020-09-12 16:40:50 +02:00
a059c637a7 Merge branch 'master' into stable 2020-08-30 01:43:05 +02:00
117 changed files with 5764 additions and 1742 deletions

View File

@ -1 +1,10 @@
.env
.env
config*.yml
!config.default.yml
*.db
*.exe
wakapi
Dockerfile
docker-compose.yml
.dockerignore
.git*

View File

@ -1,8 +0,0 @@
ENV=dev
WAKAPI_DB_TYPE=sqlite3 # mysql, postgres, sqlite3
WAKAPI_DB_NAME=wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
WAKAPI_DB_USER=myuser # ignored when using sqlite
WAKAPI_DB_PASSWORD=shhh # ignored when using sqlite
WAKAPI_DB_HOST=localhost # ignored when using sqlite
WAKAPI_DB_PORT=3306 # ignored when using sqlite
WAKAPI_PASSWORD_SALT=shhh # CHANGE !

View File

@ -1,13 +1,15 @@
name: Build Wakapi on Linux
on:
push:
pull_request:
release:
types:
types:
- created
jobs:
build-and-release:
name: Build and add to Release
name: Build
runs-on: ubuntu-latest
steps:
@ -22,17 +24,21 @@ jobs:
- name: Get dependencies
run: |
go get -v -t -d ./...
go get github.com/markbates/pkger/cmd/pkger
go get
go generate
- name: Build
run: GO111MODULE=on go build -v .
- name: Zip Release
uses: TheDoctor0/zip-release@v0.3.0
with:
filename: release.zip
- name: Zip executable and sample config
if: github.event_name == 'release'
run: |
cp config.default.yml config.yml
zip -9 release.zip wakapi config.yml
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,13 +1,15 @@
name: Build Wakapi on Windows
on:
push:
pull_request:
release:
types:
types:
- created
jobs:
build-and-release:
name: Build and add to release
name: Build
runs-on: windows-latest
steps:
@ -22,18 +24,24 @@ jobs:
- name: Get dependencies
run: |
go get -v -t -d ./...
go get github.com/markbates/pkger/cmd/pkger
go get
go generate
- name: Enable Go 1.11 modules
run: cmd /c "set GO111MODULE=on"
- name: Build
run: go build -v .
- name: Compress working folder
run: Compress-Archive -Path .\* -DestinationPath release.zip
if: github.event_name == 'release'
run: |
cp .\config.default.yml .\config.yml
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath release.zip
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View File

@ -5,4 +5,8 @@ wakapi
.idea
build
*.exe
*.db
*.db
config*.yml
!config.default.yml
config.ini
pkged.go

View File

@ -1,49 +1,41 @@
# Build Stage
FROM golang:1.13 AS build-env
ADD . /src
RUN cd /src && go build -o wakapi
FROM golang:1.15 AS build-env
WORKDIR /src
ADD ./go.mod .
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
# Final Stage
ADD . .
RUN go generate && go build -o wakapi
WORKDIR /app
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
cp /src/wait-for-it.sh .
# Run Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values from .env using `-e` syntax.
# Available options are:
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD
# WAKAPI_DB_HOST
# WAKAPI_DB_PORT
# WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT
# WAKAPI_BASE_PATH
# to override config values using `-e` syntax.
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM debian
WORKDIR /app
ENV ENV prod
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.ini /app/
COPY --from=build-env /src/version.txt /app/
COPY --from=build-env /src/.env.example /app/.env
RUN sed -i 's/listen = 127.0.0.1/listen = 0.0.0.0/g' /app/config.ini
RUN sed -i 's/insecure_cookies = false/insecure_cookies = true/g' /app/config.ini
ADD static /app/static
ADD data /app/data
ADD migrations /app/migrations
ADD views /app/views
ADD wait-for-it.sh .
COPY --from=build-env /app .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh
ENTRYPOINT ./wait-for-it.sh

133
README.md
View File

@ -1,55 +1,103 @@
# 📈 wakapi
[![](http://img.shields.io/liberapay/receives/muety.svg?logo=liberapay&style=flat-square)](https://liberapay.com/muety/)
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
![](https://badges.fw-web.space/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
![GitHub issues](https://img.shields.io/github/issues/muety/wakapi)
![GitHub last commit](https://img.shields.io/github/last-commit/muety/wakapi)
[![Say thanks](https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg)](https://saythanks.io/to/n1try)
[![](https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay)](https://liberapay.com/muety/)
![GitHub go.mod Go version](https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc)](https://sonarcloud.io/dashboard?id=muety_wakapi)
---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
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!
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you like, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
## Demo
🔥 **New:** There is hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
## 👀 Demo
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day
## Prerequisites
## ⚙️ Prerequisites
**On the server side:**
* Go >= 1.13 (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`
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
* _Optional_: A MySQL- or Postgres database
* _Optional_: One of the [supported databases](#supported-databases)
**On your local machine:**
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
## Server Setup
## ⌨️ Server Setup
### Run from source
1. Clone the project
1. Copy `.env.example` to `.env` and set database credentials
1. Adapt `config.ini` to your needs
1. Build executable: `GO111MODULE=on go build`
1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
1. Install packaging tool: `GO111MODULE=on go get github.com/markbates/pkger/cmd/pkger`
1. Build executable: `GO111MODULE=on go generate && go build -o wakapi`
1. Run server: `./wakapi`
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` in `.env` 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](i#best-practices)) or set `insecure_cookies = true` in `config.ini`.
**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` to `true` in `config.yml`.
### Run with Docker
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
```bash
docker run -d -p 3000:3000 -e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" --name wakapi n1try/wakapi
```
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 [.env.example](https://github.com/muety/wakapi/blob/master/.env.example) for further options.
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.
## Client Setup
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
## 🔧 Configuration
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.
| YAML Key | Environment Variable | Default | Description |
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.custom_languages` | - | - | Map from file endings to language names |
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `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 |
| `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.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) |
### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
* [SQLite](https://sqlite.org/) (_default, easy setup_)
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
## 💻 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.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
@ -62,35 +110,23 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server`
You can view your API Key after logging in to the web interface.
## Customization
### Optional: Client-side proxy
See the [advanced setup instructions](docs/advanced_setup.md).
### Aliases
There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects `myapp-frontend` and `myapp-backend` two a common project name `myapp-web` in your statistics, you can add project aliases.
### Optional: WakaTime relay
You can connect Wakapi with WakaTime in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. Go to the settings page of your instance to configure this integration.
At the moment, this can only be done via raw database queries. For the above example, you would need to add two aliases, like this:
```sql
INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_username', 'myapp-web', 'myapp-frontend');
```
#### Types
* Project ~ type **0**
* Language ~ type **1**
* Editor ~ type **2**
* OS ~ type **3**
* Machine ~ type **4**
## API Endpoints
## 🔧 API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
* `POST /api/heartbeat`
* `GET /api/summary`
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/compat/wakatime/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health`
## Prometheus Export
## ⤴️ 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)**.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
@ -99,15 +135,18 @@ It is a standalone webserver that connects to your Wakapi instance and exposes t
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.
## Badges
## 🏷 Badges
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
## Best Practices
## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `listen = 0.0.0.0` in `config.ini`
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`
## Important Note
## 🙏 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!
## ⚠️ Important Note
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
## License
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

31
config.default.yml Normal file
View File

@ -0,0 +1,31 @@
env: development
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
port: 3000
base_path: /
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
custom_languages:
vue: Vue
jsx: JSX
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)
security:
password_salt: # CHANGE !
insecure_cookies: false
cookie_max_age: 172800

View File

@ -1,15 +0,0 @@
[server]
listen = 127.0.0.1
port = 3000
base_path = /
insecure_cookies = false
[app]
cleanup = false
[database]
max_connections = 2
[languages]
vue = Vue
jsx = JSX

310
config/config.go Normal file
View File

@ -0,0 +1,310 @@
package config
import (
"encoding/json"
"flag"
"fmt"
"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"
"log"
"net/http"
"os"
"strings"
)
const (
defaultConfigPath = "config.yml"
defaultConfigPathLegacy = "config.ini"
defaultEnvConfigPathLegacy = ".env"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3"
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
)
var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location")
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"`
CustomLanguages map[string]string `yaml:"custom_languages"`
LanguageColors map[string]string `yaml:"-"`
}
type securityConfig struct {
// 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"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
}
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"`
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
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"`
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 Config struct {
Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"`
App appConfig
Security securityConfig
Db dbConfig
Server serverConfig
}
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
}
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
return c.createCookie(name, "", path, -1)
}
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: path,
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
}
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{})
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
}
log.Printf("applied %d fixtures\n", 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)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.LanguageColors)
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func readVersion() string {
file, err := pkger.Open("/version.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
return strings.TrimSpace(string(bytes))
}
func readLanguageColors() map[string]string {
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
file, err := pkger.Open("/data/colors.json")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(bytes, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
return colors
}
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
}
return *cFlag
}
func resolveDbDialect(dbType string) string {
if dbType == "cockroach" {
return "postgres"
}
return dbType
}
func Set(config *Config) {
cfg = config
}
func Get() *Config {
return cfg
}
func Load() *Config {
config := &Config{}
flag.Parse()
maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
log.Fatalf("failed to read config: %v\n", err)
}
config.Version = readVersion()
config.App.LanguageColors = readLanguageColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
}
for k, v := range config.App.CustomLanguages {
if v == "" {
config.App.CustomLanguages[k] = "unknown"
}
}
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
log.Fatalln("either of listen_ipv4 or listen_ipv6 must be set")
}
Set(config)
return Get()
}

66
config/config_test.go Normal file
View File

@ -0,0 +1,66 @@
package config
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
func TestConfig_IsDev(t *testing.T) {
assert.True(t, IsDev("dev"))
assert.True(t, IsDev("development"))
assert.False(t, IsDev("prod"))
assert.False(t, IsDev("production"))
assert.False(t, IsDev("anything else"))
}
func Test_mysqlConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "mysql",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Host,
c.Port,
c.Name,
"Local",
), mysqlConnectionString(c))
}
func Test_postgresConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "postgres",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
c.Host,
c.Port,
c.User,
c.Name,
c.Password,
), postgresConnectionString(c))
}
func Test_sqliteConnectionString(t *testing.T) {
c := &dbConfig{
Name: "test_name",
Dialect: "sqlite3",
}
assert.Equal(t, c.Name, sqliteConnectionString(c))
}

124
config/legacy.go Normal file
View File

@ -0,0 +1,124 @@
package config
import (
"github.com/joho/godotenv"
"gopkg.in/ini.v1"
"gopkg.in/yaml.v2"
"io/ioutil"
"log"
"os"
"strconv"
)
func maybeMigrateLegacyConfig() {
if yes, err := shouldMigrateLegacyConfig(); err != nil {
log.Fatalf("failed to determine whether to migrate legacy config: %v\n", err)
} else if yes {
log.Printf("migrating legacy config (%s, %s) to new format (%s); see https://github.com/muety/wakapi/issues/54\n", defaultConfigPathLegacy, defaultEnvConfigPathLegacy, defaultConfigPath)
if err := migrateLegacyConfig(); err != nil {
log.Fatalf("failed to migrate legacy config: %v\n", err)
}
log.Printf("config migration successful; please delete %s and %s now\n", defaultConfigPathLegacy, defaultEnvConfigPathLegacy)
}
}
func shouldMigrateLegacyConfig() (bool, error) {
if _, err := os.Stat(defaultConfigPath); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return true, err
}
return true, nil
}
func migrateLegacyConfig() error {
// step 1: read envVars file parameters
envFile, err := os.Open(defaultEnvConfigPathLegacy)
if err != nil {
return err
}
envVars, err := godotenv.Parse(envFile)
if err != nil {
return err
}
env := envVars["ENV"]
dbType := envVars["WAKAPI_DB_TYPE"]
dbUser := envVars["WAKAPI_DB_USER"]
dbPassword := envVars["WAKAPI_DB_PASSWORD"]
dbHost := envVars["WAKAPI_DB_HOST"]
dbName := envVars["WAKAPI_DB_NAME"]
dbPortStr := envVars["WAKAPI_DB_PORT"]
passwordSalt := envVars["WAKAPI_PASSWORD_SALT"]
dbPort, _ := strconv.Atoi(dbPortStr)
// step 2: read ini file
cfg, err := ini.Load(defaultConfigPathLegacy)
if err != nil {
return err
}
if dbType == "" {
dbType = SQLDialectSqlite
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
insecureCookies := cfg.Section("server").Key("insecure_cookies").MustBool(false)
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// step 3: instantiate config
config := &Config{
Env: env,
App: appConfig{
CustomLanguages: customLangs,
},
Security: securityConfig{
PasswordSalt: passwordSalt,
InsecureCookies: insecureCookies,
},
Db: dbConfig{
Host: dbHost,
Port: uint(dbPort),
User: dbUser,
Password: dbPassword,
Name: dbName,
Dialect: dbType,
MaxConn: dbMaxConn,
},
Server: serverConfig{
Port: port,
ListenIpV4: addr,
BasePath: basePath,
},
}
// step 4: serialize to yaml
yamlConfig, err := yaml.Marshal(config)
if err != nil {
return err
}
// step 5: write file
if err := ioutil.WriteFile(defaultConfigPath, yamlConfig, 0600); err != nil {
return err
}
return nil
}

10
config/templates.go Normal file
View File

@ -0,0 +1,10 @@
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"
)

9
config/utils.go Normal file
View File

@ -0,0 +1,9 @@
package config
func cloneStringMap(m map[string]string) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
m2[k] = v
}
return m2
}

579
coverage/coverage.out Normal file
View File

@ -0,0 +1,579 @@
mode: set
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
github.com/muety/wakapi/models/filters.go:32.49,33.21 1 0
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 0
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 0
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 0
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 0
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 0
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 0
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 0
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
github.com/muety/wakapi/models/filters.go:47.42,50.21 2 1
github.com/muety/wakapi/models/filters.go:53.2,53.20 1 1
github.com/muety/wakapi/models/filters.go:56.2,56.22 1 1
github.com/muety/wakapi/models/filters.go:59.2,59.21 1 1
github.com/muety/wakapi/models/filters.go:62.2,62.16 1 1
github.com/muety/wakapi/models/filters.go:66.2,66.12 1 1
github.com/muety/wakapi/models/filters.go:50.21,52.3 1 1
github.com/muety/wakapi/models/filters.go:53.20,55.3 1 0
github.com/muety/wakapi/models/filters.go:56.22,58.3 1 1
github.com/muety/wakapi/models/filters.go:59.21,61.3 1 0
github.com/muety/wakapi/models/filters.go:62.16,64.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/summary.go:29.27,33.2 1 0
github.com/muety/wakapi/models/summary.go:83.29,85.2 1 1
github.com/muety/wakapi/models/summary.go:87.37,94.2 6 1
github.com/muety/wakapi/models/summary.go:96.35,98.2 1 1
github.com/muety/wakapi/models/summary.go:100.57,108.2 1 1
github.com/muety/wakapi/models/summary.go:121.33,126.26 4 1
github.com/muety/wakapi/models/summary.go:133.2,133.37 1 1
github.com/muety/wakapi/models/summary.go:137.2,140.33 2 1
github.com/muety/wakapi/models/summary.go:126.26,127.30 1 1
github.com/muety/wakapi/models/summary.go:127.30,129.4 1 1
github.com/muety/wakapi/models/summary.go:133.37,135.3 1 0
github.com/muety/wakapi/models/summary.go:140.33,146.3 1 1
github.com/muety/wakapi/models/summary.go:149.45,154.30 3 1
github.com/muety/wakapi/models/summary.go:163.2,163.30 1 1
github.com/muety/wakapi/models/summary.go:154.30,155.47 1 1
github.com/muety/wakapi/models/summary.go:155.47,156.32 1 1
github.com/muety/wakapi/models/summary.go:159.4,159.9 1 1
github.com/muety/wakapi/models/summary.go:156.32,158.5 1 1
github.com/muety/wakapi/models/summary.go:166.73,168.55 2 1
github.com/muety/wakapi/models/summary.go:173.2,173.16 1 1
github.com/muety/wakapi/models/summary.go:168.55,169.31 1 1
github.com/muety/wakapi/models/summary.go:169.31,171.4 1 1
github.com/muety/wakapi/models/summary.go:176.88,178.55 2 1
github.com/muety/wakapi/models/summary.go:186.2,186.16 1 1
github.com/muety/wakapi/models/summary.go:178.55,179.31 1 1
github.com/muety/wakapi/models/summary.go:179.31,180.23 1 1
github.com/muety/wakapi/models/summary.go:183.4,183.46 1 1
github.com/muety/wakapi/models/summary.go:180.23,181.13 1 1
github.com/muety/wakapi/models/summary.go:189.79,190.33 1 1
github.com/muety/wakapi/models/summary.go:193.2,193.16 1 1
github.com/muety/wakapi/models/summary.go:190.33,192.3 1 1
github.com/muety/wakapi/models/summary.go:196.71,197.63 1 1
github.com/muety/wakapi/models/summary.go:237.2,243.10 6 1
github.com/muety/wakapi/models/summary.go:197.63,200.45 2 1
github.com/muety/wakapi/models/summary.go:209.3,209.31 1 1
github.com/muety/wakapi/models/summary.go:216.3,216.31 1 1
github.com/muety/wakapi/models/summary.go:233.3,233.16 1 1
github.com/muety/wakapi/models/summary.go:200.45,201.32 1 1
github.com/muety/wakapi/models/summary.go:206.4,206.14 1 1
github.com/muety/wakapi/models/summary.go:201.32,202.24 1 1
github.com/muety/wakapi/models/summary.go:202.24,204.6 1 1
github.com/muety/wakapi/models/summary.go:209.31,211.60 1 1
github.com/muety/wakapi/models/summary.go:211.60,213.5 1 1
github.com/muety/wakapi/models/summary.go:216.31,218.60 1 1
github.com/muety/wakapi/models/summary.go:218.60,219.55 1 1
github.com/muety/wakapi/models/summary.go:219.55,221.6 1 1
github.com/muety/wakapi/models/summary.go:221.11,229.6 1 1
github.com/muety/wakapi/models/summary.go:246.33,248.2 1 1
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 1
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 1
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/heartbeat.go:26.34,28.2 1 1
github.com/muety/wakapi/models/heartbeat.go:30.65,31.28 1 1
github.com/muety/wakapi/models/heartbeat.go:34.2,35.45 2 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.44 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,42.42 1 1
github.com/muety/wakapi/models/heartbeat.go:31.28,33.3 1 1
github.com/muety/wakapi/models/heartbeat.go:35.45,37.3 1 0
github.com/muety/wakapi/models/heartbeat.go:39.44,41.3 1 0
github.com/muety/wakapi/models/heartbeat.go:45.50,46.11 1 1
github.com/muety/wakapi/models/heartbeat.go:59.2,59.15 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.12 1 1
github.com/muety/wakapi/models/heartbeat.go:47.22,48.18 1 1
github.com/muety/wakapi/models/heartbeat.go:49.21,50.17 1 1
github.com/muety/wakapi/models/heartbeat.go:51.23,52.19 1 1
github.com/muety/wakapi/models/heartbeat.go:53.17,54.26 1 1
github.com/muety/wakapi/models/heartbeat.go:55.22,56.18 1 1
github.com/muety/wakapi/models/heartbeat.go:59.15,61.3 1 1
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0
github.com/muety/wakapi/utils/auth.go:18.79,20.54 2 0
github.com/muety/wakapi/utils/auth.go:24.2,26.16 3 0
github.com/muety/wakapi/utils/auth.go:30.2,32.45 3 0
github.com/muety/wakapi/utils/auth.go:35.2,36.32 2 0
github.com/muety/wakapi/utils/auth.go:20.54,22.3 1 0
github.com/muety/wakapi/utils/auth.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/auth.go:32.45,34.3 1 0
github.com/muety/wakapi/utils/auth.go:39.65,41.54 2 0
github.com/muety/wakapi/utils/auth.go:45.2,46.30 2 0
github.com/muety/wakapi/utils/auth.go:41.54,43.3 1 0
github.com/muety/wakapi/utils/auth.go:49.97,51.16 2 0
github.com/muety/wakapi/utils/auth.go:55.2,55.104 1 0
github.com/muety/wakapi/utils/auth.go:59.2,59.19 1 0
github.com/muety/wakapi/utils/auth.go:51.16,53.3 1 0
github.com/muety/wakapi/utils/auth.go:55.104,57.3 1 0
github.com/muety/wakapi/utils/auth.go:62.30,64.2 1 0
github.com/muety/wakapi/utils/auth.go:66.56,70.2 3 0
github.com/muety/wakapi/utils/auth.go:73.53,75.2 1 0
github.com/muety/wakapi/utils/auth.go:77.55,80.16 3 0
github.com/muety/wakapi/utils/auth.go:83.2,83.16 1 0
github.com/muety/wakapi/utils/auth.go:80.16,82.3 1 0
github.com/muety/wakapi/utils/auth.go:86.43,91.2 4 0
github.com/muety/wakapi/utils/color.go:8.93,10.41 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.41,11.50 1 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.24,23.2 1 0
github.com/muety/wakapi/utils/common.go:25.56,28.45 3 1
github.com/muety/wakapi/utils/common.go:31.2,31.40 1 1
github.com/muety/wakapi/utils/common.go:28.45,30.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
github.com/muety/wakapi/utils/summary.go:37.2,37.22 1 0
github.com/muety/wakapi/utils/summary.go:14.28,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.32,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.31,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.32,22.24 1 0
github.com/muety/wakapi/utils/summary.go:23.31,24.23 1 0
github.com/muety/wakapi/utils/summary.go:25.32,26.42 1 0
github.com/muety/wakapi/utils/summary.go:27.33,28.43 1 0
github.com/muety/wakapi/utils/summary.go:29.35,30.43 1 0
github.com/muety/wakapi/utils/summary.go:31.26,32.21 1 0
github.com/muety/wakapi/utils/summary.go:33.10,34.39 1 0
github.com/muety/wakapi/utils/summary.go:40.73,47.56 5 0
github.com/muety/wakapi/utils/summary.go:61.2,68.8 2 0
github.com/muety/wakapi/utils/summary.go:47.56,49.3 1 0
github.com/muety/wakapi/utils/summary.go:49.8,51.17 2 0
github.com/muety/wakapi/utils/summary.go:55.3,56.17 2 0
github.com/muety/wakapi/utils/summary.go:51.17,53.4 1 0
github.com/muety/wakapi/utils/summary.go:56.17,58.4 1 0
github.com/muety/wakapi/config/config.go:83.70,85.2 1 0
github.com/muety/wakapi/config/config.go:87.65,89.2 1 0
github.com/muety/wakapi/config/config.go:91.82,101.2 1 0
github.com/muety/wakapi/config/config.go:103.31,105.2 1 0
github.com/muety/wakapi/config/config.go:107.32,109.2 1 0
github.com/muety/wakapi/config/config.go:111.74,112.19 1 0
github.com/muety/wakapi/config/config.go:113.10,114.34 1 0
github.com/muety/wakapi/config/config.go:114.34,123.4 8 0
github.com/muety/wakapi/config/config.go:127.73,128.33 1 0
github.com/muety/wakapi/config/config.go:128.33,136.17 5 0
github.com/muety/wakapi/config/config.go:140.3,141.13 2 0
github.com/muety/wakapi/config/config.go:136.17,138.4 1 0
github.com/muety/wakapi/config/config.go:145.50,146.19 1 0
github.com/muety/wakapi/config/config.go:159.2,159.12 1 0
github.com/muety/wakapi/config/config.go:147.23,151.5 1 0
github.com/muety/wakapi/config/config.go:152.26,155.5 1 0
github.com/muety/wakapi/config/config.go:156.24,157.48 1 0
github.com/muety/wakapi/config/config.go:162.53,172.2 1 1
github.com/muety/wakapi/config/config.go:174.56,176.16 2 1
github.com/muety/wakapi/config/config.go:180.2,187.3 1 1
github.com/muety/wakapi/config/config.go:176.16,178.3 1 0
github.com/muety/wakapi/config/config.go:190.54,192.2 1 1
github.com/muety/wakapi/config/config.go:194.60,196.2 1 0
github.com/muety/wakapi/config/config.go:198.59,200.2 1 0
github.com/muety/wakapi/config/config.go:202.29,204.2 1 1
github.com/muety/wakapi/config/config.go:206.27,208.16 2 0
github.com/muety/wakapi/config/config.go:211.2,214.16 3 0
github.com/muety/wakapi/config/config.go:218.2,218.22 1 0
github.com/muety/wakapi/config/config.go:208.16,210.3 1 0
github.com/muety/wakapi/config/config.go:214.16,216.3 1 0
github.com/muety/wakapi/config/config.go:221.45,231.16 4 0
github.com/muety/wakapi/config/config.go:235.2,235.57 1 0
github.com/muety/wakapi/config/config.go:239.2,239.30 1 0
github.com/muety/wakapi/config/config.go:243.2,243.15 1 0
github.com/muety/wakapi/config/config.go:231.16,233.3 1 0
github.com/muety/wakapi/config/config.go:235.57,237.3 1 0
github.com/muety/wakapi/config/config.go:239.30,241.3 1 0
github.com/muety/wakapi/config/config.go:246.38,247.43 1 0
github.com/muety/wakapi/config/config.go:251.2,251.15 1 0
github.com/muety/wakapi/config/config.go:247.43,249.3 1 0
github.com/muety/wakapi/config/config.go:254.45,255.27 1 0
github.com/muety/wakapi/config/config.go:258.2,258.15 1 0
github.com/muety/wakapi/config/config.go:255.27,257.3 1 0
github.com/muety/wakapi/config/config.go:261.26,263.2 1 0
github.com/muety/wakapi/config/config.go:265.20,267.2 1 0
github.com/muety/wakapi/config/config.go:269.21,276.96 4 0
github.com/muety/wakapi/config/config.go:280.2,288.52 5 0
github.com/muety/wakapi/config/config.go:292.2,292.47 1 0
github.com/muety/wakapi/config/config.go:298.2,298.70 1 0
github.com/muety/wakapi/config/config.go:302.2,303.14 2 0
github.com/muety/wakapi/config/config.go:276.96,278.3 1 0
github.com/muety/wakapi/config/config.go:288.52,290.3 1 0
github.com/muety/wakapi/config/config.go:292.47,293.14 1 0
github.com/muety/wakapi/config/config.go:293.14,295.4 1 0
github.com/muety/wakapi/config/config.go:298.70,300.3 1 0
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
github.com/muety/wakapi/config/utils.go:3.60,5.22 2 0
github.com/muety/wakapi/config/utils.go:8.2,8.11 1 0
github.com/muety/wakapi/config/utils.go:5.22,7.3 1 0
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
github.com/muety/wakapi/services/misc.go:81.24,82.144 1 0
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
github.com/muety/wakapi/services/misc.go:82.144,84.4 1 0
github.com/muety/wakapi/services/misc.go:84.9,90.4 2 0
github.com/muety/wakapi/services/misc.go:91.48,94.4 2 0
github.com/muety/wakapi/services/misc.go:98.86,101.30 3 0
github.com/muety/wakapi/services/misc.go:106.2,109.17 1 0
github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
github.com/muety/wakapi/services/summary.go:53.2,53.65 1 1
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
github.com/muety/wakapi/services/summary.go:53.65,55.3 1 0
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:16.73,21.2 1 0
github.com/muety/wakapi/services/user.go:23.74,25.2 1 0
github.com/muety/wakapi/services/user.go:27.72,29.2 1 0
github.com/muety/wakapi/services/user.go:31.58,33.2 1 0
github.com/muety/wakapi/services/user.go:35.88,42.93 2 0
github.com/muety/wakapi/services/user.go:48.2,48.38 1 0
github.com/muety/wakapi/services/user.go:42.93,44.3 1 0
github.com/muety/wakapi/services/user.go:44.8,46.3 1 0
github.com/muety/wakapi/services/user.go:51.73,53.2 1 0
github.com/muety/wakapi/services/user.go:55.78,58.2 2 0
github.com/muety/wakapi/services/user.go:60.79,62.2 1 0
github.com/muety/wakapi/services/user.go:64.106,66.96 2 0
github.com/muety/wakapi/services/user.go:71.2,71.68 1 0
github.com/muety/wakapi/services/user.go:66.96,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/alias.go:17.77,22.2 1 1
github.com/muety/wakapi/services/alias.go:26.60,27.43 1 1
github.com/muety/wakapi/services/alias.go:30.2,30.14 1 1
github.com/muety/wakapi/services/alias.go:27.43,29.3 1 1
github.com/muety/wakapi/services/alias.go:33.62,35.16 2 1
github.com/muety/wakapi/services/alias.go:38.2,38.12 1 1
github.com/muety/wakapi/services/alias.go:35.16,37.3 1 1
github.com/muety/wakapi/services/alias.go:41.76,43.16 2 0
github.com/muety/wakapi/services/alias.go:46.2,46.21 1 0
github.com/muety/wakapi/services/alias.go:43.16,45.3 1 0
github.com/muety/wakapi/services/alias.go:49.113,51.16 2 0
github.com/muety/wakapi/services/alias.go:54.2,54.21 1 0
github.com/muety/wakapi/services/alias.go:51.16,53.3 1 0
github.com/muety/wakapi/services/alias.go:57.108,58.32 1 1
github.com/muety/wakapi/services/alias.go:64.2,65.46 2 1
github.com/muety/wakapi/services/alias.go:70.2,70.19 1 1
github.com/muety/wakapi/services/alias.go:58.32,59.52 1 1
github.com/muety/wakapi/services/alias.go:59.52,61.4 1 1
github.com/muety/wakapi/services/alias.go:65.46,66.48 1 1
github.com/muety/wakapi/services/alias.go:66.48,68.4 1 1
github.com/muety/wakapi/services/alias.go:73.77,75.16 2 0
github.com/muety/wakapi/services/alias.go:78.2,79.20 2 0
github.com/muety/wakapi/services/alias.go:75.16,77.3 1 0
github.com/muety/wakapi/services/alias.go:82.60,83.24 1 0
github.com/muety/wakapi/services/alias.go:86.2,88.12 3 0
github.com/muety/wakapi/services/alias.go:83.24,85.3 1 0
github.com/muety/wakapi/services/alias.go:91.69,94.28 3 0
github.com/muety/wakapi/services/alias.go:102.2,104.31 2 0
github.com/muety/wakapi/services/alias.go:108.2,108.12 1 0
github.com/muety/wakapi/services/alias.go:94.28,95.21 1 0
github.com/muety/wakapi/services/alias.go:98.3,99.16 2 0
github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0
github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0
github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0
github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: '3.7'
services:
wakapi:
build: .
ports:
- 3000:3000
restart: always
environment:
WAKAPI_DB_TYPE: "postgres"
WAKAPI_DB_NAME: "wakapi"
WAKAPI_DB_USER: "wakapi"
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
WAKAPI_DB_HOST: "db"
WAKAPI_DB_PORT: "5432"
ENVIRONMENT: "prod"
db:
image: postgres:12.3
environment:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "CHANGE_ME!!!"
POSTGRES_DB: "wakapi"

29
docs/advanced_setup.md Normal file
View File

@ -0,0 +1,29 @@
# Advanced Setup
## 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.
In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up webserver / reverse proxy.
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

20
go.mod
View File

@ -3,18 +3,28 @@ module github.com/muety/wakapi
go 1.13
require (
github.com/go-co-op/gocron v0.3.3
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/gorm v1.9.11
github.com/jinzhu/configor v1.2.0
github.com/joho/godotenv v1.3.0
github.com/kr/pretty v0.2.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/markbates/pkger v0.17.1
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
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/t-tiger/gorm-bulk-insert v1.3.0
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
github.com/stretchr/testify v1.6.1
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0
gopkg.in/yaml.v2 v2.2.8
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
)

193
go.sum
View File

@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
@ -31,21 +31,24 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/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/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=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/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-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 h1:epsH3lb7KVbXHYk7LYGN5EiE0MxcevHU85CKITJ0wUY=
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@ -56,13 +59,13 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
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/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
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/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/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=
@ -71,12 +74,15 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
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 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
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-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=
@ -84,17 +90,17 @@ github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b
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/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 h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
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=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -106,11 +112,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
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=
@ -152,14 +155,64 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
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/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
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=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.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/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=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
github.com/jackc/pgproto3/v2 v2.0.5/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=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.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/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/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/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.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/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=
@ -167,7 +220,6 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
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=
@ -178,29 +230,40 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
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/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/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=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.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.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
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 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@ -223,6 +286,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/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=
@ -252,6 +317,7 @@ github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9
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/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=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -280,6 +346,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
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/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@ -289,8 +358,12 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
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/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=
@ -312,20 +385,21 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20=
github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY=
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/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/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/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=
@ -334,10 +408,15 @@ go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.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/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -345,17 +424,22 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
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-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/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/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=
@ -375,6 +459,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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=
@ -394,19 +479,24 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/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 h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/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-20181108054448-85acf8d2951c/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=
@ -416,24 +506,28 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
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-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-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
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-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
@ -448,14 +542,16 @@ google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/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/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
@ -464,11 +560,24 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
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.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
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-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=

244
main.go
View File

@ -1,101 +1,125 @@
package main
//go:generate $GOPATH/bin/pkger
import (
"fmt"
"github.com/gorilla/handlers"
"github.com/markbates/pkger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"github.com/muety/wakapi/repositories"
"log"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
_ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var (
db *gorm.DB
config *models.Config
config *conf.Config
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
aliasRepository repositories.IAliasRepository
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
var (
aliasService services.IAliasService
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
summaryService services.ISummaryService
aggregationService services.IAggregationService
keyValueService services.IKeyValueService
miscService services.IMiscService
)
// TODO: Refactor entire project to be structured after business domains
func main() {
config = models.GetConfig()
config = conf.Load()
// Enable line numbers in logging
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Show data loss warning
if config.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
}
// Connect to database
var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
if config.DbDialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;")
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
}
db.LogMode(config.IsDev())
db.DB().SetMaxIdleConns(int(config.DbMaxConn))
db.DB().SetMaxOpenConns(int(config.DbMaxConn))
if config.IsDev() {
db = db.Debug()
}
sqlDb, _ := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
log.Println(err)
log.Fatal("could not connect to database")
}
// TODO: Graceful shutdown
defer db.Close()
defer sqlDb.Close()
// Migrate database schema
common.RunCustomPreMigrations(db, config)
runDatabaseMigrations()
applyFixtures()
common.RunCustomPostMigrations(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
// Services
aliasService = services.NewAliasService(db)
heartbeatService = services.NewHeartbeatService(db)
userService = services.NewUserService(db)
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(db)
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Custom migrations and initial data
migrateLanguages()
// Aggregate heartbeats to summaries and persist them
// Schedule background tasks
go aggregationService.Schedule()
if config.CleanUp {
go heartbeatService.ScheduleCleanUp()
}
go miscService.ScheduleCountTotalTime()
// TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
@ -118,6 +142,7 @@ func main() {
userService,
[]string{"/api/health", "/api/compat/shields/v1"},
).Handler
wakatimeRelayMiddleware := customMiddleware.NewWakatimeRelayMiddleware().Handler
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
@ -126,12 +151,13 @@ func main() {
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
@ -139,14 +165,23 @@ func main() {
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/aliases").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostAlias)
settingsRouter.Path("/aliases/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteAlias)
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
settingsRouter.Path("/wakatime_integration").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostSetWakatimeApiKey)
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
heartbeatsApiRouter := apiRouter.Path("/heartbeat").Methods(http.MethodPost).Subrouter()
heartbeatsApiRouter.Use(wakatimeRelayMiddleware)
heartbeatsApiRouter.Path("").HandlerFunc(heartbeatHandler.ApiPost)
// Wakatime compat V1 API Routes
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
@ -155,51 +190,78 @@ func main() {
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
// Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
router.PathPrefix("/assets").Handler(http.FileServer(pkger.Dir("/static")))
// Listen HTTP
portString := config.Addr + ":" + strconv.Itoa(config.Port)
s := &http.Server{
Handler: router,
Addr: portString,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
listen(router)
}
func listen(handler http.Handler) {
var s4, s6 *http.Server
// IPv4
if config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{
Handler: handler,
Addr: bindString4,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
log.Printf("Listening on %+s\n", portString)
s.ListenAndServe()
// IPv6
if config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{
Handler: handler,
Addr: bindString6,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
if config.UseTLS() {
if s4 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
} else {
if s4 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
}
<-make(chan interface{}, 1)
}
func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.DbDialect)(db); err != nil {
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
log.Fatal(err)
}
}
func applyFixtures() {
if err := config.GetFixturesFunc(config.DbDialect)(db); err != nil {
log.Fatal(err)
}
}
func migrateLanguages() {
for k, v := range config.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
Updates(models.Heartbeat{Language: v})
if result.Error != nil {
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
}
}
}
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
}
}

View File

@ -4,30 +4,25 @@ import (
"context"
"errors"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"strings"
"time"
"github.com/patrickmn/go-cache"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
)
type AuthenticateMiddleware struct {
config *models.Config
userSrvc *services.UserService
cache *cache.Cache
config *conf.Config
userSrvc services.IUserService
whitelistPaths []string
}
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: models.GetConfig(),
config: conf.Get(),
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
}
}
@ -57,14 +52,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized)
} else {
utils.ClearCookie(w, models.AuthCookieKey, !m.config.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound)
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
}
return
}
m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx))
}
@ -77,14 +70,9 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
var user *models.User
userKey := strings.TrimSpace(key)
cachedUser, ok := m.cache.Get(userKey)
if !ok {
user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, err
}
} else {
user = cachedUser.(*models.User)
user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, err
}
return user, nil
}
@ -95,18 +83,12 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return nil, err
}
cachedUser, ok := m.cache.Get(login.Username)
if ok {
return cachedUser.(*models.User), nil
}
user, err := m.userSrvc.GetUserById(login.Username)
if err != nil {
return nil, err
}
if !CheckAndMigratePassword(user, login, m.config.PasswordSalt, m.userSrvc) {
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, &m.userSrvc) {
return nil, errors.New("invalid password")
}
@ -114,14 +96,14 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
}
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.IUserService) bool {
if utils.IsMd5(user.Password) {
if utils.CheckPasswordMd5(user, login.Password) {
if utils.CompareMd5(user.Password, login.Password, "") {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login)
(*userServiceRef).MigrateMd5Password(user, login)
return true
}
return false
}
return utils.CheckPasswordBcrypt(user, login.Password, salt)
return utils.CompareBcrypt(user.Password, login.Password, salt)
}

View File

@ -0,0 +1,56 @@
package middlewares
import (
"encoding/base64"
"fmt"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
mockRequest := &http.Request{
Header: http.Header{
// 'Basic' prefix missing here
"Authorization": []string{fmt.Sprintf("%s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
// TODO: somehow test cookie auth function

View File

@ -0,0 +1,97 @@
package relay
import (
"bytes"
"encoding/base64"
"fmt"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"io"
"io/ioutil"
"log"
"net/http"
"time"
)
const (
WakatimeApiUrl = "https://wakatime.com/api/v1"
WakatimeApiHeartbeatsEndpoint = "/users/current/heartbeats.bulk"
)
/* Middleware to conditionally relay heartbeats to Wakatime */
type WakatimeRelayMiddleware struct {
httpClient *http.Client
}
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
return &WakatimeRelayMiddleware{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
})
}
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
defer next(w, r)
if r.Method != http.MethodPost {
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil || user.WakatimeApiKey == "" {
return
}
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
headers := http.Header{
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
"Content-Type": r.Header.Values("Content-Type"),
"Accept": r.Header.Values("Accept"),
"User-Agent": r.Header.Values("User-Agent"),
"X-Origin": []string{
fmt.Sprintf("wakapi v%s", config.Get().Version),
},
"Authorization": []string{
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
},
}
go m.send(
http.MethodPost,
WakatimeApiUrl+WakatimeApiHeartbeatsEndpoint,
bytes.NewReader(body),
headers,
)
}
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header) {
request, err := http.NewRequest(method, url, body)
if err != nil {
log.Printf("error constructing relayed request %v\n", err)
}
for k, v := range headers {
for _, h := range v {
request.Header.Set(k, h)
}
}
response, err := m.httpClient.Do(request)
if err != nil {
log.Printf("error executing relayed request %v\n", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
log.Printf("failed to relay request, got status %d\n", response.StatusCode)
}
}

View File

@ -0,0 +1,11 @@
package common
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
type migrationFunc struct {
f func(db *gorm.DB, cfg *config.Config) error
name string
}

View File

@ -0,0 +1,30 @@
package common
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"log"
)
var customPostMigrations []migrationFunc
func init() {
customPostMigrations = []migrationFunc{
{
f: func(db *gorm.DB, cfg *config.Config) error {
return cfg.GetFixturesFunc(cfg.Db.Dialect)(db)
},
name: "apply fixtures",
},
// TODO: add function to modify aggregated summaries according to configured custom language mappings
}
}
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPostMigrations {
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}
}
}

View File

@ -0,0 +1,108 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
var customPreMigrations []migrationFunc
func init() {
customPreMigrations = []migrationFunc{
{
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
oldTableName, newTableName := "custom_rules", "language_mappings"
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
if migrator.HasTable(oldTableName) {
log.Printf("renaming '%s' table to '%s'\n", oldTableName, newTableName)
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
return err
}
log.Printf("renaming '%s' index to '%s'\n", oldIndexName, newIndexName)
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
}
return nil
},
name: "rename language mappings table",
},
{
f: func(db *gorm.DB, cfg *config.Config) error {
// drop all already existing foreign key constraints
// afterwards let them be re-created by auto migrate with the newly introduced cascade settings,
migrator := db.Migrator()
const lookupKey = "20201106-migration_cascade_constraints"
if cfg.Db.Dialect == config.SQLDialectSqlite {
// https://stackoverflow.com/a/1884893/3112139
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
// things like deleting all summaries won't work in those cases unless an entirely new db is created
log.Println("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
log.Println("key-value table not yet existing")
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, lookupKey).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
log.Println("no need to migrate cascade constraints")
return nil
}
// SELECT * FROM INFORMATION_SCHEMA.table_constraints;
constraints := map[string]interface{}{
"fk_summaries_editors": &models.SummaryItem{},
"fk_summaries_languages": &models.SummaryItem{},
"fk_summaries_machines": &models.SummaryItem{},
"fk_summaries_operating_systems": &models.SummaryItem{},
"fk_summaries_projects": &models.SummaryItem{},
"fk_summary_items_summary": &models.SummaryItem{},
"fk_summaries_user": &models.Summary{},
"fk_language_mappings_user": &models.LanguageMapping{},
"fk_heartbeats_user": &models.Heartbeat{},
"fk_aliases_user": &models.Alias{},
}
for name, table := range constraints {
if migrator.HasConstraint(table, name) {
log.Printf("dropping constraint '%s'", name)
if err := migrator.DropConstraint(table, name); err != nil {
return err
}
}
}
if err := db.Create(&models.KeyStringValue{
Key: lookupKey,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
name: "add cascade constraints",
},
}
}
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPreMigrations {
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}
}
}

View File

@ -0,0 +1,25 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
func MigrateLanguages(db *gorm.DB) {
cfg := config.Get()
for k, v := range cfg.App.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
Updates(models.Heartbeat{Language: v})
if result.Error != nil {
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
}
}
}

View File

@ -1,87 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
create table aliases
(
id integer primary key autoincrement,
type integer not null,
user_id varchar(255) not null,
key varchar(255) not null,
value varchar(255) not null
);
create index idx_alias_type_key
on aliases (type, key);
create index idx_alias_user
on aliases (user_id);
create table summaries
(
id integer primary key autoincrement,
user_id varchar(255) not null,
from_time timestamp default CURRENT_TIMESTAMP not null,
to_time timestamp default CURRENT_TIMESTAMP not null
);
create index idx_time_summary_user
on summaries (user_id, from_time, to_time);
create table summary_items
(
id integer primary key autoincrement,
summary_id integer REFERENCES summaries (id) ON DELETE CASCADE ON UPDATE CASCADE,
type integer,
key varchar(255),
total bigint
);
create table users
(
id varchar(255) primary key,
api_key varchar(255) unique,
password varchar(255)
);
create table heartbeats
(
id integer primary key autoincrement,
user_id varchar(255) not null REFERENCES users (id) ON DELETE RESTRICT ON UPDATE RESTRICT,
entity varchar(255) not null,
type varchar(255),
category varchar(255),
project varchar(255),
branch varchar(255),
language varchar(255),
is_write bool,
editor varchar(255),
operating_system varchar(255),
time timestamp default CURRENT_TIMESTAMP
);
create index idx_entity
on heartbeats (entity);
create index idx_language
on heartbeats (language);
create index idx_time
on heartbeats (time);
create index idx_time_user
on heartbeats (user_id, time);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP INDEX idx_alias_user;
DROP INDEX idx_alias_type_key;
DROP TABLE aliases;
DROP INDEX idx_time_summary_user;
DROP TABLE summaries;
DROP TABLE summary_items;
DROP TABLE heartbeats;
DROP INDEX idx_entity;
DROP INDEX idx_language;
DROP INDEX idx_time;
DROP INDEX idx_time_user;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
create table key_string_values
(
key varchar(255) primary key,
value text
);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
drop table key_string_value;

View File

@ -1,20 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
-- SQLite does not allow altering a table to add a new column with default of CURRENT_TIMESTAMP
-- See https://www.sqlite.org/lang_altertable.html
alter table users
add `created_at` timestamp default '2020-01-01T00:00:00.000' not null;
alter table users
add `last_logged_in_at` timestamp default '2020-01-01T00:00:00.000' not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `created_at`;
alter table users
drop column `last_logged_in_at`;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table heartbeats
add column `machine` varchar(255);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table heartbeats
drop column `machine`;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table users
add column `badges_enabled` tinyint(1) default 0 not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `badges_enabled`;

45
mocks/alias_repository.go Normal file
View File

@ -0,0 +1,45 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type AliasRepositoryMock struct {
mock.Mock
}
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) GetByUserAndKey(s string, s2 string) ([]*models.Alias, error) {
args := m.Called(s, s2)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
args := m.Called(s, s2, u)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) GetByUserAndTypeAndValue(s string, u uint8, s2 string) (*models.Alias, error) {
args := m.Called(s, u, s2)
return args.Get(0).(*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) Insert(s *models.Alias) (*models.Alias, error) {
args := m.Called(s)
return args.Get(0).(*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) Delete(u uint) error {
args := m.Called(u)
return args.Error(0)
}
func (m *AliasRepositoryMock) DeleteBatch(u []uint) error {
args := m.Called(u)
return args.Error(0)
}

50
mocks/alias_service.go Normal file
View File

@ -0,0 +1,50 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type AliasServiceMock struct {
mock.Mock
}
func (m *AliasServiceMock) IsInitialized(s string) bool {
args := m.Called(s)
return args.Bool(0)
}
func (m *AliasServiceMock) InitializeUser(s string) error {
args := m.Called(s)
return args.Error(0)
}
func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (string, error) {
args := m.Called(s, u, s2)
return args.String(0), args.Error(1)
}
func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
args := m.Called(s, s2, u)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasServiceMock) Create(a *models.Alias) (*models.Alias, error) {
args := m.Called(a)
return args.Get(0).(*models.Alias), args.Error(1)
}
func (m *AliasServiceMock) Delete(s *models.Alias) error {
args := m.Called(s)
return args.Error(0)
}
func (m *AliasServiceMock) DeleteMulti(a []*models.Alias) error {
args := m.Called(a)
return args.Error(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type HeartbeatServiceMock struct {
mock.Mock
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats)
return 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)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type SummaryRepositoryMock struct {
mock.Mock
}
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
args := m.Called(summary)
return args.Error(0)
}
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)
}
func (m *SummaryRepositoryMock) GetLastByUser() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
args := m.Called(s)
return args.Error(0)
}

55
mocks/user_service.go Normal file
View File

@ -0,0 +1,55 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type UserServiceMock struct {
mock.Mock
}
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByKey(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)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) {
args := m.Called(user, s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1)
}

View File

@ -2,8 +2,22 @@ package models
type Alias struct {
ID uint `gorm:"primary_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
User *User `json:"-" gorm:"not null"`
UserID string `gorm:"not null; index:idx_alias_user"`
Key string `gorm:"not null; index:idx_alias_type_key"`
Value string `gorm:"not null"`
}
func (a *Alias) IsValid() bool {
return a.Key != "" && a.Value != "" && a.validateType()
}
func (a *Alias) validateType() bool {
for _, t := range SummaryTypes() {
if a.Type == t {
return true
}
}
return false
}

View File

@ -22,8 +22,8 @@ type BadgeData struct {
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
var total time.Duration
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
total = summary.TotalTimeByKey(filterType, filterKey)
if hasFilter, _, _ := filters.First(); hasFilter {
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}

View File

@ -21,7 +21,7 @@ type allTimeData struct {
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
var total time.Duration
if key := filters.Project; key != "" {
total = summary.TotalTimeByKey(models.SummaryProject, key)
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}

View File

@ -64,11 +64,11 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
for i, s := range summaries {
data[i] = newDataFrom(s)
if s.FromTime.Before(minDate) {
minDate = s.FromTime
if s.FromTime.T().Before(minDate) {
minDate = s.FromTime.T()
}
if s.ToTime.After(maxDate) {
maxDate = s.ToTime
if s.ToTime.T().After(maxDate) {
maxDate = s.ToTime.T()
}
}
@ -101,8 +101,8 @@ func newDataFrom(s *models.Summary) *summariesData {
},
Range: &summariesRange{
Date: time.Now().Format(time.RFC3339),
End: s.ToTime,
Start: s.FromTime,
End: s.ToTime.T(),
Start: s.FromTime.T(),
Text: "",
Timezone: zone,
},
@ -158,13 +158,17 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
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())
percentage := math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100
if math.IsNaN(percentage) || math.IsInf(percentage, 0) {
percentage = 0
}
return &summariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs,
Minutes: mins,
Name: e.Key,
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
Percent: percentage,
Seconds: secs,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),

View File

@ -1,229 +0,0 @@
package models
import (
"encoding/json"
"github.com/gorilla/securecookie"
"github.com/jinzhu/gorm"
"github.com/joho/godotenv"
migrate "github.com/rubenv/sql-migrate"
"gopkg.in/ini.v1"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
)
var cfg *Config
type Config struct {
Env string
Version string
Port int
Addr string
BasePath string
DbHost string
DbPort uint
DbUser string
DbPassword string
DbName string
DbDialect string
DbMaxConn uint
CleanUp bool
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string
SecureCookieHashKey string
SecureCookieBlockKey string
InsecureCookies bool
CustomLanguages map[string]string
LanguageColors map[string]string
SecureCookie *securecookie.SecureCookie
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) GetMigrationFunc(dbDialect string) MigrationFunc {
switch dbDialect {
case "sqlite3":
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/sqlite3",
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d migrations\n", n)
return nil
}
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&Alias{})
db.AutoMigrate(&Summary{})
db.AutoMigrate(&SummaryItem{})
db.AutoMigrate(&User{})
db.AutoMigrate(&Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
db.AutoMigrate(&SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
db.AutoMigrate(&KeyStringValue{})
return nil
}
}
}
func (c *Config) GetFixturesFunc(dbDialect string) MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/common/fixtures",
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d fixtures\n", n)
return nil
}
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func SetConfig(config *Config) {
cfg = config
}
func GetConfig() *Config {
return cfg
}
func LookupFatal(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("missing env variable '%s'", key)
}
return v
}
func readVersion() string {
file, err := os.Open("version.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
return string(bytes)
}
func readConfig() *Config {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
version := readVersion()
env := LookupFatal("ENV")
dbType := LookupFatal("WAKAPI_DB_TYPE")
dbUser := LookupFatal("WAKAPI_DB_USER")
dbPassword := LookupFatal("WAKAPI_DB_PASSWORD")
dbHost := LookupFatal("WAKAPI_DB_HOST")
dbName := LookupFatal("WAKAPI_DB_NAME")
dbPortStr := LookupFatal("WAKAPI_DB_PORT")
passwordSalt := LookupFatal("WAKAPI_PASSWORD_SALT")
dbPort, err := strconv.Atoi(dbPortStr)
cfg, err := ini.Load("config.ini")
if err != nil {
log.Fatalf("Fail to read file: %v", err)
}
if dbType == "" {
dbType = "mysql"
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(1)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
insecureCookies := IsDev(env) || cfg.Section("server").Key("insecure_cookies").MustBool(false)
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
if strings.HasSuffix(basePath, "/") {
basePath = basePath[:len(basePath)-1]
}
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
data, err := ioutil.ReadFile("data/colors.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
secureCookie := securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
return &Config{
Env: env,
Version: version,
Port: port,
Addr: addr,
BasePath: basePath,
DbHost: dbHost,
DbPort: uint(dbPort),
DbUser: dbUser,
DbPassword: dbPassword,
DbName: dbName,
DbDialect: dbType,
DbMaxConn: dbMaxConn,
CleanUp: cleanUp,
InsecureCookies: insecureCookies,
SecureCookie: secureCookie,
PasswordSalt: passwordSalt,
CustomLanguages: customLangs,
LanguageColors: colors,
}
}

67
models/filters.go Normal file
View File

@ -0,0 +1,67 @@
package models
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
type FilterElement struct {
Type uint8
Key string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{OS: key}
case SummaryLanguage:
return &Filters{Language: key}
case SummaryEditor:
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
}
func (f *Filters) All() []*FilterElement {
all := make([]*FilterElement, 0)
if f.Project != "" {
all = append(all, &FilterElement{Type: SummaryProject, Key: f.Project})
}
if f.Editor != "" {
all = append(all, &FilterElement{Type: SummaryEditor, Key: f.Editor})
}
if f.Language != "" {
all = append(all, &FilterElement{Type: SummaryLanguage, Key: f.Language})
}
if f.Machine != "" {
all = append(all, &FilterElement{Type: SummaryMachine, Key: f.Machine})
}
if f.OS != "" {
all = append(all, &FilterElement{Type: SummaryOS, Key: f.OS})
}
return all
}

View File

@ -7,7 +7,7 @@ import (
type Heartbeat struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
@ -19,27 +19,46 @@ type Heartbeat struct {
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp
}
func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
}
func (h *Heartbeat) Augment(customLangs map[string]string) {
if h.Language == "" {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := customLangs[ending]; !ok {
return
}
h.Language, _ = customLangs[ending]
func (h *Heartbeat) Augment(languageMappings map[string]string) {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
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) {
switch t {
case SummaryProject:
key = h.Project
case SummaryEditor:
key = h.Editor
case SummaryLanguage:
key = h.Language
case SummaryOS:
key = h.OperatingSystem
case SummaryMachine:
key = h.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

53
models/heartbeat_test.go Normal file
View File

@ -0,0 +1,53 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestHeartbeat_Valid_Success(t *testing.T) {
sut := &Heartbeat{
User: &User{
ID: "johndoe@example.org",
},
UserID: "johndoe@example.org",
Time: CustomTime(time.Now()),
}
assert.True(t, sut.Valid())
}
func TestHeartbeat_Valid_MissingUser(t *testing.T) {
sut := &Heartbeat{
Time: CustomTime(time.Now()),
}
assert.False(t, sut.Valid())
}
func TestHeartbeat_Augment(t *testing.T) {
testMappings := map[string]string{
"py": "Python3",
}
sut := &Heartbeat{
Entity: "~/dev/file.py",
Language: "Python",
}
sut.Augment(testMappings)
assert.Equal(t, "Python3", sut.Language)
}
func TestHeartbeat_GetKey(t *testing.T) {
sut := &Heartbeat{
Project: "wakapi",
}
assert.Equal(t, "wakapi", sut.GetKey(SummaryProject))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryOS))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryMachine))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryLanguage))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
}

38
models/heartbeats.go Normal file
View File

@ -0,0 +1,38 @@
package models
import "sort"
type Heartbeats []*Heartbeat
func (h Heartbeats) Len() int {
return len(h)
}
func (h Heartbeats) Less(i, j int) bool {
return h[i].Time.T().Before(h[j].Time.T())
}
func (h Heartbeats) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *Heartbeats) Sorted() *Heartbeats {
sort.Sort(h)
return h
}
func (h *Heartbeats) First() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[0]
}
func (h *Heartbeats) Last() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[h.Len()-1]
}

View File

@ -0,0 +1,21 @@
package models
type LanguageMapping struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
Language string `json:"language" gorm:"type:varchar(64)"`
}
func (m *LanguageMapping) IsValid() bool {
return m.validateLanguage() && m.validateExtension()
}
func (m *LanguageMapping) validateLanguage() bool {
return len(m.Language) >= 1
}
func (m *LanguageMapping) validateExtension() bool {
return len(m.Extension) >= 1
}

View File

@ -1,5 +1,5 @@
package models
func init() {
SetConfig(readConfig())
// nothing no init here, yet
}

View File

@ -4,7 +4,7 @@ import (
"database/sql/driver"
"errors"
"fmt"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"math"
"strconv"
"strings"
@ -24,43 +24,9 @@ type KeyStringValue struct {
Value string `gorm:"type:text"`
}
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
case SummaryLanguage:
return &Filters{Project: key}
case SummaryEditor:
return &Filters{Project: key}
case SummaryMachine:
return &Filters{Project: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time
@ -76,28 +42,38 @@ func (j *CustomTime) UnmarshalJSON(b []byte) error {
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
err error
)
switch value.(type) {
case string:
t, err := time.Parse("2006-01-02 15:04:05-07:00", value.(string))
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
}
*j = CustomTime(t)
case int64:
*j = CustomTime(time.Unix(value.(int64), 0))
t = time.Unix(0, value.(int64))
break
case time.Time:
*j = CustomTime(value.(time.Time))
t = value.(time.Time)
break
default:
return errors.New(fmt.Sprintf("unsupported type: %T", value))
}
t = time.Unix(0, (t.UnixNano()/int64(time.Millisecond))*int64(time.Millisecond)) // round to millisecond precision
*j = CustomTime(t)
return nil
}
func (j CustomTime) Value() (driver.Value, error) {
return time.Time(j), nil
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil
}
func (j CustomTime) String() string {
@ -105,6 +81,10 @@ func (j CustomTime) String() string {
return t.Format("2006-01-02 15:04:05.000")
}
func (j CustomTime) Time() time.Time {
func (j CustomTime) T() time.Time {
return time.Time(j)
}
func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0
}

View File

@ -1,6 +1,7 @@
package models
import (
"sort"
"time"
)
@ -34,19 +35,23 @@ func Intervals() []string {
const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"`
OperatingSystems []*SummaryItem `json:"operating_systems"`
Machines []*SummaryItem `json:"machines"`
ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
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:"-"`
Key string `json:"key"`
@ -73,16 +78,27 @@ type SummaryParams struct {
Recompute bool
}
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Projects))
sort.Sort(sort.Reverse(s.Machines))
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
return s
}
func (s *Summary) Types() []uint8 {
return SummaryTypes()
}
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
return map[uint8]*[]*SummaryItem{
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
return map[uint8]*SummaryItems{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
@ -147,31 +163,94 @@ func (s *Summary) TotalTime() time.Duration {
return timeSum * time.Second
}
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if item.Key != key {
continue
}
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
for _, f := range filter.All() {
timeSum += s.TotalTimeByKey(f.Type, f.Key)
}
return timeSum
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
s.Projects = processAliases(s.Projects)
s.Editors = processAliases(s.Editors)
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
return s
}
func (s SummaryItems) Len() int {
return len(s)
}
func (s SummaryItems) Less(i, j int) bool {
return s[i].Total < s[j].Total
}
func (s SummaryItems) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

195
models/summary_test.go Normal file
View File

@ -0,0 +1,195 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration / time.Second,
},
},
}
sut.FillUnknown()
itemLists := [][]*SummaryItem{
sut.Machines,
sut.OperatingSystems,
sut.Languages,
sut.Editors,
}
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
}
}
func TestSummary_TotalTimeBy(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
}
func TestSummary_TotalTimeByFilters(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
filters1 := &Filters{Project: "wakapi"}
filters2 := &Filters{Project: "wakapi", Language: "Go"} // filters have OR logic
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration1+testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
}
func TestSummary_WithResolvedAliases(t *testing.T) {
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
var resolver AliasResolver = func(t uint8, k string) string {
switch t {
case SummaryProject:
switch k {
case "wakapi-mobile":
return "wakapi"
}
case SummaryLanguage:
switch k {
case "Java 8":
return "Java"
}
}
return k
}
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "wakapi-mobile",
Total: testDuration2 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration3 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Java 8",
Total: testDuration4 / time.Second,
},
},
}
sut = sut.WithResolvedAliases(resolver)
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
assert.Len(t, sut.Projects, 2)
assert.Len(t, sut.Languages, 1)
assert.Empty(t, sut.Editors)
assert.Empty(t, sut.OperatingSystems)
assert.Empty(t, sut.Machines)
}
func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2,
},
{
Type: SummaryProject,
Key: "anchr-mobile",
Total: testDuration3,
},
},
}
sut = sut.Sorted()
assert.Equal(t, testDuration3, sut.Projects[0].Total)
assert.Equal(t, testDuration1, sut.Projects[1].Total)
assert.Equal(t, testDuration2, sut.Projects[2].Total)
}

View File

@ -6,7 +6,8 @@ type User struct {
Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`
}
type Login struct {
@ -26,6 +27,11 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"`
}
type TimeByUser struct {
User string
Time CustomTime
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat
@ -38,7 +44,7 @@ func (s *Signup) IsValid() bool {
}
func validateUsername(username string) bool {
return len(username) >= 3 && username != "current"
return len(username) >= 1 && username != "current"
}
func validatePassword(password string) bool {

18
models/view/home.go Normal file
View File

@ -0,0 +1,18 @@
package view
type HomeViewModel struct {
Success string
Error string
TotalHours int
TotalUsers int
}
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
s.Success = m
return s
}
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
s.Error = m
return s
}

22
models/view/imprint.go Normal file
View File

@ -0,0 +1,22 @@
package view
type ImprintViewModel struct {
HtmlText string
Success string
Error string
}
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
s.Success = m
return s
}
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
s.Error = m
return s
}
func (s *ImprintViewModel) WithHtmlText(t string) *ImprintViewModel {
s.HtmlText = t
return s
}

16
models/view/login.go Normal file
View File

@ -0,0 +1,16 @@
package view
type LoginViewModel struct {
Success string
Error string
}
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
s.Success = m
return s
}
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
s.Error = m
return s
}

27
models/view/settings.go Normal file
View File

@ -0,0 +1,27 @@
package view
import "github.com/muety/wakapi/models"
type SettingsViewModel struct {
User *models.User
LanguageMappings []*models.LanguageMapping
Aliases []*SettingsVMCombinedAlias
Success string
Error string
}
type SettingsVMCombinedAlias struct {
Key string
Type uint8
Values []string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m
return s
}
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
s.Error = m
return s
}

16
models/view/summary.go Normal file
View File

@ -0,0 +1,16 @@
package view
type SummaryViewModel struct {
Success string
Error string
}
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
s.Success = m
return s
}
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
s.Error = m
return s
}

89
repositories/alias.go Normal file
View File

@ -0,0 +1,89 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type AliasRepository struct {
db *gorm.DB
}
func NewAliasRepository(db *gorm.DB) *AliasRepository {
return &AliasRepository{db: db}
}
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.
Where(&models.Alias{UserID: userId}).
Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.
Where(&models.Alias{
UserID: userId,
Key: key,
}).
Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.
Where(&models.Alias{
UserID: userId,
Key: key,
Type: summaryType,
}).
Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
alias := &models.Alias{}
if err := r.db.
Where(&models.Alias{
UserID: userId,
Type: summaryType,
Value: value,
}).
First(alias).Error; err != nil {
return nil, err
}
return alias, nil
}
func (r *AliasRepository) Insert(alias *models.Alias) (*models.Alias, error) {
if !alias.IsValid() {
return nil, errors.New("invalid alias")
}
result := r.db.Create(alias)
if err := result.Error; err != nil {
return nil, err
}
return alias, nil
}
func (r *AliasRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.Alias{}).Error
}
func (r *AliasRepository) DeleteBatch(ids []uint) error {
return r.db.
Where("id IN ?", ids).
Delete(models.Alias{}).Error
}

View File

@ -0,0 +1,54 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type HeartbeatRepository struct {
db *gorm.DB
}
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db}
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db.Create(&heartbeats).Error; err != nil {
return err
}
return nil
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time < ?", to).
Order("time asc").
Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, min(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db.
Where("time <= ?", t).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

63
repositories/key_value.go Normal file
View File

@ -0,0 +1,63 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"log"
)
type KeyValueRepository struct {
db *gorm.DB
}
func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
return &KeyValueRepository{db: db}
}
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{}
if err := r.db.
Where(&models.KeyStringValue{Key: key}).
First(&kv).Error; err != nil {
return nil, err
}
return kv, nil
}
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
result := r.db.
Clauses(clause.OnConflict{
UpdateAll: true,
}).
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
Create(kv)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
log.Printf("warning: did not insert key '%s', maybe just updated?\n", kv.Key)
}
return nil
}
func (r *KeyValueRepository) DeleteString(key string) error {
result := r.db.
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing deleted")
}
return nil
}

View File

@ -0,0 +1,52 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type LanguageMappingRepository struct {
config *config.Config
db *gorm.DB
}
func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
return &LanguageMappingRepository{config: config.Get(), db: db}
}
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 {
return mapping, err
}
return mapping, nil
}
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if err := r.db.
Where(&models.LanguageMapping{UserID: userId}).
Find(&mappings).Error; err != nil {
return mappings, err
}
return mappings, nil
}
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
if !mapping.IsValid() {
return nil, errors.New("invalid mapping")
}
result := r.db.Create(mapping)
if err := result.Error; err != nil {
return nil, err
}
return mapping, nil
}
func (r *LanguageMappingRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.LanguageMapping{}).Error
}

View File

@ -0,0 +1,52 @@
package repositories
import (
"github.com/muety/wakapi/models"
"time"
)
type IAliasRepository interface {
Insert(*models.Alias) (*models.Alias, error)
Delete(uint) error
DeleteBatch([]uint) error
GetByUser(string) ([]*models.Alias, error)
GetByUserAndKey(string, string) ([]*models.Alias, error)
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
GetByUserAndTypeAndValue(string, uint8, string) (*models.Alias, error)
}
type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueRepository interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingRepository interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
}
type IUserRepository interface {
GetById(string) (*models.User, error)
GetByApiKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error)
}

59
repositories/summary.go Normal file
View File

@ -0,0 +1,59 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type SummaryRepository struct {
db *gorm.DB
}
func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
return &SummaryRepository{db: db}
}
func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil {
return err
}
return nil
}
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
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) GetLastByUser() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(to_time) as time").
Joins("left join summaries on users.id = summaries.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *SummaryRepository) DeleteByUser(userId string) error {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}

80
repositories/user.go Normal file
View File

@ -0,0 +1,80 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetById(userId string) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
return u, err
}
return u, 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 {
return u, err
}
return u, nil
}
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Find(&users).Error; err != nil {
return nil, err
}
return users, 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 {
return nil, false, err
}
if result.RowsAffected == 1 {
return user, true, nil
}
return user, false, nil
}
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
result := r.db.Model(user).Updates(user)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}
func (r *UserRepository) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
result := r.db.Model(user).Update(key, value)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}

View File

@ -2,12 +2,14 @@ package v1
import (
"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"
"net/http"
"regexp"
"strings"
)
const (
@ -16,16 +18,16 @@ const (
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
config *models.Config
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: models.GetConfig(),
config: conf.Get(),
}
}
@ -33,6 +35,11 @@ func (h *BadgeHandler) ApiGet(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
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled {
@ -50,18 +57,20 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
interval = groups[1]
}
filters := &models.Filters{}
var filters *models.Filters
switch filterEntity {
case "project":
filters.Project = filterKey
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
filters.OS = filterKey
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
filters.Editor = filterKey
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
filters.Language = filterKey
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
filters.Machine = filterKey
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
default:
filters = &models.Filters{}
}
summary, err, status := h.loadUserSummary(user, interval)
@ -87,7 +96,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,6 +2,7 @@ package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -12,14 +13,14 @@ import (
)
type AllTimeHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *conf.Config
summarySrvc services.ISummaryService
}
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: conf.Get(),
}
}
@ -42,7 +43,7 @@ func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
utils.RespondJSON(w, http.StatusOK, vm)
}
@ -54,7 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -3,6 +3,7 @@ package v1
import (
"errors"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -13,14 +14,14 @@ import (
)
type SummariesHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *conf.Config
summarySrvc services.ISummaryService
}
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: conf.Get(),
}
}
@ -85,7 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,7 +2,7 @@ package routes
import (
"fmt"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"net/http"
)
@ -16,8 +16,10 @@ func NewHealthHandler(db *gorm.DB) *HealthHandler {
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if err := h.db.DB().Ping(); err == nil {
dbStatus = 1
if sqlDb, err := h.db.DB(); err == nil {
if err := sqlDb.Ping(); err == nil {
dbStatus = 1
}
}
w.Header().Set("Content-Type", "text/plain")

View File

@ -2,6 +2,7 @@ package routes
import (
"encoding/json"
conf "github.com/muety/wakapi/config"
"net/http"
"os"
@ -12,14 +13,16 @@ import (
)
type HeartbeatHandler struct {
config *models.Config
heartbeatSrvc *services.HeartbeatService
config *conf.Config
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{
config: models.GetConfig(),
heartbeatSrvc: heartbeatService,
config: conf.Get(),
heartbeatSrvc: heartbeatService,
languageMappingSrvc: languageMappingService,
}
}
@ -46,7 +49,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
hb.Machine = machineName
hb.User = user
hb.UserID = user.ID
hb.Augment(h.config.CustomLanguages)
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)

65
routes/home.go Normal file
View File

@ -0,0 +1,65 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
"strconv"
"time"
)
type HomeHandler struct {
config *conf.Config
keyValueSrvc services.IKeyValueService
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
return &HomeHandler{
config: conf.Get(),
keyValueSrvc: keyValueService,
}
}
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
var totalHours int
var totalUsers int
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
if d, err := time.ParseDuration(t.Value); err == nil {
totalHours = int(d.Hours())
}
}
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
if d, err := strconv.Atoi(t.Value); err == nil {
totalUsers = d
}
}
return &view.HomeViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
TotalHours: totalHours,
TotalUsers: totalUsers,
}
}

41
routes/imprint.go Normal file
View File

@ -0,0 +1,41 @@
package routes
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
)
type ImprintHandler struct {
config *conf.Config
keyValueSrvc services.IKeyValueService
}
func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
return &ImprintHandler{
config: conf.Get(),
keyValueSrvc: keyValueService,
}
}
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
text := "failed to load content"
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
text = data.Value
}
templates[conf.ImprintTemplate].Execute(w, h.buildViewModel(r).WithHtmlText(text))
}
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
return &view.ImprintViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

159
routes/login.go Normal file
View File

@ -0,0 +1,159 @@
package routes
import (
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
"time"
)
type LoginHandler struct {
config *conf.Config
userSrvc services.IUserService
}
func NewLoginHandler(userService services.IUserService) *LoginHandler {
return &LoginHandler{
config: conf.Get(),
userSrvc: userService,
}
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, &h.userSrvc) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -1,179 +0,0 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type IndexHandler struct {
config *models.Config
userSrvc *services.UserService
keyValueSrvc *services.KeyValueService
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
return &IndexHandler{
config: models.GetConfig(),
userSrvc: userService,
keyValueSrvc: keyValueService,
}
}
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "") {
return
}
templates["index.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
text := "failed to load content"
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
text = data.Value
}
templates["imprint.tpl.html"].Execute(w, &struct {
HtmlText string
}{HtmlText: text})
}
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.PasswordSalt, h.userSrvc) {
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
utils.ClearCookie(w, models.AuthCookieKey, !h.config.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "signup.tpl.html") {
return
}
templates["signup.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
return
}
if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.BasePath, msg), http.StatusFound)
}

View File

@ -2,33 +2,42 @@ 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/ioutil"
"net/http"
"path"
"strings"
)
func init() {
func Init() {
loadTemplates()
}
var templates map[string]*template.Template
func loadTemplates() {
tplPath := "views"
const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"capitalize": utils.Capitalize,
"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,
"getBasePath": func() string {
return models.GetConfig().BasePath
return config.Get().Server.BasePath
},
"getVersion": func() string {
return models.GetConfig().Version
return config.Get().Version
},
"getDbType": func() string {
return strings.ToLower(config.Get().Db.Type)
},
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
@ -36,7 +45,12 @@ func loadTemplates() {
})
templates = make(map[string]*template.Template)
files, err := ioutil.ReadDir(tplPath)
dir, err := pkger.Open(tplPath)
if err != nil {
panic(err)
}
defer dir.Close()
files, err := dir.Readdir(0)
if err != nil {
panic(err)
}
@ -47,7 +61,17 @@ func loadTemplates() {
continue
}
tpl, err := tpls.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
templateFile, err := pkger.Open(fmt.Sprintf("%s/%s", tplPath, tplName))
if err != nil {
panic(err)
}
defer templateFile.Close()
template, err := ioutil.ReadAll(templateFile)
if err != nil {
panic(err)
}
tpl, err := tpls.New(tplName).Parse(string(template))
if err != nil {
panic(err)
}
@ -56,32 +80,21 @@ func loadTemplates() {
}
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
if tplName == "" {
tplName = "index.tpl.html"
func typeName(t uint8) string {
if t == models.SummaryProject {
return "project"
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error, Success: success})
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
if t == models.SummaryLanguage {
return "language"
}
if t == models.SummaryEditor {
return "editor"
}
if t == models.SummaryOS {
return "operating system"
}
if t == models.SummaryMachine {
return "machine"
}
return "unknown"
}

View File

@ -3,24 +3,35 @@ package routes
import (
"fmt"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"net/url"
"strconv"
)
type SettingsHandler struct {
config *models.Config
userSrvc *services.UserService
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
return &SettingsHandler{
config: models.GetConfig(),
userSrvc: userService,
config: conf.Get(),
summarySrvc: summaryService,
aliasSrvc: aliasService,
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
userSrvc: userService,
}
}
@ -29,16 +40,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
data := map[string]interface{}{
"User": user,
}
// TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, "settings.tpl.html") {
return
}
templates["settings.tpl.html"].Execute(w, data)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -50,32 +52,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
user.Password = credentials.PasswordNew
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
@ -83,23 +93,128 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
Username: user.ID,
Password: user.Password,
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
}
msg := url.QueryEscape("password was updated successfully")
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
}
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping not found"))
return
} else if mapping.UserID != user.ID {
w.WriteHeader(http.StatusForbidden)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("not allowed to delete mapping"))
return
}
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
}
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
w.WriteHeader(http.StatusConflict)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
}
func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
aliasKey := r.PostFormValue("key")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
if err != nil {
aliasType = 99 // nothing will be found later on
}
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("aliases not found"))
return
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete aliases"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("aliases deleted successfully"))
}
func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
aliasKey := r.PostFormValue("key")
aliasValue := r.PostFormValue("value")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
if err != nil {
aliasType = 99 // Alias.IsValid() will return false later on
}
alias := &models.Alias{
UserID: user.ID,
Key: aliasKey,
Value: aliasValue,
Type: uint8(aliasType),
}
if _, err := h.aliasSrvc.Create(alias); err != nil {
w.WriteHeader(http.StatusBadRequest)
// TODO: distinguish between bad request, conflict and server error
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid input"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("alias added successfully"))
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
@ -109,12 +224,28 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
}
func (h *SettingsHandler) PostSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.SetWakatimeApiKey(user, r.PostFormValue("api_key")); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("Wakatime API Key updated successfully"))
}
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
@ -123,11 +254,72 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
log.Printf("clearing summaries for user '%s'\n", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
log.Printf("failed to clear summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
return
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
log.Printf("failed to regenerate summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated this may take a few seconds"))
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := r.Context().Value(models.UserKey).(*models.User)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
if _, ok := aliasMap[k]; !ok {
aliasMap[k] = []*models.Alias{a}
} else {
aliasMap[k] = append(aliasMap[k], a)
}
}
combinedAliases := make([]*view.SettingsVMCombinedAlias, 0)
for _, l := range aliasMap {
ca := &view.SettingsVMCombinedAlias{
Key: l[0].Key,
Type: l[0].Type,
Values: make([]string, len(l)),
}
for i, a := range l {
ca.Values[i] = a.Value
}
combinedAliases = append(combinedAliases, ca)
}
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -1,21 +1,23 @@
package routes
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type SummaryHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *conf.Config
summarySrvc services.ISummaryService
}
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: conf.Get(),
}
}
@ -43,23 +45,25 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r)
if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
w.WriteHeader(status)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil {
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
return
}
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
LanguageColors: utils.FilterLanguageColors(h.config.App.GetLanguageColors(), summary),
ApiKey: user.ApiKey,
}
templates["summary.tpl.html"].Execute(w, vm)
templates[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
@ -68,10 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
return nil, err, http.StatusBadRequest
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

3
scripts/docker_mysql.sh Normal file
View File

@ -0,0 +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

View File

@ -0,0 +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

View File

@ -1,15 +1,18 @@
#!/usr/bin/python3
import argparse
import base64
import random
import string
import sys
from datetime import datetime, timedelta
from typing import List
from typing import List, Union
import requests
from tqdm import tqdm
N_PROJECTS = 5
N_PAST_HOURS = 24
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 vscode/1.42.1 vscode-wakatime/4.0.0'
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',
@ -36,23 +39,25 @@ class Heartbeat:
self.is_write: bool = is_write
self.branch: str = branch
self.type: str = type
self.category: str = None
self.category: Union[str, None] = None
def generate_data(n: int) -> List[Heartbeat]:
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(5)]
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
languages: List[str] = list(LANGUAGES.keys())
for i in range(n):
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),
hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59),
seconds=random.randint(0, 59)
seconds=random.randint(0, 59),
milliseconds=random.randint(0, 999),
microseconds=random.randint(0, 999)
)
data.append(Heartbeat(
@ -65,29 +70,44 @@ def generate_data(n: int) -> List[Heartbeat]:
return data
def post_data_sync(data: List[Heartbeat], url: str):
for h in 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
'User-Agent': UA,
'Authorization': f'Basic {encoded_key}',
'X-Machine-Name': MACHINE,
})
if r.status_code != 200:
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 i in range(length))
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__':
n: int = 10
url: str = 'http://admin:admin@localhost:3000/api/heartbeat'
args = parse_arguments()
if len(sys.argv) > 1:
n = int(sys.argv[1])
if len(sys.argv) > 2:
url = sys.argv[2]
random.seed(args.seed)
data: List[Heartbeat] = generate_data(n)
post_data_sync(data, url)
data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
post_data_sync(data, args.url, args.apikey)

View File

@ -1,34 +1,32 @@
package services
import (
"github.com/muety/wakapi/config"
"log"
"runtime"
"time"
"github.com/jasonlvhit/gocron"
"github.com/jinzhu/gorm"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
const (
aggregateIntervalDays int = 1 // TODO: Make configurable
aggregateIntervalDays int = 1
)
type AggregationService struct {
Config *models.Config
Db *gorm.DB
UserService *UserService
SummaryService *SummaryService
HeartbeatService *HeartbeatService
config *config.Config
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
}
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
return &AggregationService{
Config: models.GetConfig(),
Db: db,
UserService: userService,
SummaryService: summaryService,
HeartbeatService: heartbeatService,
config: config.Get(),
userService: userService,
summaryService: summaryService,
heartbeatService: heartbeatService,
}
}
@ -39,31 +37,42 @@ type AggregationJob struct {
}
// Schedule a job to (re-)generate summaries every day shortly after midnight
// TODO: Make configurable
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
log.Fatalf("failed to run AggregationJob: %v\n", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
s.StartBlocking()
}
func (srv *AggregationService) Run(userIds map[string]bool) error {
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
defer close(jobs)
defer close(summaries)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
}
for i := 0; i < int(srv.Config.DbMaxConn); i++ {
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
go srv.persistWorker(summaries)
}
// Run once initially
srv.trigger(jobs)
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
gocron.Every(1).Day().At("02:15").Do(srv.trigger, jobs)
<-gocron.Start()
return srv.trigger(jobs, userIds)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.SummaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
} else {
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
@ -74,72 +83,83 @@ 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 {
if err := srv.summaryService.Insert(summary); err != nil {
log.Printf("Failed to save summary (%v, %v, %s) %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
log.Println("Generating summaries.")
users, err := srv.UserService.GetAll()
if err != nil {
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
log.Println(err)
return err
}
latestSummaries, err := srv.SummaryService.GetLatestByUser()
if err != nil {
log.Println(err)
return err
}
userSummaryTimes := make(map[string]time.Time)
for _, s := range latestSummaries {
userSummaryTimes[s.UserID] = s.ToTime
}
missingUserIDs := make([]string, 0)
for _, u := range users {
if _, ok := userSummaryTimes[u.ID]; !ok {
missingUserIDs = append(missingUserIDs, u.ID)
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, len(userIds))
for i, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users[i] = u
}
}
} else {
users = allUsers
}
firstHeartbeats, err := srv.HeartbeatService.GetFirstUserHeartbeats(missingUserIDs)
// 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 {
log.Println(err)
return err
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
// 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 {
log.Println(err)
return err
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
// Build actual lookup table from it
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
for _, e := range firstUserHeartbeatTimes {
firstUserHeartbeatLookup[e.User] = e.Time
}
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
generateUserJobs(e.User, e.Time.T(), jobs)
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
// Case 2: User has no aggregated summaries, yet, but has heartbeats
// -> Spawn jobs to create summaries from their first heartbeat to now
generateUserJobs(e.User, t.T(), jobs)
}
// Case 3: User doesn't have heartbeats at all
// -> Nothing to do
}
return nil
}
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
var from, to time.Time
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 = time.Date(
from.Year(),
from.Month(),
from.Day()+aggregateIntervalDays,
0, 0, 0, 0,
from.Location(),
)
// Iteratively aggregate per-day summaries until end of yesterday is reached
end := getStartOfToday().Add(-1 * time.Second)
if lastAggregation.Hour() == 0 {
from = lastAggregation
} else {
from = time.Date(
lastAggregation.Year(),
lastAggregation.Month(),
lastAggregation.Day()+aggregateIntervalDays,
0, 0, 0, 0,
lastAggregation.Location(),
)
}
for from.Before(end) && to.Before(end) {
to = time.Date(
from.Year(),

View File

@ -2,53 +2,114 @@ package services
import (
"errors"
"sync"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"log"
"sync"
)
type AliasService struct {
Config *models.Config
Db *gorm.DB
config *config.Config
repository repositories.IAliasRepository
}
func NewAliasService(db *gorm.DB) *AliasService {
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
return &AliasService{
Config: models.GetConfig(),
Db: db,
config: config.Get(),
repository: aliasRepo,
}
}
var userAliases sync.Map
func (srv *AliasService) LoadUserAliases(userId string) error {
var aliases []*models.Alias
if err := srv.Db.
Where(&models.Alias{UserID: userId}).
Find(&aliases).Error; err != nil {
return err
}
userAliases.Store(userId, aliases)
return nil
}
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
if ua, ok := userAliases.Load(userId); ok {
for _, a := range ua.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
}
return value, nil
}
return "", errors.New("user aliases not initialized")
}
func (srv *AliasService) IsInitialized(userId string) bool {
if _, ok := userAliases.Load(userId); ok {
return true
}
return false
}
func (srv *AliasService) InitializeUser(userId string) error {
aliases, err := srv.repository.GetByUser(userId)
if err == nil {
userAliases.Store(userId, aliases)
}
return err
}
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
return aliases, nil
}
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType)
if err != nil {
return nil, err
}
return aliases, nil
}
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
if !srv.IsInitialized(userId) {
if err := srv.InitializeUser(userId); err != nil {
return "", err
}
}
aliases, _ := userAliases.Load(userId)
for _, a := range aliases.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
}
return value, nil
}
func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
result, err := srv.repository.Insert(alias)
if err != nil {
return nil, err
}
go srv.reinitUser(alias.UserID)
return result, nil
}
func (srv *AliasService) Delete(alias *models.Alias) error {
if alias.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(alias.ID)
go srv.reinitUser(alias.UserID)
return err
}
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
ids := make([]uint, len(aliases))
affectedUsers := make(map[string]bool)
for i, a := range aliases {
if a.UserID == "" {
return errors.New("no user id specified")
}
affectedUsers[a.UserID] = true
ids[i] = a.ID
}
err := srv.repository.DeleteBatch(ids)
for k := range affectedUsers {
go srv.reinitUser(k)
}
return err
}
func (srv *AliasService) reinitUser(userId string) {
if err := srv.InitializeUser(userId); err != nil {
log.Printf("error initializing user aliases %v\n", err)
}
}

63
services/alias_test.go Normal file
View File

@ -0,0 +1,63 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"testing"
)
type AliasServiceTestSuite struct {
suite.Suite
TestUserId string
AliasRepository *mocks.AliasRepositoryMock
}
func (suite *AliasServiceTestSuite) SetupSuite() {
suite.TestUserId = "johndoe@example.org"
aliases := []*models.Alias{
{
Type: models.SummaryProject,
UserID: suite.TestUserId,
Key: "wakapi",
Value: "wakapi-mobile",
},
}
aliasRepoMock := new(mocks.AliasRepositoryMock)
aliasRepoMock.On("GetByUser", suite.TestUserId).Return(aliases, nil)
aliasRepoMock.On("GetByUser", mock.AnythingOfType("string")).Return([]*models.Alias{}, assert.AnError)
suite.AliasRepository = aliasRepoMock
}
func TestAliasServiceTestSuite(t *testing.T) {
suite.Run(t, new(AliasServiceTestSuite))
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
sut := NewAliasService(suite.AliasRepository)
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
assert.Equal(suite.T(), "wakapi", result1)
assert.Nil(suite.T(), err1)
assert.Equal(suite.T(), "wakapi", result2)
assert.Nil(suite.T(), err2)
assert.Equal(suite.T(), "anchr", result3)
assert.Nil(suite.T(), err3)
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
sut := NewAliasService(suite.AliasRepository)
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
assert.Empty(suite.T(), result)
assert.Error(suite.T(), err)
}

View File

@ -1,94 +1,56 @@
package services
import (
"github.com/jasonlvhit/gocron"
"github.com/muety/wakapi/utils"
"log"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"time"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
gormbulk "github.com/t-tiger/gorm-bulk-insert"
)
const (
TableHeartbeat = "heartbeat"
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
)
type HeartbeatService struct {
Config *models.Config
Db *gorm.DB
config *config.Config
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
Config: models.GetConfig(),
Db: db,
config: config.Get(),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
}
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
var batch []interface{}
for _, h := range heartbeats {
batch = append(batch, *h)
}
if err := gormbulk.BulkInsert(srv.Db, batch, 3000); err != nil {
return err
}
return nil
return srv.repository.InsertBatch(heartbeats)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := srv.Db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time <= ?", to).
Order("time asc").
Find(&heartbeats).Error; err != nil {
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil {
return nil, err
}
return heartbeats, nil
return srv.augmented(heartbeats, user.ID)
}
// Will return *models.Heartbeat object with only user_id and time fields filled
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := srv.Db.
Table("heartbeats").
Select("user_id, min(time) as time").
Where("user_id IN (?)", userIds).
Group("user_id").
Scan(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers()
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
if err := srv.Db.
Where("time <= ?", t).
Delete(models.Heartbeat{}).Error; err != nil {
return err
return srv.repository.DeleteBefore(t)
}
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
if err != nil {
return nil, err
}
return nil
}
func (srv *HeartbeatService) CleanUp() error {
refTime := utils.StartOfToday().Add(-cleanUpInterval)
if err := srv.DeleteBefore(refTime); err != nil {
log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err)
return err
for i := range heartbeats {
heartbeats[i].Augment(languageMapping)
}
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
return nil
}
func (srv *HeartbeatService) ScheduleCleanUp() {
srv.CleanUp()
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
<-gocron.Start()
return heartbeats, nil
}

View File

@ -1,62 +1,31 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
)
type KeyValueService struct {
Config *models.Config
Db *gorm.DB
config *config.Config
repository repositories.IKeyValueRepository
}
func NewKeyValueService(db *gorm.DB) *KeyValueService {
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
return &KeyValueService{
Config: models.GetConfig(),
Db: db,
config: config.Get(),
repository: keyValueRepo,
}
}
func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{}
if err := srv.Db.
Where(&models.KeyStringValue{Key: key}).
First(&kv).Error; err != nil {
return nil, err
}
return kv, nil
return srv.repository.GetString(key)
}
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
result := srv.Db.
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
FirstOrCreate(kv)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing updated")
}
return nil
return srv.repository.PutString(kv)
}
func (srv *KeyValueService) DeleteString(key string) error {
result := srv.Db.
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing deleted")
}
return nil
return srv.repository.DeleteString(key)
}

View File

@ -0,0 +1,78 @@
package services
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type LanguageMappingService struct {
config *config.Config
cache *cache.Cache
repository repositories.ILanguageMappingRepository
}
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
return &LanguageMappingService{
config: config.Get(),
repository: languageMappingsRepo,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *LanguageMappingService) GetById(id uint) (*models.LanguageMapping, error) {
return srv.repository.GetById(id)
}
func (srv *LanguageMappingService) GetByUser(userId string) ([]*models.LanguageMapping, error) {
if mappings, found := srv.cache.Get(userId); found {
return mappings.([]*models.LanguageMapping), nil
}
mappings, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, mappings, cache.DefaultExpiration)
return mappings, nil
}
func (srv *LanguageMappingService) ResolveByUser(userId string) (map[string]string, error) {
mappings := srv.getServerMappings()
userMappings, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, m := range userMappings {
mappings[m.Extension] = m.Language
}
return mappings, nil
}
func (srv *LanguageMappingService) Create(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
result, err := srv.repository.Insert(mapping)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
return result, nil
}
func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error {
if mapping.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(mapping.ID)
srv.cache.Delete(mapping.UserID)
return err
}
func (srv *LanguageMappingService) getServerMappings() map[string]string {
// https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it
return srv.config.App.GetCustomLanguages()
}

119
services/misc.go Normal file
View File

@ -0,0 +1,119 @@
package services
import (
"github.com/muety/wakapi/config"
"go.uber.org/atomic"
"log"
"runtime"
"strconv"
"time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
type MiscService struct {
config *config.Config
userService IUserService
summaryService ISummaryService
keyValueService IKeyValueService
jobCount atomic.Uint32
}
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
return &MiscService{
config: config.Get(),
userService: userService,
summaryService: summaryService,
keyValueService: keyValueService,
}
}
type CountTotalTimeJob struct {
UserID string
NumJobs int
}
type CountTotalTimeResult struct {
UserId string
Total time.Duration
}
func (srv *MiscService) ScheduleCountTotalTime() {
// Run once initially
if err := srv.runCountTotalTime(); err != nil {
log.Fatalf("failed to run CountTotalTimeJob: %v\n", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
s.StartBlocking()
}
func (srv *MiscService) runCountTotalTime() error {
jobs := make(chan *CountTotalTimeJob)
results := make(chan *CountTotalTimeResult)
defer close(jobs)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results)
}
go srv.persistTotalTimeWorker(results)
// generate the jobs
if users, err := srv.userService.GetAll(); err == nil {
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
}
}
} else {
return err
}
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve); err != nil {
log.Printf("Failed to count total for user %s: %v.\n", job.UserID, err)
} else {
log.Printf("Successfully counted total for user %s.\n", job.UserID)
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
}
}
if srv.jobCount.Inc() == uint32(job.NumJobs) {
srv.jobCount.Store(0)
close(results)
}
}
}
func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeResult) {
var c int
var total time.Duration
for result := range results {
total += result.Total
c++
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
log.Printf("Failed to save total time count: %v\n", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(c),
}); err != nil {
log.Printf("Failed to save total users count: %v\n", err)
}
}

68
services/services.go Normal file
View File

@ -0,0 +1,68 @@
package services
import (
"github.com/muety/wakapi/models"
"time"
)
type IAggregationService interface {
Schedule()
Run(map[string]bool) error
}
type IMiscService interface {
ScheduleCountTotalTime()
}
type IAliasService interface {
Create(*models.Alias) (*models.Alias, error)
Delete(*models.Alias) error
DeleteMulti([]*models.Alias) error
IsInitialized(string) bool
InitializeUser(string) error
GetByUser(string) ([]*models.Alias, error)
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
GetAliasOrDefault(string, uint8, string) (string, error)
}
type IHeartbeatService interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingService interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
ResolveByUser(string) (map[string]string, error)
Create(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(mapping *models.LanguageMapping) error
}
type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
Insert(*models.Summary) error
}
type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
SetWakatimeApiKey(*models.User, string) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
}

View File

@ -3,87 +3,129 @@ package services
import (
"crypto/md5"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"math"
"sort"
"strconv"
"time"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
Config *models.Config
Cache *cache.Cache
Db *gorm.DB
HeartbeatService *HeartbeatService
AliasService *AliasService
config *config.Config
cache *cache.Cache
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
}
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
return &SummaryService{
Config: models.GetConfig(),
Cache: cache.New(24*time.Hour, 24*time.Hour),
Db: db,
HeartbeatService: heartbeatService,
AliasService: aliasService,
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
}
}
type Interval struct {
Start time.Time
End time.Time
}
// Public summary generation methods
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary
var cacheKey string
if recompute {
existingSummaries = make([]*models.Summary, 0)
} else {
cacheKey = getHash([]time.Time{from, to}, user)
if result, ok := srv.Cache.Get(cacheKey); ok {
return result.(*models.Summary), nil
}
summaries, err := srv.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
existingSummaries = summaries
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
missingIntervals := getMissingIntervals(from, to, existingSummaries)
// Wrap alias resolution
resolve := func(t uint8, k string) string {
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
return s
}
heartbeats := make([]*models.Heartbeat, 0)
// Initialize alias resolver service
if err := srv.aliasService.InitializeUser(user.ID); err != nil {
return nil, err
}
// Get actual summary
s, err := f(from, to, user)
if err != nil {
return nil, err
}
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
missingIntervals := srv.getMissingIntervals(from, to, summaries)
for _, interval := range missingIntervals {
hb, err := srv.HeartbeatService.GetAllWithin(interval.Start, interval.End, user)
if err != nil {
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
summaries = append(summaries, s)
} else {
return nil, err
}
heartbeats = append(heartbeats, hb...)
}
// Merge existing and newly generated summary snippets
summary, err := srv.mergeSummaries(summaries)
if err != nil {
return nil, err
}
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
// Initialize and fetch data
var heartbeats models.Heartbeats
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
heartbeats = rawHeartbeats
} else {
return nil, err
}
types := models.SummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, typedAggregations)
}
// Aggregate raw heartbeats by types in parallel and collect them
var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem
var editorItems []*models.SummaryItem
var osItems []*models.SummaryItem
var machineItems []*models.SummaryItem
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
}
c := make(chan models.SummaryItemContainer)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, user, c)
}
for i := 0; i < len(types); i++ {
item := <-c
item := <-typedAggregations
switch item.Type {
case models.SummaryProject:
projectItems = item.Items
@ -97,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
machineItems = item.Items
}
}
close(c)
realFrom, realTo := from, to
if len(existingSummaries) > 0 {
realFrom = existingSummaries[0].FromTime
realTo = existingSummaries[len(existingSummaries)-1].ToTime
for _, summary := range existingSummaries {
summary.FillUnknown()
}
}
if len(heartbeats) > 0 {
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
realFrom = t1
}
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
realTo = t2
}
if heartbeats.Len() > 0 {
from = time.Time(heartbeats.First().Time)
to = time.Time(heartbeats.Last().Time)
}
aggregatedSummary := &models.Summary{
summary := &models.Summary{
UserID: user.ID,
FromTime: realFrom,
ToTime: realTo,
FromTime: models.CustomTime(from),
ToTime: models.CustomTime(to),
Projects: projectItems,
Languages: languageItems,
Editors: editorItems,
@ -129,84 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
Machines: machineItems,
}
allSummaries := []*models.Summary{aggregatedSummary}
allSummaries = append(allSummaries, existingSummaries...)
//summary.FillUnknown()
summary, err := mergeSummaries(allSummaries)
if err != nil {
return nil, err
}
return summary.Sorted(), nil
}
if cacheKey != "" {
srv.Cache.SetDefault(cacheKey, summary)
}
// CRUD methods
return summary, nil
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
return srv.repository.GetLastByUser()
}
func (srv *SummaryService) DeleteByUser(userId string) error {
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
if err := srv.Db.Create(summary).Error; err != nil {
return err
}
return nil
return srv.repository.Insert(summary)
}
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
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
}
// Private summary generation and utility methods
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.
Table("summaries").
Select("user_id, max(to_time) as to_time").
Group("user_id").
Scan(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
durations := make(map[string]time.Duration)
for i, h := range heartbeats {
var key string
switch summaryType {
case models.SummaryProject:
key = h.Project
case models.SummaryEditor:
key = h.Editor
case models.SummaryLanguage:
key = h.Language
case models.SummaryOS:
key = h.OperatingSystem
case models.SummaryMachine:
key = h.Machine
}
if key == "" {
key = models.UnknownSummaryKey
}
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
key = aliasedKey
}
key := h.GetKey(summaryType)
if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0)
@ -216,9 +191,16 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
continue
}
timePassed := h.Time.Time().Sub(heartbeats[i-1].Time.Time())
timeThresholded := math.Min(float64(timePassed), float64(time.Duration(2)*time.Minute))
durations[key] += time.Duration(int64(timeThresholded))
t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
// This is a hack. The time difference between two heartbeats from two subsequent day (e.g. 23:59:59 and 00:00:01) are ignored.
// This is to prevent a discrepancy between summaries computed solely from heartbeats and summaries involving pre-aggregated per-day summaries.
// For the latter, a duration is already pre-computed and information about individual heartbeats is lost, so there can be no cross-day overflow.
// Essentially, we simply ignore such edge-case heartbeats here, which makes the eventual total duration potentially a bit shorter.
if t1.Day() == t2.Day() {
timePassed := t1.Sub(t2)
tdiff = time.Duration(int64(math.Min(float64(timePassed), float64(HeartbeatDiffThreshold))))
}
durations[key] += tdiff
}
items := make([]*models.SummaryItem, 0)
@ -237,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
}
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
if len(existingSummaries) == 0 {
return []*Interval{{from, to}}
}
intervals := make([]*Interval, 0)
// Pre
if from.Before(existingSummaries[0].FromTime) {
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime})
}
// Between
for i := 0; i < len(existingSummaries)-1; i++ {
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
}
}
// Post
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) {
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime})
}
return intervals
}
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 {
return nil, errors.New("no summaries given")
}
@ -295,28 +241,28 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
return nil, errors.New("users don't match")
}
if s.FromTime.Before(minTime) {
minTime = s.FromTime
if s.FromTime.T().Before(minTime) {
minTime = s.FromTime.T()
}
if s.ToTime.After(maxTime) {
maxTime = s.ToTime
if s.ToTime.T().After(maxTime) {
maxTime = s.ToTime.T()
}
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
}
finalSummary.FromTime = minTime
finalSummary.ToTime = maxTime
finalSummary.FromTime = models.CustomTime(minTime)
finalSummary.ToTime = models.CustomTime(maxTime)
return finalSummary, nil
}
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
items := make(map[string]*models.SummaryItem)
// Build map from existing
@ -346,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
return itemList
}
func getHash(times []time.Time, user *models.User) string {
digest := md5.New()
for _, t := range times {
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
if len(summaries) == 0 {
return []*models.Interval{{from, to}}
}
intervals := make([]*models.Interval, 0)
// Pre
if from.Before(summaries[0].FromTime.T()) {
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(summaries)-1; i++ {
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
}
}
// Post
if to.After(summaries[len(summaries)-1].ToTime.T()) {
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
}
return intervals
}
func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New()
for _, a := range args {
digest.Write([]byte(a))
}
digest.Write([]byte(user.ID))
return string(digest.Sum(nil))
}

290
services/summary_test.go Normal file
View File

@ -0,0 +1,290 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"math/rand"
"strings"
"testing"
"time"
)
const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
TestEditorGoland = "GoLand"
TestEditorIntellij = "idea"
TestEditorVscode = "vscode"
TestOsLinux = "Linux"
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
MinUnixTime1 = 1601510400000 * 1e6
)
type SummaryServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
}
func (suite *SummaryServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId}
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
},
}
}
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
}
func TestSummaryServiceTestSuite(t *testing.T) {
suite.Run(t, new(SummaryServiceTestSuite))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), from, result.FromTime.T())
assert.Equal(suite.T(), to, result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assert.Empty(suite.T(), result.Projects)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assertNumAllItems(suite.T(), 1, result, "")
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T())
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e")
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
summaries []*models.Summary
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(20 * time.Minute)),
ToTime: models.CustomTime(to.Add(-6 * time.Hour)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(to.Add(-6 * time.Hour)),
ToTime: models.CustomTime(to),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject2,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 2)
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
filtered = append(filtered, h)
}
}
return filtered
}
func assertNumAllItems(t *testing.T, expected int, summary *models.Summary, except string) {
if !strings.Contains(except, "p") {
assert.Len(t, summary.Projects, expected)
}
if !strings.Contains(except, "e") {
assert.Len(t, summary.Editors, expected)
}
if !strings.Contains(except, "l") {
assert.Len(t, summary.Languages, expected)
}
if !strings.Contains(except, "o") {
assert.Len(t, summary.OperatingSystems, expected)
}
if !strings.Contains(except, "m") {
assert.Len(t, summary.Machines, expected)
}
}

View File

@ -1,49 +1,59 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
uuid "github.com/satori/go.uuid"
"time"
)
type UserService struct {
Config *models.Config
Db *gorm.DB
Config *config.Config
cache *cache.Cache
repository repositories.IUserRepository
}
func NewUserService(db *gorm.DB) *UserService {
func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{
Config: models.GetConfig(),
Db: db,
Config: config.Get(),
repository: userRepo,
cache: cache.New(1*time.Hour, 2*time.Hour),
}
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
return u, err
if u, ok := srv.cache.Get(userId); ok {
return u.(*models.User), nil
}
u, err := srv.repository.GetById(userId)
if err != nil {
return nil, err
}
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
return u, nil
}
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
if u, ok := srv.cache.Get(key); ok {
return u.(*models.User), nil
}
u, err := srv.repository.GetByApiKey(key)
if err != nil {
return nil, err
}
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
return u, nil
}
func (srv *UserService) GetAll() ([]*models.User, error) {
var users []*models.User
if err := srv.Db.
Table("users").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
return srv.repository.GetAll()
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
@ -53,65 +63,43 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
Password: signup.Password,
}
if err := utils.HashPassword(u, srv.Config.PasswordSalt); err != nil {
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {
return nil, false, err
} else {
u.Password = hash
}
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return u, true, nil
}
return u, false, nil
return srv.repository.InsertOrGet(u)
}
func (srv *UserService) Update(user *models.User) (*models.User, error) {
result := srv.Db.Model(&models.User{}).Updates(user)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
srv.cache.Flush()
return srv.repository.Update(user)
}
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
srv.cache.Flush()
user.ApiKey = uuid.NewV4().String()
return srv.Update(user)
}
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
if err := result.Error; err != nil {
return nil, err
}
srv.cache.Flush()
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
srv.cache.Flush()
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
srv.cache.Flush()
user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
if hash, err := utils.HashBcrypt(user.Password, srv.Config.Security.PasswordSalt); err != nil {
return nil, err
} else {
user.Password = hash
}
result := srv.Db.Model(user).Update("password", user.Password)
if err := result.Error; err != nil {
return nil, err
} else if result.RowsAffected < 1 {
return nil, errors.New("nothing changes")
}
return user, nil
return srv.repository.UpdateField(user, "password", user.Password)
}

3
sonar-project.properties Normal file
View File

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

View File

@ -1,4 +1,3 @@
const SHOW_TOP_N = 10
const CHART_TARGET_SIZE = 200
const projectsCanvas = document.getElementById('chart-projects')
@ -17,7 +16,16 @@ const containers = [projectContainer, osContainer, editorContainer, languageCont
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
topNPickers.forEach(e => {
const idx = parseInt(e.attributes['data-entity'].value)
e.max = Math.min(data[idx].length, 10)
e.value = e.max
})
let charts = []
let showTopN = []
let resizeCount = 0
String.prototype.toHHMMSS = function () {
@ -38,7 +46,7 @@ String.prototype.toHHMMSS = function () {
return hours + ':' + minutes + ':' + seconds
}
function draw() {
function draw(subselection) {
function getTooltipOptions(key, type) {
return {
mode: 'single',
@ -47,19 +55,26 @@ function draw() {
let idx = type === 'pie' ? item.index : item.datasetIndex
let d = wakapiData[key][idx]
return `${d.key}: ${d.total.toString().toHHMMSS()}`
}
},
title: () => 'Total Time'
}
}
}
charts.forEach(c => c.destroy())
function shouldUpdate(index) {
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
}
let projectChart = !projectsCanvas.classList.contains('hidden')
charts
.filter((c, i) => shouldUpdate(i))
.forEach(c => c.destroy())
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
? new Chart(projectsCanvas.getContext('2d'), {
type: 'horizontalBar',
data: {
datasets: wakapiData.projects
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => {
return {
label: p.key,
@ -87,18 +102,18 @@ function draw() {
})
: null
let osChart = !osCanvas.classList.contains('hidden')
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
? new Chart(osCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => p.key)
},
options: {
@ -109,18 +124,18 @@ function draw() {
})
: null
let editorChart = !editorsCanvas.classList.contains('hidden')
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
? new Chart(editorsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
}],
labels: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => p.key)
},
options: {
@ -131,18 +146,18 @@ function draw() {
})
: null
let languageChart = !languagesCanvas.classList.contains('hidden')
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
? new Chart(languagesCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
}],
labels: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => p.key)
},
options: {
@ -153,18 +168,18 @@ function draw() {
})
: null
let machineChart = !machinesCanvas.classList.contains('hidden')
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
? new Chart(machinesCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
}],
labels: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => p.key)
},
options: {
@ -179,13 +194,14 @@ function draw() {
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()
if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()
}
}
function setTopLabels() {
[...document.getElementsByClassName('top-label')]
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
function parseTopN() {
showTopN = topNPickers.map(e => parseInt(e.value))
}
function togglePlaceholders(mask) {
@ -203,7 +219,7 @@ function togglePlaceholders(mask) {
}
function getPresentDataMask() {
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
}
function getContainer(chart) {
@ -242,8 +258,8 @@ function equalizeHeights() {
})
}
function getTotal(data) {
let total = data.reduce((acc, d) => acc + d.total, 0)
function getTotal(items) {
let total = items.reduce((acc, d) => acc + d.total, 0)
document.getElementById('total-span').innerText = total.toString().toHHMMSS()
}
@ -300,7 +316,13 @@ window.addEventListener('click', function (event) {
})
window.addEventListener('load', function () {
setTopLabels()
topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()
draw([parseInt(e.attributes['data-entity'].value)])
}))
parseTopN()
togglePlaceholders(getPresentDataMask())
draw()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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