mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
254 Commits
Author | SHA1 | Date | |
---|---|---|---|
4dd77ded26 | |||
0bccbffd80 | |||
2b45b064eb | |||
5d8fc99b93 | |||
8231d76200 | |||
c6fd43a964 | |||
4ab657ebd5 | |||
0a07ac1dd4 | |||
a64201c93b | |||
b105b0fe1c | |||
649c658923 | |||
bc9191a514 | |||
04690d287d | |||
c142b525a4 | |||
304fa3b03f | |||
e01e6575db | |||
75e61c0dc3 | |||
6973743f41 | |||
26ef93c1af | |||
0556efd39a | |||
030181fb2f | |||
8b9a9a1a42 | |||
6576837396 | |||
1a10a4fb21 | |||
0e3ce1e9e4 | |||
50a54bde22 | |||
53f3a9d685 | |||
c37278e660 | |||
e2deadfd44 | |||
ed35e7b82d | |||
b672859021 | |||
d3713017e3 | |||
dca736752e | |||
337b39481b | |||
b9ea6530f9 | |||
a9739a6db0 | |||
a22836a644 | |||
c8e7fb461a | |||
c2b099378a | |||
20dd4cf0ab | |||
f8e1453754 | |||
fbd90d2cc1 | |||
129e208169 | |||
9fd9ffbb3d | |||
0884f620f1 | |||
7ab9c45f4f | |||
915436822b | |||
0f1d1bce4d | |||
6256c8e10a | |||
2a9fbfdfd7 | |||
56247b4e1e | |||
9d7afde6a9 | |||
0df0168584 | |||
a6fe15d69b | |||
ae363c1c82 | |||
127a614190 | |||
b8cefeb595 | |||
ae97095688 | |||
4706809170 | |||
ddc29f0414 | |||
f4af787ecf | |||
da6a00fec5 | |||
6ad33e3c3b | |||
e6e134678a | |||
1783858854 | |||
e1d040bd55 | |||
7f3a654b26 | |||
2b57da224c | |||
01d51b78b1 | |||
6b83600acc | |||
65bbd744b5 | |||
81ca703501 | |||
2d1010e9d9 | |||
5ca9a6a8be | |||
caf87de887 | |||
9fc3c65efe | |||
f73285160d | |||
1f557d562f | |||
3685f3a156 | |||
b3afe9bfa2 | |||
9de2c20885 | |||
2846748b26 | |||
f2f6fe1483 | |||
17ddd7ca76 | |||
292ae41c58 | |||
4f86f67716 | |||
017530ac4a | |||
81d3251856 | |||
16af17fc37 | |||
701ed0a3e1 | |||
218c93e975 | |||
44de057022 | |||
e55adf6287 | |||
56800be8e8 | |||
c149766ecc | |||
759e8e4dfd | |||
708863fd33 | |||
e2f046a83d | |||
30510591eb | |||
daf67b844a | |||
6b0b3bddda | |||
ef17d06763 | |||
301cab4be4 | |||
703805412b | |||
88eb68b1a9 | |||
8191a52ce1 | |||
5b3e88247e | |||
59b85863cc | |||
22fbfceca2 | |||
4d7fc6bff9 | |||
218c571859 | |||
e4c413a33c | |||
66b01c2797 | |||
0cee7496e0 | |||
e571e5266d | |||
b0480356de | |||
b1c1f14e35 | |||
9e5847b66d | |||
bb1d6c048d | |||
8fc39f23fa | |||
97a2fadf92 | |||
6f30272b8c | |||
11fbce58d4 | |||
6d2697ec37 | |||
2f5973cfa3 | |||
77050f23f2 | |||
6b1f1c1360 | |||
fca12f522f | |||
d1dc73b5e6 | |||
8fed606e9b | |||
9ff35b85d0 | |||
8ba3fdcaad | |||
161e375f74 | |||
da3c80362c | |||
e1906abd38 | |||
fd9e2acdf1 | |||
d9e163bf73 | |||
3a7f2918f4 | |||
d728426b45 | |||
22260ceb0d | |||
38ae41611f | |||
242928aba5 | |||
82e9244cdc | |||
aef0c929df | |||
9cb9747e2e | |||
68a17950ef | |||
a2368ff76a | |||
4838300086 | |||
a60c725d38 | |||
8ceef42ad4 | |||
8bed266110 | |||
a7afd73e62 | |||
1dc5be4784 | |||
b6812ddc3a | |||
4f7cc3c57e | |||
c6139e5366 | |||
28269aa329 | |||
b7ae15496d | |||
f483488dd5 | |||
0c3f3b37b0 | |||
dc1a0c7983 | |||
e4b38d3f51 | |||
665ffe8bd1 | |||
3e5a51c272 | |||
979549448c | |||
105f96ff72 | |||
31013ad986 | |||
db4cb92c26 | |||
779108ad88 | |||
61f8a22cff | |||
179a107c2a | |||
ef0c76e2ff | |||
617d9ad7e4 | |||
fd239e4f21 | |||
417d4789ab | |||
a6aff07b21 | |||
b732eea9b7 | |||
71d1b2177b | |||
b2a3579be9 | |||
42a6e9d923 | |||
1f44ccadba | |||
6ea72c6d02 | |||
d93348842a | |||
fb92747129 | |||
4e6e665e19 | |||
a3d8c4d464 | |||
e9eaa9da53 | |||
5adb795f59 | |||
a552073d18 | |||
de0401d4bb | |||
c39538db13 | |||
189a09d91f | |||
d57c02af7c | |||
16b683fcbd | |||
acda62488d | |||
1aecfc4ca3 | |||
cd97976ed5 | |||
3a4504d56a | |||
a018f70c3f | |||
a03e49e7f0 | |||
ec81d9fe5d | |||
b7a1e2d795 | |||
98b62b33c8 | |||
262bee9022 | |||
9766d8e903 | |||
39c4777fc8 | |||
143c80b7b4 | |||
72e42a9c42 | |||
439a87dec9 | |||
e8067bb13e | |||
219e969957 | |||
e610bb3ee3 | |||
889edd7a33 | |||
4161623c24 | |||
67fe6eea56 | |||
095fef4868 | |||
a0e64ca955 | |||
903defca99 | |||
16b9aa2282 | |||
4a78f66778 | |||
f4328c452f | |||
e806e5455e | |||
97e1fb27eb | |||
ad8168801c | |||
35cdc7b485 | |||
664714de8f | |||
7befb82814 | |||
2f12d8efde | |||
8ddd9904a0 | |||
78874566a4 | |||
e269b37b0e | |||
e6a04cc76d | |||
cb8f68df82 | |||
b4d2ee7d16 | |||
1224024913 | |||
8efc3854ab | |||
755cabb5f4 | |||
96ff490d8d | |||
68e66298b8 | |||
c2d30826f6 | |||
861c81e414 | |||
892d265c4d | |||
e19761337f | |||
3f973a28ea | |||
86fc751e58 | |||
178c417757 | |||
395d039d41 | |||
fdf2289f8e | |||
06b3fdd17c | |||
4506493353 | |||
11728b80ac | |||
b7c7817923 | |||
c78ee5465c | |||
4336d732c9 |
@ -1 +1,10 @@
|
||||
.env
|
||||
.env
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
*.db
|
||||
*.exe
|
||||
wakapi
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.git*
|
||||
|
42
.github/workflows/docker.yml
vendored
Normal file
42
.github/workflows/docker.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*.*.*-*'
|
||||
|
||||
jobs:
|
||||
docker-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://stackoverflow.com/questions/58177786
|
||||
- name: Get version
|
||||
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
n1try/wakapi:${{ env.GIT_TAG }}
|
||||
n1try/wakapi:latest
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
26
.github/workflows/linux-build-on-release.yml
vendored
26
.github/workflows/linux-build-on-release.yml
vendored
@ -1,20 +1,23 @@
|
||||
name: Build Wakapi on Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and add to Release
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -22,18 +25,19 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get -v -t -d ./...
|
||||
|
||||
go get
|
||||
|
||||
- name: Build
|
||||
run: GO111MODULE=on go build -v .
|
||||
|
||||
- name: Zip Release
|
||||
uses: TheDoctor0/zip-release@v0.3.0
|
||||
with:
|
||||
filename: release.zip
|
||||
exclusions: '*.git*'
|
||||
- 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 }}
|
||||
|
23
.github/workflows/win-build-on-release.yml
vendored
23
.github/workflows/win-build-on-release.yml
vendored
@ -1,20 +1,23 @@
|
||||
name: Build Wakapi on Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and add to release
|
||||
name: Build
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -22,18 +25,22 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get -v -t -d ./...
|
||||
go get
|
||||
|
||||
- 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 }}
|
||||
|
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,10 +1,14 @@
|
||||
launch.json
|
||||
.vscode
|
||||
.env
|
||||
wakapi
|
||||
.idea
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
config.yml
|
||||
config.ini
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
pkged.go
|
||||
package.json
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
node_modules
|
54
Dockerfile
54
Dockerfile
@ -1,27 +1,37 @@
|
||||
# Build Stage
|
||||
|
||||
FROM golang:1.13 AS build-env
|
||||
ADD . /src
|
||||
RUN cd /src && go build -o wakapi
|
||||
FROM golang:1.16 AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
|
||||
# Final Stage
|
||||
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
|
||||
chmod +x wait-for-it.sh
|
||||
|
||||
ADD . .
|
||||
RUN go 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 . && \
|
||||
cp /src/entrypoint.sh .
|
||||
|
||||
# Run Stage
|
||||
|
||||
# When running the application using `docker run`, you can pass environment variables
|
||||
# to override config values 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
|
||||
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
||||
|
||||
FROM debian
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y ca-certificates
|
||||
|
||||
# See README.md and config.default.yml for all config options
|
||||
ENV ENVIRONMENT prod
|
||||
ENV WAKAPI_DB_TYPE sqlite3
|
||||
ENV WAKAPI_DB_USER ''
|
||||
@ -29,20 +39,12 @@ 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'
|
||||
ENV WAKAPI_ALLOW_SIGNUP 'true'
|
||||
|
||||
COPY --from=build-env /src/wakapi /app/
|
||||
COPY --from=build-env /src/config.default.yml /app/config.yml
|
||||
COPY --from=build-env /src/version.txt /app/
|
||||
|
||||
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml
|
||||
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
|
||||
|
||||
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 ./entrypoint.sh
|
||||
|
382
README.md
382
README.md
@ -1,138 +1,348 @@
|
||||
# 📈 wakapi
|
||||
<p align="center">
|
||||
<img src="static/assets/images/logo-gh.svg" width="350">
|
||||
</p>
|
||||
|
||||
[](https://liberapay.com/muety/)
|
||||
[](https://saythanks.io/to/n1try)
|
||||

|
||||
[](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||

|
||||
<p align="center">
|
||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
|
||||
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
||||
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
|
||||
</p>
|
||||
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
---
|
||||
<p align="center">
|
||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
|
||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
||||
</p>
|
||||
|
||||
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
|
||||
<h3 align="center">A minimalist, self-hosted WakaTime-compatible backend for coding statistics.</h3>
|
||||
|
||||

|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="https://wakapi.dev">Website</a>
|
||||
<span> | </span>
|
||||
<a href="#-features">Features</a>
|
||||
<span> | </span>
|
||||
<a href="#-how-to-use">How to use</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/muety/wakapi/issues">Issues</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/muety">Contact</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
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!
|
||||
<p align="center">
|
||||
<img src="static/assets/images/screenshot.png" width="500px">
|
||||
</p>
|
||||
|
||||
## 👀 Demo
|
||||
🔥 **New:** There is a hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
|
||||
## Table of Contents
|
||||
* [User Survey](#-user-survey)
|
||||
* [Features](#-features)
|
||||
* [Roadmap](#-roadmap)
|
||||
* [How to use](#-how-to-use)
|
||||
* [Configuration Options](#-configuration-options)
|
||||
* [API Endpoints](#-api-endpoints)
|
||||
* [Integrations](#-integrations)
|
||||
* [Best Practices](#-best-practices)
|
||||
* [Developer Notes](#-developer-notes)
|
||||
* [Support](#-support)
|
||||
* [FAQs](#-faqs)
|
||||
|
||||
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 ❕
|
||||
Further instructions can be found in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||
|
||||
## ⚙️ Prerequisites
|
||||
**On the server side:**
|
||||
* Go >= 1.13 (with `$GOPATH` properly set)
|
||||
## 📬 **User Survey**
|
||||
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
||||
|
||||
## 🚀 Features
|
||||
* ✅ 100 % free and open-source
|
||||
* ✅ Built by developers for developers
|
||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||
* ✅ Badges
|
||||
* ✅ REST API
|
||||
* ✅ Partially compatible with WakaTime
|
||||
* ✅ WakaTime integration
|
||||
* ✅ Support for Prometheus exports
|
||||
* ✅ Lightning fast
|
||||
* ✅ Self-hosted
|
||||
|
||||
## 🚧 Roadmap
|
||||
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#80](https://github.com/muety/wakapi/issues/80), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
|
||||
|
||||
## ⌨️ How to use?
|
||||
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
||||
|
||||
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
|
||||
If you want to you out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below).
|
||||
|
||||
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||
|
||||
### 📦 Option 2: Quick-run a Release
|
||||
```bash
|
||||
$ curl -L https://wakapi.dev/get | bash
|
||||
```
|
||||
|
||||
### 🐳 Option 3: Use Docker
|
||||
```bash
|
||||
# Create a persistent volume
|
||||
$ docker volume create wakapi-data
|
||||
|
||||
# Run the container
|
||||
$ 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)" \
|
||||
-v wakapi-data:/data \
|
||||
--name wakapi n1try/wakapi
|
||||
```
|
||||
|
||||
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
|
||||
|
||||
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
|
||||
|
||||
### 🧑💻 Option 4: Compile and run from source
|
||||
#### Prerequisites
|
||||
* Go >= 1.16 (with `$GOPATH` properly set)
|
||||
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||
* Fedora / RHEL: `dnf install @development-tools`
|
||||
* Ubuntu / Debian: `apt install build-essential`
|
||||
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
|
||||
* _Optional_: A MySQL- or Postgres database
|
||||
|
||||
**On your local machine:**
|
||||
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Build the executable
|
||||
$ go build -o wakapi
|
||||
|
||||
## ⌨️ Server Setup
|
||||
### Run from source
|
||||
1. Clone the project
|
||||
1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
|
||||
1. Build executable: `GO111MODULE=on go build`
|
||||
1. Run server: `./wakapi`
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
|
||||
**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` 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
|
||||
# Run it
|
||||
$ ./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 [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
|
||||
**Note:** Check the comments `config.yml` for best practices regarding security configuration and more.
|
||||
|
||||
## 🔧 Configuration
|
||||
### 💻 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)
|
||||
2. **Editing your local `~/.wakatime.cfg`** file as follows
|
||||
|
||||
```ini
|
||||
[settings]
|
||||
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
|
||||
api_url = http://localhost:3000/api/heartbeat
|
||||
|
||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
```
|
||||
|
||||
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
|
||||
|
||||
## 🔧 Configuration Options
|
||||
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
|
||||
|
||||
| YAML Key | Environment Variable | Default | Description |
|
||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
|
||||
| `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` | Network address 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 |
|
||||
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
|
||||
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
|
||||
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
|
||||
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
|
||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
||||
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of sqlite3, mysql, postgres) |
|
||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
||||
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
||||
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
||||
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
||||
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
||||
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
|
||||
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details |
|
||||
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
||||
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
||||
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||
| `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
||||
|
||||
## 💻 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.
|
||||
### 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_)
|
||||
|
||||
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
|
||||
2. Make your local WakaTime client talk to Wakapi by **editing your local `~/.wakatime.cfg`** file as follows
|
||||
|
||||
```
|
||||
api_url = https://your.server:someport/api/heartbeat`
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
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**
|
||||
### Client-side proxy (`optional`)
|
||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
||||
|
||||
## 🔧 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)).
|
||||
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
||||
|
||||
* `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/health`
|
||||
### Generating Swagger docs
|
||||
```bash
|
||||
$ go get -u github.com/swaggo/swag/cmd/swag
|
||||
$ swag init -o static/docs
|
||||
```
|
||||
|
||||
## ⤴️ 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)**.
|
||||
## 🤝 Integrations
|
||||
### Prometheus Export
|
||||
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
|
||||
|
||||
[](https://github.com/MacroPower/wakatime_exporter)
|
||||
```bash
|
||||
# 1. Start Wakapi with the feature enabled
|
||||
$ export WAKAPI_EXPOSE_METRICS=true
|
||||
$ ./wakapi
|
||||
|
||||
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
|
||||
# 2. Get your API key and hash it
|
||||
$ echo "<YOUR_API_KEY>" | base64
|
||||
|
||||
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
|
||||
# 3. Add a Prometheus scrape config to your prometheus.yml (see below)
|
||||
```
|
||||
|
||||
## 🏷 Badges
|
||||
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
|
||||
#### Scrape config example
|
||||
```yml
|
||||
# prometheus.yml
|
||||
# (assuming your Wakapi instance listens at localhost, port 3000)
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'wakapi'
|
||||
scrape_interval: 1m
|
||||
metrics_path: '/api/metrics'
|
||||
bearer_token: '<YOUR_BASE64_HASHED_TOKEN>'
|
||||
static_configs:
|
||||
- targets: ['localhost:3000']
|
||||
```
|
||||
|
||||
#### Grafana
|
||||
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
|
||||
|
||||

|
||||
|
||||
### WakaTime Integration
|
||||
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
|
||||
|
||||
### GitHub Readme Stats Integrations
|
||||
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example.
|
||||
|
||||

|
||||
|
||||
## 👍 Best Practices
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
||||
|
||||
## ⚠️ 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!**
|
||||
## 🤓 Developer Notes
|
||||
### Running tests
|
||||
```bash
|
||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
### Building web assets
|
||||
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.
|
||||
|
||||
#### TailwindCSS
|
||||
```bash
|
||||
$ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css
|
||||
```
|
||||
|
||||
#### Iconify
|
||||
```bash
|
||||
$ yarn add -D @iconify/json-tools @iconify/json
|
||||
$ node scripts/bundle_icons.js
|
||||
```
|
||||
|
||||
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
||||
|
||||
## 🙏 Support
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
|
||||
## ❔ FAQs
|
||||
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
|
||||
|
||||
<details>
|
||||
<summary><b>What data is sent to Wakapi?</b></summary>
|
||||
|
||||
<ul>
|
||||
<li>File names</li>
|
||||
<li>Project names</li>
|
||||
<li>Editor names</li>
|
||||
<li>You computer's host name</li>
|
||||
<li>Timestamps for every action you take in your editor</li>
|
||||
<li>...</li>
|
||||
</ul>
|
||||
|
||||
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
|
||||
|
||||
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens if I'm offline?</b></summary>
|
||||
|
||||
All data is cached locally on your machine and sent in batches once you're online again.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How did Wakapi come about?</b></summary>
|
||||
|
||||
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing)">9 $ a month</a> back then. Luckily, most parts of WakaTime are open source!
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How does Wakapi compare to WakaTime?</b></summary>
|
||||
|
||||
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi, include:
|
||||
|
||||
<ul>
|
||||
<li>Leaderboards</li>
|
||||
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
|
||||
<li>Personal Goals</li>
|
||||
<li>Team- / Organization Support</li>
|
||||
<li>Integrations (with GitLab, etc.)</li>
|
||||
<li>Richer API</li>
|
||||
</ul>
|
||||
|
||||
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How are durations calculated?</b></summary>
|
||||
|
||||
Inferring a measure for your coding time from heartbeats works a bit different than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat, that occurs after a longer pause, with 2 extra minutes.
|
||||
|
||||
Here is an example (circles are heartbeats):
|
||||
|
||||
```
|
||||
|---o---o--------------o---o---|
|
||||
| |10s| 3m |10s| |
|
||||
|
||||
```
|
||||
|
||||
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen find a solution, but not actually typing code.
|
||||
|
||||
<ul>
|
||||
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
|
||||
<li><b>WakaTime</b> (with 2 min timeout): 20 sec
|
||||
<li><b>Wakapi:</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
|
||||
</ul>
|
||||
|
||||
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
||||
</details>
|
||||
|
||||
## 🙏 Thanks
|
||||
I highly appreciate the efforts of [@alanhamlett](https://github.com/alanhamlett) and the WakaTime team and am thankful for their software being open source.
|
||||
|
||||
## 📓 License
|
||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||
|
@ -1,26 +1,58 @@
|
||||
env: development
|
||||
env: production
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||
tls_cert_path: # leave blank to not use https
|
||||
tls_key_path: # leave blank to not use https
|
||||
port: 3000
|
||||
base_path: /
|
||||
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
||||
|
||||
app:
|
||||
cleanup: false # only edit, if you know what you're doing
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
svelte: Svelte
|
||||
|
||||
db:
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
user: # leave blank when using sqlite3
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
max_conn: 2
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
user: # leave blank when using sqlite3
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
insecure_cookies: false
|
||||
password_salt: # change this
|
||||
insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
|
||||
cookie_max_age: 172800
|
||||
allow_signup: true
|
||||
expose_metrics: false
|
||||
|
||||
sentry:
|
||||
dsn: # leave blank to disable sentry integration
|
||||
enable_tracing: true # whether to use performance monitoring
|
||||
sample_rate: 0.75 # probability of tracing a request
|
||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||
|
||||
mail:
|
||||
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
||||
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
||||
smtp: # smtp settings when sending mails via smtp
|
||||
host:
|
||||
port:
|
||||
username:
|
||||
password:
|
||||
tls:
|
||||
sender: Wakapi <noreply@wakapi.dev>
|
||||
mailwhale: # mailwhale.dev settings when using mailwhale as sending service
|
||||
url:
|
||||
client_id:
|
||||
client_secret:
|
||||
|
327
config/config.go
327
config/config.go
@ -3,56 +3,132 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/data"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "config.yml"
|
||||
defaultConfigPathLegacy = "config.ini"
|
||||
defaultEnvConfigPathLegacy = ".env"
|
||||
defaultConfigPath = "config.yml"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
KeyLastImportImport = "last_import"
|
||||
|
||||
SimpleDateFormat = "2006-01-02"
|
||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||
|
||||
ErrUnauthorized = "401 unauthorized"
|
||||
ErrInternalServerError = "500 internal server error"
|
||||
)
|
||||
|
||||
var (
|
||||
cfg *Config
|
||||
cFlag *string
|
||||
const (
|
||||
WakatimeApiUrl = "https://wakatime.com/api/v1"
|
||||
WakatimeApiUserUrl = "/users/current"
|
||||
WakatimeApiAllTimeUrl = "/users/current/all_time_since_today"
|
||||
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
|
||||
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
|
||||
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
|
||||
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
|
||||
)
|
||||
|
||||
const (
|
||||
MailProviderSmtp = "smtp"
|
||||
MailProviderMailWhale = "mailwhale"
|
||||
)
|
||||
|
||||
var emailProviders = []string{
|
||||
MailProviderSmtp,
|
||||
MailProviderMailWhale,
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||
var env string
|
||||
|
||||
type appConfig struct {
|
||||
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
LanguageColors map[string]string `yaml:"-"`
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
}
|
||||
|
||||
type securityConfig struct {
|
||||
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
|
||||
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
|
||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||
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 `default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Host string `env:"WAKAPI_DB_HOST"`
|
||||
Port uint `env:"WAKAPI_DB_PORT"`
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||
Dialect string `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||
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"`
|
||||
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
|
||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
||||
}
|
||||
|
||||
type sentryConfig struct {
|
||||
Dsn string `env:"WAKAPI_SENTRY_DSN"`
|
||||
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
|
||||
SampleRate float32 `yaml:"sample_rate" default:"0.75" env:"WAKAPI_SENTRY_SAMPLE_RATE"`
|
||||
SampleRateHeartbeats float32 `yaml:"sample_rate_heartbeats" default:"0.1" env:"WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS"`
|
||||
}
|
||||
|
||||
type mailConfig struct {
|
||||
Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"`
|
||||
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
|
||||
MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
|
||||
Smtp SMTPMailConfig `yaml:"smtp"`
|
||||
}
|
||||
|
||||
type MailwhaleMailConfig struct {
|
||||
Url string `env:"WAKAPI_MAIL_MAILWHALE_URL"`
|
||||
ClientId string `yaml:"client_id" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_ID"`
|
||||
ClientSecret string `yaml:"client_secret" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET"`
|
||||
}
|
||||
|
||||
type SMTPMailConfig struct {
|
||||
Host string `env:"WAKAPI_MAIL_SMTP_HOST"`
|
||||
Port uint `env:"WAKAPI_MAIL_SMTP_PORT"`
|
||||
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
|
||||
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
|
||||
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
|
||||
Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@ -62,104 +138,114 @@ type Config struct {
|
||||
Security securityConfig
|
||||
Db dbConfig
|
||||
Server serverConfig
|
||||
Sentry sentryConfig
|
||||
Mail mailConfig
|
||||
}
|
||||
|
||||
func init() {
|
||||
cFlag = flag.String("c", defaultConfigPath, "config file location")
|
||||
flag.Parse()
|
||||
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 {
|
||||
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(&models.Alias{})
|
||||
db.AutoMigrate(&models.Summary{})
|
||||
db.AutoMigrate(&models.SummaryItem{})
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
|
||||
db.AutoMigrate(&models.SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.FileMigrationSource{
|
||||
Dir: "migrations/common/fixtures",
|
||||
}
|
||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (c *appConfig) GetLanguageColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["languages"], true)
|
||||
}
|
||||
|
||||
log.Printf("applied %d fixtures\n", n)
|
||||
return nil
|
||||
}
|
||||
func (c *appConfig) GetEditorColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["editors"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetOSColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||
}
|
||||
|
||||
func (c *serverConfig) GetPublicUrl() string {
|
||||
return strings.TrimSuffix(c.PublicUrl, "/")
|
||||
}
|
||||
|
||||
func (c *SMTPMailConfig) ConnStr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
|
||||
func 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 readLanguageColors() map[string]string {
|
||||
func readColors() map[string]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"`
|
||||
// Source:
|
||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// – https://wakatime.com/colors/operating_systems
|
||||
// - https://wakatime.com/colors/editors
|
||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
|
||||
raw := data.ColorsFile
|
||||
if IsDev(env) {
|
||||
raw, _ = ioutil.ReadFile("data/colors.json")
|
||||
}
|
||||
|
||||
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
|
||||
var colors = make(map[string]map[string]string)
|
||||
if err := json.Unmarshal(raw, &colors); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return colors
|
||||
@ -167,12 +253,27 @@ func readLanguageColors() map[string]string {
|
||||
|
||||
func mustReadConfigLocation() string {
|
||||
if _, err := os.Stat(*cFlag); err != nil {
|
||||
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
|
||||
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
|
||||
}
|
||||
|
||||
return *cFlag
|
||||
}
|
||||
|
||||
func resolveDbDialect(dbType string) string {
|
||||
if dbType == "cockroach" {
|
||||
return "postgres"
|
||||
}
|
||||
return dbType
|
||||
}
|
||||
|
||||
func findString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func Set(config *Config) {
|
||||
cfg = config
|
||||
}
|
||||
@ -181,18 +282,19 @@ func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
func Load(version string) *Config {
|
||||
config := &Config{}
|
||||
|
||||
maybeMigrateLegacyConfig()
|
||||
flag.Parse()
|
||||
|
||||
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
|
||||
log.Fatalf("failed to read config: %v\n", err)
|
||||
logbuch.Fatal("failed to read config: %v", err)
|
||||
}
|
||||
|
||||
config.Version = readVersion()
|
||||
config.App.LanguageColors = readLanguageColors()
|
||||
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
|
||||
env = config.Env
|
||||
config.Version = strings.TrimSpace(version)
|
||||
config.App.Colors = readColors()
|
||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
@ -208,6 +310,23 @@ func Load() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
||||
}
|
||||
|
||||
if config.Db.MaxConn <= 0 {
|
||||
logbuch.Fatal("you must allow at least one database connection")
|
||||
}
|
||||
|
||||
if config.Sentry.Dsn != "" {
|
||||
logbuch.Info("enabling sentry integration")
|
||||
initSentry(config.Sentry, config.IsDev())
|
||||
}
|
||||
|
||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
}
|
||||
|
67
config/config_test.go
Normal file
67
config/config_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
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",
|
||||
Charset: "utf8mb4",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&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))
|
||||
}
|
86
config/db.go
Normal file
86
config/db.go
Normal file
@ -0,0 +1,86 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
/*
|
||||
A quick note to myself including some clarifications about time zones.
|
||||
|
||||
- There are basically four time zones (at least in case of MySQL): (1) User, (2) Wakapi (host system), (3) MySQL server, (4) MySQL session
|
||||
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
|
||||
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps))
|
||||
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
|
||||
- Setting a `loc` parameter specifies what location parsed time.Time objects will be in, however, does not affect the session time zone setting (https://github.com/go-sql-driver/mysql#loc)
|
||||
- I.e., when not setting `time_zone` in addition, the session time zone will probably default to the server time zone (UTC in case of Docker)
|
||||
- Session time zone will result in conversions of inserted times from that time zone to UTC
|
||||
- From my understanding, TIMESTAMP only stores a plain time value without tz information and then converts it only for retrieval to whatever tz is set for the session
|
||||
- E.g., when inserting '2021-04-27 08:26:07' with session tz set to Europe/Berlin and then viewing the database table with UTC tz will return '2021-04-27 06:26:07' instead
|
||||
- Currently, no session tz is set (only loc), so the database server will assume it receives UTC. However, as no tz is set when retrieving the values either, they are also going to be returned just as is and as long as `loc=Local` is set properly, they are parsed in Go code with the correct time zone
|
||||
- As long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
|
||||
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
|
||||
- It is important to have same `time_zone` and `loc` parameters set when sending and receiving, no matter what it is (writing / reading in 'UTC' will yield same results as writing / reading in 'Europe/Berlin')
|
||||
- "The session time zone setting affects display and storage of time values that are zone-sensitive. This includes the values displayed by functions such as NOW() or CURTIME(), and values stored in and retrieved from TIMESTAMP columns. Values for TIMESTAMP columns are converted from the session time zone to UTC for storage, and from UTC to the session time zone for retrieval." (https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html)
|
||||
- Wakapi always uses time.Local for everything, i.e. all times in the database have to be interpreted with that tz
|
||||
- New heartbeats are sent with Python-like Unix timestamps, i.e. are absolute points in time as therefore not subject to any kind of tz issues
|
||||
- E.g. with Wakapi running in Europe/Berlin, 1619379014.7335322 (2021-04-25T19:30:14.733Z (UTC)) will be inserted as 2021-04-25T21:30:14.733+0200 (CEST), but obviously represents the exact same point in time no matter where it originated from
|
||||
- The reason why we need to explicitly care about tzs in the first place is the fact that user's can request their data within intervals and the results should correspond to their tz
|
||||
- Users from California wouldn't have to care about their heartbeats being stored in German time zone
|
||||
- However, they DO care when requesting their summaries
|
||||
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
|
||||
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
|
||||
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
|
||||
*/
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Name,
|
||||
config.Charset,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
sslmode,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
14
config/fs.go
Normal file
14
config/fs.go
Normal file
@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ChooseFS returns a local (DirFS) file system when on 'dev' environment and the given go-embed file system otherwise
|
||||
func ChooseFS(localDir string, embeddedFS fs.FS) fs.FS {
|
||||
if Get().IsDev() {
|
||||
return os.DirFS(localDir)
|
||||
}
|
||||
return embeddedFS
|
||||
}
|
127
config/legacy.go
127
config/legacy.go
@ -1,127 +0,0 @@
|
||||
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 = "sqlite3"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// step 3: instantiate config
|
||||
config := &Config{
|
||||
Env: env,
|
||||
App: appConfig{
|
||||
CleanUp: cleanUp,
|
||||
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
|
||||
}
|
146
config/sentry.go
Normal file
146
config/sentry.go
Normal file
@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// How to: Logging
|
||||
// Use logbuch.[Debug|Info|Warn|Error|Fatal]() by default
|
||||
// Use config.Log().[Debug|Info|Warn|Error|Fatal]() when wanting the log to appear in Sentry as well
|
||||
|
||||
type capturingWriter struct {
|
||||
Writer io.Writer
|
||||
Message string
|
||||
}
|
||||
|
||||
func (c *capturingWriter) Clear() {
|
||||
c.Message = ""
|
||||
}
|
||||
|
||||
func (c *capturingWriter) Write(p []byte) (n int, err error) {
|
||||
c.Message = string(p)
|
||||
return c.Writer.Write(p)
|
||||
}
|
||||
|
||||
// SentryWrapperLogger is a wrapper around a logbuch.Logger that forwards events to Sentry in addition and optionally allows to attach a request context
|
||||
type SentryWrapperLogger struct {
|
||||
*logbuch.Logger
|
||||
req *http.Request
|
||||
outWriter *capturingWriter
|
||||
errWriter *capturingWriter
|
||||
}
|
||||
|
||||
func Log() *SentryWrapperLogger {
|
||||
ow, ew := &capturingWriter{Writer: os.Stdout}, &capturingWriter{Writer: os.Stderr}
|
||||
return &SentryWrapperLogger{
|
||||
Logger: logbuch.NewLogger(ow, ew),
|
||||
outWriter: ow,
|
||||
errWriter: ew,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Request(req *http.Request) *SentryWrapperLogger {
|
||||
l.req = req
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Debug(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Debug(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelDebug)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Info(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Info(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelInfo)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Warn(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Warn(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelWarning)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Error(msg string, params ...interface{}) {
|
||||
l.errWriter.Clear()
|
||||
l.Logger.Error(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelError)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Fatal(msg string, params ...interface{}) {
|
||||
l.errWriter.Clear()
|
||||
l.Logger.Fatal(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelFatal)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
|
||||
event := sentry.NewEvent()
|
||||
event.Level = level
|
||||
event.Message = msg
|
||||
|
||||
if l.req != nil {
|
||||
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
||||
hub := h.(*sentry.Hub)
|
||||
hub.Scope().SetRequest(l.req)
|
||||
if u := getPrincipal(l.req); u != nil {
|
||||
hub.Scope().SetUser(sentry.User{ID: u.ID})
|
||||
}
|
||||
hub.CaptureEvent(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sentry.CaptureEvent(event)
|
||||
}
|
||||
|
||||
func initSentry(config sentryConfig, debug bool) {
|
||||
if err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: config.Dsn,
|
||||
Debug: debug,
|
||||
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
||||
if !config.EnableTracing {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
|
||||
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
||||
txName := hub.Scope().Transaction()
|
||||
|
||||
if strings.HasPrefix(txName, "GET /assets") || strings.HasPrefix(txName, "GET /api/health") {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
if txName == "POST /api/heartbeat" {
|
||||
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
|
||||
}
|
||||
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
|
||||
}),
|
||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
if hint.Context != nil {
|
||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||
if u := getPrincipal(req); u != nil {
|
||||
event.User.ID = u.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return event
|
||||
},
|
||||
}); err != nil {
|
||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getPrincipal(r *http.Request) *models.User {
|
||||
type principalGetter interface {
|
||||
GetPrincipal() *models.User
|
||||
}
|
||||
if p := r.Context().Value("principal"); p != nil {
|
||||
return p.(principalGetter).GetPrincipal()
|
||||
}
|
||||
return nil
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
IndexTemplate = "index.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
IndexTemplate = "index.tpl.html"
|
||||
LoginTemplate = "login.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SetPasswordTemplate = "set-password.tpl.html"
|
||||
ResetPasswordTemplate = "reset-password.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
)
|
||||
|
14
config/utils.go
Normal file
14
config/utils.go
Normal file
@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
func cloneStringMap(m map[string]string, keysToLower bool) map[string]string {
|
||||
m2 := make(map[string]string)
|
||||
for k, v := range m {
|
||||
if keysToLower {
|
||||
k = strings.ToLower(k)
|
||||
}
|
||||
m2[k] = v
|
||||
}
|
||||
return m2
|
||||
}
|
747
coverage/coverage.out
Normal file
747
coverage/coverage.out
Normal file
@ -0,0 +1,747 @@
|
||||
mode: set
|
||||
github.com/muety/wakapi/models/user.go:8.13,10.2 1 1
|
||||
github.com/muety/wakapi/models/user.go:76.36,77.22 1 1
|
||||
github.com/muety/wakapi/models/user.go:80.2,81.16 2 1
|
||||
github.com/muety/wakapi/models/user.go:84.2,84.11 1 1
|
||||
github.com/muety/wakapi/models/user.go:77.22,79.3 1 1
|
||||
github.com/muety/wakapi/models/user.go:81.16,83.3 1 0
|
||||
github.com/muety/wakapi/models/user.go:87.41,90.2 2 1
|
||||
github.com/muety/wakapi/models/user.go:92.43,95.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:97.45,100.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:102.33,107.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:109.41,111.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:113.45,115.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:117.45,119.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:121.39,123.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:125.39,128.2 2 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/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.47,33.21 1 1
|
||||
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 1
|
||||
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 1
|
||||
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 1
|
||||
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 1
|
||||
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 1
|
||||
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
|
||||
github.com/muety/wakapi/models/mail.go:16.44,20.2 3 0
|
||||
github.com/muety/wakapi/models/mail.go:22.44,26.2 3 0
|
||||
github.com/muety/wakapi/models/mail.go:28.32,41.2 1 0
|
||||
github.com/muety/wakapi/models/mail.go:43.41,45.2 1 0
|
||||
github.com/muety/wakapi/models/mail_address.go:15.13,18.2 2 1
|
||||
github.com/muety/wakapi/models/mail_address.go:24.38,26.2 1 0
|
||||
github.com/muety/wakapi/models/mail_address.go:28.35,30.21 2 1
|
||||
github.com/muety/wakapi/models/mail_address.go:36.2,36.11 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:30.21,31.21 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:34.3,34.18 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:31.21,33.4 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:39.35,41.2 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:43.43,45.22 2 0
|
||||
github.com/muety/wakapi/models/mail_address.go:48.2,48.12 1 0
|
||||
github.com/muety/wakapi/models/mail_address.go:45.22,47.3 1 0
|
||||
github.com/muety/wakapi/models/mail_address.go:51.46,53.22 2 1
|
||||
github.com/muety/wakapi/models/mail_address.go:56.2,56.12 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:53.22,55.3 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:59.40,60.22 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:65.2,65.13 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:60.22,61.17 1 1
|
||||
github.com/muety/wakapi/models/mail_address.go:61.17,63.4 1 1
|
||||
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
|
||||
github.com/muety/wakapi/models/summary.go:70.29,72.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:74.37,81.2 6 1
|
||||
github.com/muety/wakapi/models/summary.go:83.35,85.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:87.57,95.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:97.64,99.2 1 0
|
||||
github.com/muety/wakapi/models/summary.go:112.33,117.26 4 1
|
||||
github.com/muety/wakapi/models/summary.go:124.2,124.37 1 1
|
||||
github.com/muety/wakapi/models/summary.go:128.2,131.33 2 1
|
||||
github.com/muety/wakapi/models/summary.go:117.26,118.30 1 1
|
||||
github.com/muety/wakapi/models/summary.go:118.30,120.4 1 1
|
||||
github.com/muety/wakapi/models/summary.go:124.37,126.3 1 0
|
||||
github.com/muety/wakapi/models/summary.go:131.33,137.3 1 1
|
||||
github.com/muety/wakapi/models/summary.go:140.45,145.30 3 1
|
||||
github.com/muety/wakapi/models/summary.go:154.2,154.30 1 1
|
||||
github.com/muety/wakapi/models/summary.go:145.30,146.47 1 1
|
||||
github.com/muety/wakapi/models/summary.go:146.47,147.32 1 1
|
||||
github.com/muety/wakapi/models/summary.go:150.4,150.9 1 1
|
||||
github.com/muety/wakapi/models/summary.go:147.32,149.5 1 1
|
||||
github.com/muety/wakapi/models/summary.go:157.73,159.55 2 1
|
||||
github.com/muety/wakapi/models/summary.go:164.2,164.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:159.55,160.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:160.31,162.4 1 1
|
||||
github.com/muety/wakapi/models/summary.go:167.88,169.55 2 1
|
||||
github.com/muety/wakapi/models/summary.go:177.2,177.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:169.55,170.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:170.31,171.23 1 1
|
||||
github.com/muety/wakapi/models/summary.go:174.4,174.46 1 1
|
||||
github.com/muety/wakapi/models/summary.go:171.23,172.13 1 1
|
||||
github.com/muety/wakapi/models/summary.go:180.70,182.8 2 1
|
||||
github.com/muety/wakapi/models/summary.go:185.2,185.10 1 1
|
||||
github.com/muety/wakapi/models/summary.go:182.8,184.3 1 1
|
||||
github.com/muety/wakapi/models/summary.go:188.71,189.63 1 1
|
||||
github.com/muety/wakapi/models/summary.go:229.2,235.10 6 1
|
||||
github.com/muety/wakapi/models/summary.go:189.63,192.45 2 1
|
||||
github.com/muety/wakapi/models/summary.go:201.3,201.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:208.3,208.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:225.3,225.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:192.45,193.32 1 1
|
||||
github.com/muety/wakapi/models/summary.go:198.4,198.14 1 1
|
||||
github.com/muety/wakapi/models/summary.go:193.32,194.24 1 1
|
||||
github.com/muety/wakapi/models/summary.go:194.24,196.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:201.31,203.60 1 1
|
||||
github.com/muety/wakapi/models/summary.go:203.60,205.5 1 1
|
||||
github.com/muety/wakapi/models/summary.go:208.31,210.60 1 1
|
||||
github.com/muety/wakapi/models/summary.go:210.60,211.55 1 1
|
||||
github.com/muety/wakapi/models/summary.go:211.55,213.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:213.11,221.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:238.33,240.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:242.43,244.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:246.38,248.2 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:36.65,38.46 2 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:38.46,39.108 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:39.108,42.4 2 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:46.50,47.11 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:60.2,60.15 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:64.2,64.12 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:48.22,49.18 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:50.21,51.17 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:52.23,53.19 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:54.17,55.26 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:56.22,57.18 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:60.15,62.3 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:67.37,83.2 1 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:91.41,93.16 2 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:96.2,97.10 2 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:93.16,95.3 1 0
|
||||
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/interval.go:39.47,40.23 1 0
|
||||
github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0
|
||||
github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0
|
||||
github.com/muety/wakapi/models/interval.go:41.13,43.4 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/shared.go:35.52,37.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:39.52,42.16 3 0
|
||||
github.com/muety/wakapi/models/shared.go:45.2,47.12 3 0
|
||||
github.com/muety/wakapi/models/shared.go:42.16,44.3 1 0
|
||||
github.com/muety/wakapi/models/shared.go:50.52,56.22 2 0
|
||||
github.com/muety/wakapi/models/shared.go:71.2,74.12 3 0
|
||||
github.com/muety/wakapi/models/shared.go:57.14,61.17 2 0
|
||||
github.com/muety/wakapi/models/shared.go:64.17,66.8 2 0
|
||||
github.com/muety/wakapi/models/shared.go:67.10,68.64 1 0
|
||||
github.com/muety/wakapi/models/shared.go:61.17,63.4 1 0
|
||||
github.com/muety/wakapi/models/shared.go:77.51,80.2 2 0
|
||||
github.com/muety/wakapi/models/shared.go:82.45,84.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:86.37,88.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:90.35,92.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:94.34,96.2 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:22.35,24.2 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:26.62,29.2 2 0
|
||||
github.com/muety/wakapi/config/sentry.go:39.33,46.2 2 0
|
||||
github.com/muety/wakapi/config/sentry.go:48.79,51.2 2 0
|
||||
github.com/muety/wakapi/config/sentry.go:53.72,57.2 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:59.71,63.2 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:65.71,69.2 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:71.72,75.2 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:77.72,81.2 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:83.67,88.18 4 0
|
||||
github.com/muety/wakapi/config/sentry.go:100.2,100.28 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:88.18,89.65 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:89.65,92.42 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:95.4,96.10 2 0
|
||||
github.com/muety/wakapi/config/sentry.go:92.42,94.5 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:103.50,107.91 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:107.91,108.29 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:112.4,115.96 3 0
|
||||
github.com/muety/wakapi/config/sentry.go:118.4,118.39 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:121.4,121.69 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:108.29,110.5 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:115.96,117.5 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:118.39,120.5 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:123.79,124.27 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:131.4,131.16 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:124.27,125.84 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:125.84,126.42 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:126.42,128.7 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:133.17,135.3 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:138.49,142.51 2 0
|
||||
github.com/muety/wakapi/config/sentry.go:145.2,145.12 1 0
|
||||
github.com/muety/wakapi/config/sentry.go:142.51,144.3 1 0
|
||||
github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0
|
||||
github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0
|
||||
github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0
|
||||
github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0
|
||||
github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:148.70,150.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:152.65,154.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:156.82,166.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:168.31,170.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:172.32,174.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:176.74,177.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:178.10,179.34 1 0
|
||||
github.com/muety/wakapi/config/config.go:179.34,180.90 1 0
|
||||
github.com/muety/wakapi/config/config.go:183.4,183.100 1 0
|
||||
github.com/muety/wakapi/config/config.go:186.4,186.91 1 0
|
||||
github.com/muety/wakapi/config/config.go:189.4,189.95 1 0
|
||||
github.com/muety/wakapi/config/config.go:192.4,192.93 1 0
|
||||
github.com/muety/wakapi/config/config.go:195.4,195.97 1 0
|
||||
github.com/muety/wakapi/config/config.go:198.4,198.101 1 0
|
||||
github.com/muety/wakapi/config/config.go:201.4,201.14 1 0
|
||||
github.com/muety/wakapi/config/config.go:180.90,182.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:183.100,185.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:186.91,188.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:189.95,191.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:192.93,194.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:195.97,197.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:198.101,200.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:206.50,207.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:220.2,220.12 1 0
|
||||
github.com/muety/wakapi/config/config.go:208.23,212.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:213.26,216.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:217.24,218.48 1 0
|
||||
github.com/muety/wakapi/config/config.go:223.53,234.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:236.56,238.16 2 1
|
||||
github.com/muety/wakapi/config/config.go:242.2,249.3 1 1
|
||||
github.com/muety/wakapi/config/config.go:238.16,240.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:252.54,254.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:256.60,258.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:260.59,262.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:264.57,266.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:268.53,270.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:272.46,274.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:276.43,278.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:280.29,282.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:284.48,295.16 2 0
|
||||
github.com/muety/wakapi/config/config.go:299.2,300.53 2 0
|
||||
github.com/muety/wakapi/config/config.go:304.2,304.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:295.16,297.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:300.53,302.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:307.38,308.43 1 0
|
||||
github.com/muety/wakapi/config/config.go:311.2,311.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:308.43,310.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:314.45,315.27 1 0
|
||||
github.com/muety/wakapi/config/config.go:318.2,318.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:315.27,317.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:321.77,322.29 1 0
|
||||
github.com/muety/wakapi/config/config.go:327.2,327.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:322.29,323.18 1 0
|
||||
github.com/muety/wakapi/config/config.go:323.18,325.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:330.26,332.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:334.20,336.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:338.35,343.96 3 0
|
||||
github.com/muety/wakapi/config/config.go:347.2,356.52 6 0
|
||||
github.com/muety/wakapi/config/config.go:360.2,360.47 1 0
|
||||
github.com/muety/wakapi/config/config.go:366.2,366.70 1 0
|
||||
github.com/muety/wakapi/config/config.go:370.2,370.28 1 0
|
||||
github.com/muety/wakapi/config/config.go:374.2,374.29 1 0
|
||||
github.com/muety/wakapi/config/config.go:379.2,379.94 1 0
|
||||
github.com/muety/wakapi/config/config.go:383.2,384.14 2 0
|
||||
github.com/muety/wakapi/config/config.go:343.96,345.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:356.52,358.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:360.47,361.14 1 0
|
||||
github.com/muety/wakapi/config/config.go:361.14,363.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:366.70,368.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:370.28,372.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:374.29,377.3 2 0
|
||||
github.com/muety/wakapi/config/config.go:379.94,381.3 1 0
|
||||
github.com/muety/wakapi/config/fs.go:9.56,10.19 1 0
|
||||
github.com/muety/wakapi/config/fs.go:13.2,13.19 1 0
|
||||
github.com/muety/wakapi/config/fs.go:10.19,12.3 1 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/summary.go:10.66,11.40 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:12.27,14.4 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:19.88,22.2 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:24.95,26.16 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:29.2,29.38 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:26.16,28.3 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:32.105,35.18 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:70.2,70.22 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:36.28,37.26 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:38.32,40.24 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:41.31,42.29 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:43.31,45.27 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:46.32,47.30 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:48.32,50.28 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:51.31,52.29 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:53.32,54.44 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:55.41,57.42 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:58.33,59.45 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:60.33,61.45 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:62.35,63.45 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:64.26,65.21 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:66.10,67.39 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:73.73,80.56 5 0
|
||||
github.com/muety/wakapi/utils/summary.go:96.2,103.8 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:80.56,82.3 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:82.8,82.54 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:82.54,84.3 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:84.8,86.17 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:90.3,91.17 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:86.17,88.4 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:91.17,93.4 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:106.48,110.51 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:113.2,113.12 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:110.51,112.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:37.65,39.85 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:39.85,41.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
|
||||
github.com/muety/wakapi/utils/common.go:18.73,19.58 1 0
|
||||
github.com/muety/wakapi/utils/common.go:22.2,22.87 1 0
|
||||
github.com/muety/wakapi/utils/common.go:25.2,25.64 1 0
|
||||
github.com/muety/wakapi/utils/common.go:19.58,21.3 1 0
|
||||
github.com/muety/wakapi/utils/common.go:22.87,24.3 1 0
|
||||
github.com/muety/wakapi/utils/common.go:28.40,30.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:32.44,34.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:36.45,38.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:40.24,42.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:44.56,47.45 3 1
|
||||
github.com/muety/wakapi/utils/common.go:50.2,50.40 1 1
|
||||
github.com/muety/wakapi/utils/common.go:47.45,49.3 1 1
|
||||
github.com/muety/wakapi/utils/date.go:8.43,10.2 1 1
|
||||
github.com/muety/wakapi/utils/date.go:12.48,14.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:16.51,18.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:20.44,23.2 2 1
|
||||
github.com/muety/wakapi/utils/date.go:25.52,27.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:29.45,31.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:33.51,35.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:37.44,39.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:42.42,44.2 1 1
|
||||
github.com/muety/wakapi/utils/date.go:47.41,49.21 2 1
|
||||
github.com/muety/wakapi/utils/date.go:52.2,52.36 1 1
|
||||
github.com/muety/wakapi/utils/date.go:49.21,51.3 1 1
|
||||
github.com/muety/wakapi/utils/date.go:56.63,58.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:61.62,67.2 5 0
|
||||
github.com/muety/wakapi/utils/date.go:70.67,73.33 2 1
|
||||
github.com/muety/wakapi/utils/date.go:82.2,82.18 1 1
|
||||
github.com/muety/wakapi/utils/date.go:73.33,75.19 2 1
|
||||
github.com/muety/wakapi/utils/date.go:78.3,79.10 2 1
|
||||
github.com/muety/wakapi/utils/date.go:75.19,77.4 1 1
|
||||
github.com/muety/wakapi/utils/date.go:85.50,91.2 5 0
|
||||
github.com/muety/wakapi/utils/date.go:94.79,97.36 3 1
|
||||
github.com/muety/wakapi/utils/date.go:101.2,101.21 1 1
|
||||
github.com/muety/wakapi/utils/date.go:105.2,105.21 1 1
|
||||
github.com/muety/wakapi/utils/date.go:109.2,109.13 1 1
|
||||
github.com/muety/wakapi/utils/date.go:97.36,100.3 2 0
|
||||
github.com/muety/wakapi/utils/date.go:101.21,104.3 2 1
|
||||
github.com/muety/wakapi/utils/date.go:105.21,108.3 2 1
|
||||
github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:16.16,18.3 1 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:21.15,23.47 2 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0
|
||||
github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0
|
||||
github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0
|
||||
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
|
||||
github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0
|
||||
github.com/muety/wakapi/utils/color.go:11.50,13.4 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/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/middlewares/security.go:19.62,20.43 1 0
|
||||
github.com/muety/wakapi/middlewares/security.go:20.43,22.3 1 0
|
||||
github.com/muety/wakapi/middlewares/security.go:25.80,26.36 1 0
|
||||
github.com/muety/wakapi/middlewares/security.go:31.2,31.27 1 0
|
||||
github.com/muety/wakapi/middlewares/security.go:26.36,27.30 1 0
|
||||
github.com/muety/wakapi/middlewares/security.go:27.30,29.4 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:19.91,25.2 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:27.90,30.2 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:32.90,35.2 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:37.71,38.71 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:38.71,40.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:43.107,47.16 3 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:51.2,51.31 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:67.2,68.12 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:47.16,49.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:51.31,52.31 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:57.3,57.29 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:52.31,55.4 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:57.29,60.4 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:71.70,72.39 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:77.2,77.14 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:72.39,73.60 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:73.60,75.4 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:80.92,82.16 2 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:86.2,89.16 4 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:92.2,92.18 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:82.16,84.3 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:89.16,91.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:95.92,97.16 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:101.2,102.16 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:109.2,109.18 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:97.16,99.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:102.16,104.3 1 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:13.83,14.43 1 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:14.43,19.3 1 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:22.84,24.34 2 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:31.2,31.27 1 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:24.34,25.50 1 0
|
||||
github.com/muety/wakapi/middlewares/filetype.go:25.50,29.4 3 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:20.102,21.43 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:21.43,27.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:30.80,39.44 7 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:45.2,54.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:39.44,40.38 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:40.38,42.4 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:57.41,59.14 2 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:62.2,62.14 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:65.2,65.11 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:59.14,61.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:62.14,64.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:68.41,69.42 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:72.2,72.12 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:69.42,71.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:103.52,105.2 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:117.45,118.20 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:118.20,122.3 3 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:124.54,127.18 3 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:134.2,135.15 2 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:127.18,130.17 2 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:130.17,132.4 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:137.42,138.20 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:138.20,140.3 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:142.36,144.2 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:145.42,147.2 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:148.40,150.2 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:151.52,153.2 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:15.62,17.2 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:19.58,21.2 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:42.71,43.43 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:43.43,45.3 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:48.81,51.2 2 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:53.55,54.52 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:54.52,56.3 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:59.49,60.52 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:63.2,63.12 1 0
|
||||
github.com/muety/wakapi/middlewares/principal.go:60.52,62.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:28.149,36.2 1 1
|
||||
github.com/muety/wakapi/services/summary.go:40.136,43.66 2 1
|
||||
github.com/muety/wakapi/services/summary.go:48.2,48.44 1 1
|
||||
github.com/muety/wakapi/services/summary.go:54.2,54.65 1 1
|
||||
github.com/muety/wakapi/services/summary.go:59.2,60.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:65.2,67.30 3 1
|
||||
github.com/muety/wakapi/services/summary.go:43.66,45.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:48.44,51.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:54.65,56.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:60.16,62.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:70.101,73.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:78.2,79.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:84.2,85.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:94.2,95.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:100.2,101.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:73.52,75.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:79.16,81.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:85.44,86.78 1 1
|
||||
github.com/muety/wakapi/services/summary.go:86.78,88.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:88.9,90.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:95.16,97.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:104.102,107.89 2 1
|
||||
github.com/muety/wakapi/services/summary.go:113.2,117.26 4 1
|
||||
github.com/muety/wakapi/services/summary.go:122.2,128.34 6 1
|
||||
github.com/muety/wakapi/services/summary.go:144.2,144.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:149.2,162.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:107.89,109.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:109.8,111.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:117.26,119.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:128.34,130.20 2 1
|
||||
github.com/muety/wakapi/services/summary.go:131.30,132.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:133.31,134.30 1 1
|
||||
github.com/muety/wakapi/services/summary.go:135.29,136.28 1 1
|
||||
github.com/muety/wakapi/services/summary.go:137.25,138.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:139.30,140.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:144.26,147.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:167.76,169.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:171.62,173.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:175.66,177.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:181.127,184.31 2 1
|
||||
github.com/muety/wakapi/services/summary.go:207.2,208.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:216.2,216.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:220.2,220.67 1 1
|
||||
github.com/muety/wakapi/services/summary.go:184.31,187.35 2 1
|
||||
github.com/muety/wakapi/services/summary.go:191.3,191.13 1 1
|
||||
github.com/muety/wakapi/services/summary.go:195.3,200.27 2 1
|
||||
github.com/muety/wakapi/services/summary.go:204.3,204.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:187.35,189.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:191.13,192.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:200.27,203.4 2 1
|
||||
github.com/muety/wakapi/services/summary.go:208.30,214.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:216.40,218.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:223.97,224.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:228.2,242.30 5 1
|
||||
github.com/muety/wakapi/services/summary.go:270.2,273.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:224.24,226.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:242.30,244.41 2 1
|
||||
github.com/muety/wakapi/services/summary.go:249.3,249.38 1 1
|
||||
github.com/muety/wakapi/services/summary.go:253.3,253.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:257.3,257.34 1 1
|
||||
github.com/muety/wakapi/services/summary.go:261.3,267.25 6 1
|
||||
github.com/muety/wakapi/services/summary.go:244.41,246.12 2 1
|
||||
github.com/muety/wakapi/services/summary.go:249.38,251.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:253.37,255.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:257.34,259.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:276.127,280.32 2 1
|
||||
github.com/muety/wakapi/services/summary.go:284.2,284.27 1 1
|
||||
github.com/muety/wakapi/services/summary.go:292.2,294.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:299.2,299.43 1 1
|
||||
github.com/muety/wakapi/services/summary.go:303.2,303.17 1 1
|
||||
github.com/muety/wakapi/services/summary.go:280.32,282.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:284.27,285.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:285.37,287.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:287.9,289.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:294.26,297.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:299.43,301.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:306.116,307.25 1 1
|
||||
github.com/muety/wakapi/services/summary.go:311.2,314.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:319.2,319.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:344.2,344.54 1 1
|
||||
github.com/muety/wakapi/services/summary.go:348.2,348.18 1 1
|
||||
github.com/muety/wakapi/services/summary.go:307.25,309.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:314.44,316.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:319.40,321.19 2 1
|
||||
github.com/muety/wakapi/services/summary.go:328.3,333.34 3 1
|
||||
github.com/muety/wakapi/services/summary.go:338.3,338.22 1 1
|
||||
github.com/muety/wakapi/services/summary.go:321.19,322.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:333.34,335.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:338.22,340.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:344.54,346.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:351.59,353.25 2 1
|
||||
github.com/muety/wakapi/services/summary.go:356.2,356.32 1 1
|
||||
github.com/muety/wakapi/services/summary.go:353.25,355.3 1 1
|
||||
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/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.151 1 0
|
||||
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
|
||||
github.com/muety/wakapi/services/misc.go:82.151,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/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.78,27.16 2 0
|
||||
github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:40.60,42.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/user.go:19.73,25.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
|
||||
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
|
||||
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
|
||||
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
|
||||
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
|
||||
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
|
||||
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:55.76,57.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:59.86,61.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:63.58,65.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:67.61,70.2 2 0
|
||||
github.com/muety/wakapi/services/user.go:72.48,74.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:76.102,86.93 2 0
|
||||
github.com/muety/wakapi/services/user.go:92.2,92.38 1 0
|
||||
github.com/muety/wakapi/services/user.go:86.93,88.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:88.8,90.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:95.73,98.2 2 0
|
||||
github.com/muety/wakapi/services/user.go:100.78,104.2 3 0
|
||||
github.com/muety/wakapi/services/user.go:106.99,109.2 2 0
|
||||
github.com/muety/wakapi/services/user.go:111.106,114.96 3 0
|
||||
github.com/muety/wakapi/services/user.go:119.2,119.68 1 0
|
||||
github.com/muety/wakapi/services/user.go:114.96,116.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:116.8,118.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:122.85,124.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:126.57,129.2 2 0
|
||||
github.com/muety/wakapi/services/user.go:131.38,133.2 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:29.142,37.2 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:46.43,48.37 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:52.2,54.19 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:48.37,50.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:57.67,58.47 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:61.2,66.40 4 0
|
||||
github.com/muety/wakapi/services/aggregation.go:70.2,70.50 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:75.2,75.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:81.2,81.35 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:58.47,60.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:66.40,68.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:70.50,72.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:75.60,79.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:84.109,85.24 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:85.24,86.111 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:86.111,88.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:88.9,91.4 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:95.80,96.33 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:96.33,97.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:97.60,99.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:103.100,107.59 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:122.2,123.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:129.2,130.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:136.2,137.44 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:142.2,142.41 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:156.2,156.12 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:107.59,110.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:110.8,110.47 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:110.47,112.30 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:112.30,113.43 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:113.43,115.5 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:117.8,119.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:123.16,126.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:130.16,133.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:137.44,139.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:142.41,143.21 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:143.21,147.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:147.9,147.62 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:147.62,151.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:159.73,162.27 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:167.2,167.27 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:170.2,170.12 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:162.27,163.39 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:163.39,165.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:167.27,169.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:173.69,176.27 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:176.27,178.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:181.83,196.41 5 0
|
||||
github.com/muety/wakapi/services/aggregation.go:196.41,206.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:209.34,212.2 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:29.80,34.32 3 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:41.2,41.55 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:34.32,35.36 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:35.36,38.4 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:44.53,46.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:48.76,50.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:52.96,54.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:56.111,58.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:61.2,61.43 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:58.16,60.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:64.116,66.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:68.78,70.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:72.62,74.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:76.116,78.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:82.2,82.28 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:86.2,86.24 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:78.16,80.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:82.28,84.3 1 0
|
1805
data/colors.json
1805
data/colors.json
File diff suppressed because it is too large
Load Diff
6
data/data.go
Normal file
6
data/data.go
Normal file
@ -0,0 +1,6 @@
|
||||
package data
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed colors.json
|
||||
var ColorsFile []byte
|
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
wakapi:
|
||||
build: .
|
||||
ports:
|
||||
- 3000:3000
|
||||
restart: always
|
||||
environment:
|
||||
# See README.md and config.default.yml for all config options
|
||||
WAKAPI_DB_TYPE: "postgres"
|
||||
WAKAPI_DB_NAME: "wakapi"
|
||||
WAKAPI_DB_USER: "wakapi"
|
||||
WAKAPI_DB_PASSWORD: "choose-a-password"
|
||||
WAKAPI_DB_HOST: "db"
|
||||
WAKAPI_DB_PORT: "5432"
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
db:
|
||||
image: postgres:12.3
|
||||
environment:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "choose-a-password"
|
||||
POSTGRES_DB: "wakapi"
|
8
entrypoint.sh
Executable file
8
entrypoint.sh
Executable file
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
|
||||
./wakapi
|
||||
else
|
||||
echo "Waiting for database to come up"
|
||||
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||
fi
|
32
go.mod
32
go.mod
@ -1,22 +1,34 @@
|
||||
module github.com/muety/wakapi
|
||||
|
||||
go 1.13
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/emvi/logbuch v1.1.1
|
||||
github.com/getsentry/sentry-go v0.10.0
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
github.com/go-openapi/spec v0.20.2 // indirect
|
||||
github.com/gorilla/handlers v1.4.2
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/jinzhu/gorm v1.9.11
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/pretty v0.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
gopkg.in/yaml.v2 v2.2.5
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/swaggo/swag v1.7.0
|
||||
go.uber.org/atomic v1.6.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
||||
golang.org/x/tools v0.1.0 // indirect
|
||||
gorm.io/driver/mysql v1.0.3
|
||||
gorm.io/driver/postgres v1.0.5
|
||||
gorm.io/driver/sqlite v1.1.3
|
||||
gorm.io/gorm v1.20.11
|
||||
)
|
||||
|
634
go.sum
634
go.sum
@ -1,478 +1,430 @@
|
||||
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/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
|
||||
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cockroachdb/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/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
|
||||
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/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
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/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/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/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/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
|
||||
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
|
||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
|
||||
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
|
||||
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
|
||||
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
|
||||
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
|
||||
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
|
||||
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1 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/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
|
||||
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
|
||||
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
|
||||
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
|
||||
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
|
||||
github.com/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/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/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=
|
||||
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
|
||||
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
|
||||
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/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/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
|
||||
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
|
||||
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
|
||||
github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
|
||||
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
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/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
|
||||
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
|
||||
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/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/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/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
|
||||
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
|
||||
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
|
||||
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
|
||||
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
|
||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
||||
github.com/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/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/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
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-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/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=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/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.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-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/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
|
||||
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
|
||||
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
|
||||
github.com/nats-io/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/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
|
||||
github.com/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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
|
||||
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
|
||||
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
|
||||
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
|
||||
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
|
||||
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
|
||||
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
|
||||
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.8.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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
|
||||
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/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/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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
|
||||
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
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/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.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/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
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/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
|
||||
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/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/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
||||
go.uber.org/atomic v1.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=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/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/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/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=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
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/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/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-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-20190626221950-04f50cda93cb/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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.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/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=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/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-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
|
||||
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/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/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=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.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=
|
||||
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=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
||||
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
|
||||
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
|
||||
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
|
||||
|
346
main.go
346
main.go
@ -1,186 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations/common"
|
||||
"embed"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/routes/api"
|
||||
"github.com/muety/wakapi/services/mail"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/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/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"
|
||||
)
|
||||
|
||||
// Embed version.txt
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
// Embed static files
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
config *conf.Config
|
||||
)
|
||||
|
||||
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
|
||||
mailService services.IMailService
|
||||
keyValueService services.IKeyValueService
|
||||
miscService services.IMiscService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
|
||||
// @title Wakapi API
|
||||
// @version 1.0
|
||||
// @description REST API to interact with [Wakapi](https://wakapi.dev)
|
||||
// @description
|
||||
// @description ## Authentication
|
||||
// @description Set header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`
|
||||
// @description **Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`
|
||||
|
||||
// @contact.name Ferdinand Mütsch
|
||||
// @contact.url https://github.com/muety
|
||||
// @contact.email ferdinand@muetsch.io
|
||||
|
||||
// @license.name GPL-3.0
|
||||
// @license.url https://github.com/muety/wakapi/blob/master/LICENSE
|
||||
|
||||
// @securitydefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @BasePath /api
|
||||
func main() {
|
||||
config = conf.Load()
|
||||
config = conf.Load(version)
|
||||
|
||||
// Enable line numbers in logging
|
||||
// Set log level
|
||||
if config.IsDev() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
logbuch.SetLevel(logbuch.LevelDebug)
|
||||
} else {
|
||||
logbuch.SetLevel(logbuch.LevelInfo)
|
||||
}
|
||||
|
||||
// Show data loss warning
|
||||
if config.App.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
// Set up GORM
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Minute,
|
||||
Colorful: false,
|
||||
LogLevel: logger.Silent,
|
||||
},
|
||||
)
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config))
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||
if config.Db.Dialect == "sqlite3" {
|
||||
db.DB().Exec("PRAGMA foreign_keys = ON;")
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
db.LogMode(config.IsDev())
|
||||
db.DB().SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
db.DB().SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, err := 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")
|
||||
logbuch.Error(err.Error())
|
||||
logbuch.Fatal("could not connect to database")
|
||||
}
|
||||
defer db.Close()
|
||||
defer sqlDb.Close()
|
||||
|
||||
// Migrate database schema
|
||||
runDatabaseMigrations()
|
||||
runCustomMigrations()
|
||||
migrations.Run(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)
|
||||
mailService = mail.NewMailService()
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
// Schedule background tasks
|
||||
go aggregationService.Schedule()
|
||||
go miscService.ScheduleCountTotalTime()
|
||||
|
||||
if config.App.CleanUp {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
routes.Init()
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
// API Handlers
|
||||
healthApiHandler := api.NewHealthApiHandler(db)
|
||||
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
settingsHandler := routes.NewSettingsHandler(userService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
// Compat Handlers
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
|
||||
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
publicRouter := router.PathPrefix("/").Subrouter()
|
||||
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
|
||||
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
|
||||
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
|
||||
rootRouter := router.PathPrefix("/").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
||||
|
||||
// Middlewares
|
||||
recoveryMiddleware := handlers.RecoveryHandler()
|
||||
loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
|
||||
corsMiddleware := handlers.CORS()
|
||||
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
|
||||
userService,
|
||||
[]string{"/api/health", "/api/compat/shields/v1"},
|
||||
).Handler
|
||||
// Globally used middlewares
|
||||
router.Use(middlewares.NewPrincipalMiddleware())
|
||||
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
|
||||
router.Use(handlers.RecoveryHandler())
|
||||
if config.Sentry.Dsn != "" {
|
||||
router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
|
||||
}
|
||||
rootRouter.Use(middlewares.NewSecurityMiddleware())
|
||||
|
||||
// Router configs
|
||||
router.Use(loggingMiddleware, recoveryMiddleware)
|
||||
summaryRouter.Use(authenticateMiddleware)
|
||||
settingsRouter.Use(authenticateMiddleware)
|
||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||
// Route registrations
|
||||
homeHandler.RegisterRoutes(rootRouter)
|
||||
loginHandler.RegisterRoutes(rootRouter)
|
||||
imprintHandler.RegisterRoutes(rootRouter)
|
||||
summaryHandler.RegisterRoutes(rootRouter)
|
||||
settingsHandler.RegisterRoutes(rootRouter)
|
||||
|
||||
// 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)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||
|
||||
// Settings Routes
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
|
||||
// Shields.io compat API Routes
|
||||
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
|
||||
// API route registrations
|
||||
summaryApiHandler.RegisterRoutes(apiRouter)
|
||||
healthApiHandler.RegisterRoutes(apiRouter)
|
||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||
metricsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
// Static Routes
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
|
||||
// https://github.com/golang/go/issues/43431
|
||||
embeddedStatic, _ := fs.Sub(staticFiles, "static")
|
||||
static := conf.ChooseFS("static", embeddedStatic)
|
||||
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
|
||||
router.PathPrefix("/contribute.json").Handler(fileServer)
|
||||
router.PathPrefix("/assets").Handler(fileServer)
|
||||
router.PathPrefix("/swagger-ui").Handler(fileServer)
|
||||
router.PathPrefix("/docs").Handler(
|
||||
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
|
||||
)
|
||||
|
||||
// Listen HTTP
|
||||
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||
s := &http.Server{
|
||||
Handler: router,
|
||||
Addr: portString,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("Listening on %+s\n", portString)
|
||||
s.ListenAndServe()
|
||||
listen(router)
|
||||
}
|
||||
|
||||
func runDatabaseMigrations() {
|
||||
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
func listen(handler http.Handler) {
|
||||
var s4, s6 *http.Server
|
||||
|
||||
func runCustomMigrations() {
|
||||
common.ApplyFixtures(db)
|
||||
common.MigrateLanguages(db)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
if s4 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-make(chan interface{}, 1)
|
||||
}
|
||||
|
@ -1,38 +1,39 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"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"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AuthenticateMiddleware struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
cache *cache.Cache
|
||||
whitelistPaths []string
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
optionalForPaths []string
|
||||
redirectTarget string // optional
|
||||
}
|
||||
|
||||
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
|
||||
return &AuthenticateMiddleware{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
whitelistPaths: whitelistPaths,
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
optionalForPaths: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMiddleware {
|
||||
m.optionalForPaths = paths
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
|
||||
m.redirectTarget = path
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.ServeHTTP(w, r, h.ServeHTTP)
|
||||
@ -40,13 +41,6 @@ func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
for _, p := range m.whitelistPaths {
|
||||
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
user, err := m.tryGetUserByCookie(r)
|
||||
|
||||
@ -54,20 +48,33 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
user, err = m.tryGetUserByApiKey(r)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||
if err != nil || user == nil {
|
||||
if m.isOptional(r.URL.Path) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if m.redirectTarget == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(conf.ErrUnauthorized))
|
||||
} else {
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.cache.Set(user.ID, user, cache.DefaultExpiration)
|
||||
SetPrincipal(r, user)
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), models.UserKey, user)
|
||||
next(w, r.WithContext(ctx))
|
||||
func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
|
||||
for _, p := range m.optionalForPaths {
|
||||
if strings.HasPrefix(requestPath, p) || requestPath == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
|
||||
@ -78,51 +85,26 @@ 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
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||
login, err := utils.ExtractCookieAuth(r, m.config)
|
||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachedUser, ok := m.cache.Get(login.Username)
|
||||
|
||||
if ok {
|
||||
return cachedUser.(*models.User), nil
|
||||
}
|
||||
|
||||
user, err := m.userSrvc.GetUserById(login.Username)
|
||||
user, err := m.userSrvc.GetUserById(*username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
// no need to check password here, as securecookie decoding will fail anyway,
|
||||
// if cookie is not properly signed
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if utils.IsMd5(user.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)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return utils.CompareBcrypt(user.Password, login.Password, salt)
|
||||
}
|
||||
|
56
middlewares/authenticate_test.go
Normal file
56
middlewares/authenticate_test.go
Normal 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)
|
||||
|
||||
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)
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
// TODO: somehow test cookie auth function
|
94
middlewares/custom/wakatime.go
Normal file
94
middlewares/custom/wakatime.go
Normal file
@ -0,0 +1,94 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* 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 := middlewares.GetPrincipal(r)
|
||||
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,
|
||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
|
||||
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 {
|
||||
logbuch.Warn("error constructing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
for _, h := range v {
|
||||
request.Header.Set(k, h)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
logbuch.Warn("error executing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
logbuch.Warn("failed to relay request, got status %d", response.StatusCode)
|
||||
}
|
||||
}
|
32
middlewares/filetype.go
Normal file
32
middlewares/filetype.go
Normal file
@ -0,0 +1,32 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SuffixFilterMiddleware struct {
|
||||
handler http.Handler
|
||||
filterTypes []string
|
||||
}
|
||||
|
||||
func NewFileTypeFilterMiddleware(filter []string) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &SuffixFilterMiddleware{
|
||||
handler: h,
|
||||
filterTypes: filter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *SuffixFilterMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.ToLower(r.URL.Path)
|
||||
for _, t := range f.filterTypes {
|
||||
if strings.HasSuffix(path, strings.ToLower(t)) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("403 forbidden"))
|
||||
return
|
||||
}
|
||||
}
|
||||
f.handler.ServeHTTP(w, r)
|
||||
}
|
@ -1,17 +1,153 @@
|
||||
package middlewares
|
||||
|
||||
// Borrowed from https://gist.github.com/elithrar/887d162dfd0c539b700ab4049c76e22b
|
||||
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoggingMiddleware struct{}
|
||||
type logFunc func(string, ...interface{})
|
||||
|
||||
func NewLoggingMiddleware() *LoggingMiddleware {
|
||||
return &LoggingMiddleware{}
|
||||
type LoggingMiddleware struct {
|
||||
handler http.Handler
|
||||
logFunc logFunc
|
||||
excludePrefixes []string
|
||||
}
|
||||
|
||||
func (m *LoggingMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return handlers.LoggingHandler(os.Stdout, h)
|
||||
func NewLoggingMiddleware(logFunc logFunc, excludePrefixes []string) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &LoggingMiddleware{
|
||||
handler: h,
|
||||
logFunc: logFunc,
|
||||
excludePrefixes: excludePrefixes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ww := wrapWriter(w)
|
||||
|
||||
start := time.Now()
|
||||
lg.handler.ServeHTTP(ww, r)
|
||||
end := time.Now()
|
||||
duration := end.Sub(start)
|
||||
|
||||
path := strings.ToLower(r.URL.Path)
|
||||
for _, prefix := range lg.excludePrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lg.logFunc(
|
||||
"[request] status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s, user=%s",
|
||||
ww.Status(),
|
||||
r.Method,
|
||||
r.URL.String(),
|
||||
duration,
|
||||
ww.BytesWritten(),
|
||||
readUserIP(r),
|
||||
readUserID(r),
|
||||
)
|
||||
}
|
||||
|
||||
func readUserIP(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
if ip == "" {
|
||||
ip = r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func readUserID(r *http.Request) string {
|
||||
if user := GetPrincipal(r); user != nil {
|
||||
return user.ID
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// The below writer-wrapping code has been lifted from
|
||||
// https://github.com/zenazn/goji/blob/master/web/middleware/logger.go - because
|
||||
// it does exactly what is needed, and it's unlikely to change in any
|
||||
// significant way that makes copying worse-off than importing. MIT licensed
|
||||
// and (c) Carl Jackson.
|
||||
|
||||
// writerProxy is a proxy around an http.ResponseWriter that allows you to hook
|
||||
// into various parts of the response process.
|
||||
type writerProxy interface {
|
||||
http.ResponseWriter
|
||||
// Status returns the HTTP status of the request, or 0 if one has not
|
||||
// yet been sent.
|
||||
Status() int
|
||||
// BytesWritten returns the total number of bytes sent to the client.
|
||||
BytesWritten() int
|
||||
// Tee causes the response body to be written to the given io.Writer in
|
||||
// addition to proxying the writes through. Only one io.Writer can be
|
||||
// tee'd to at once: setting a second one will overwrite the first.
|
||||
// Writes will be sent to the proxy before being written to this
|
||||
// io.Writer. It is illegal for the tee'd writer to be modified
|
||||
// concurrently with writes.
|
||||
Tee(io.Writer)
|
||||
// Unwrap returns the original proxied target.
|
||||
Unwrap() http.ResponseWriter
|
||||
}
|
||||
|
||||
// wrapWriter wraps an http.ResponseWriter, returning a proxy that allows you to
|
||||
// hook into various parts of the response process.
|
||||
func wrapWriter(w http.ResponseWriter) writerProxy {
|
||||
return &basicWriter{ResponseWriter: w}
|
||||
}
|
||||
|
||||
// basicWriter wraps a http.ResponseWriter that implements the minimal
|
||||
// http.ResponseWriter interface.
|
||||
type basicWriter struct {
|
||||
http.ResponseWriter
|
||||
wroteHeader bool
|
||||
code int
|
||||
bytes int
|
||||
tee io.Writer
|
||||
}
|
||||
|
||||
func (b *basicWriter) WriteHeader(code int) {
|
||||
if !b.wroteHeader {
|
||||
b.code = code
|
||||
b.wroteHeader = true
|
||||
b.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Write(buf []byte) (int, error) {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
n, err := b.ResponseWriter.Write(buf)
|
||||
if b.tee != nil {
|
||||
_, err2 := b.tee.Write(buf[:n])
|
||||
// Prefer errors generated by the proxied writer.
|
||||
if err == nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
b.bytes += n
|
||||
return n, err
|
||||
}
|
||||
func (b *basicWriter) maybeWriteHeader() {
|
||||
if !b.wroteHeader {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Status() int {
|
||||
return b.code
|
||||
}
|
||||
func (b *basicWriter) BytesWritten() int {
|
||||
return b.bytes
|
||||
}
|
||||
func (b *basicWriter) Tee(w io.Writer) {
|
||||
b.tee = w
|
||||
}
|
||||
func (b *basicWriter) Unwrap() http.ResponseWriter {
|
||||
return b.ResponseWriter
|
||||
}
|
||||
|
64
middlewares/principal.go
Normal file
64
middlewares/principal.go
Normal file
@ -0,0 +1,64 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const keyPrincipal = "principal"
|
||||
|
||||
type PrincipalContainer struct {
|
||||
principal *models.User
|
||||
}
|
||||
|
||||
func (c *PrincipalContainer) SetPrincipal(user *models.User) {
|
||||
c.principal = user
|
||||
}
|
||||
|
||||
func (c *PrincipalContainer) GetPrincipal() *models.User {
|
||||
return c.principal
|
||||
}
|
||||
|
||||
// This middleware is a bit of a dirty workaround to the fact that a http.Request's context
|
||||
// does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a
|
||||
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
|
||||
// handler 1 will not have access to values handler 2 writes to its context. In addition, Context.WithValue
|
||||
// returns a new context with the old context as a parent.
|
||||
//
|
||||
// As a concrete example, SentryMiddleware as well as LoggingMiddleware should be quite the outer layers,
|
||||
// while AuthenticationMiddleware is on the very inside of the chain. However, we still want sentry or the
|
||||
// logger to have access to the user object populated by the auth. middleware, if present.
|
||||
//
|
||||
// This middleware shall be included as the outermost layers and it injects a stateful container that does
|
||||
// nothing but conditionally hold a reference to an authenticated user object.
|
||||
//
|
||||
// Other reference: https://stackoverflow.com/questions/55972869/send-errors-to-sentry-with-golang-and-mux
|
||||
|
||||
type PrincipalMiddleware struct {
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func NewPrincipalMiddleware() func(handler http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &PrincipalMiddleware{handler: h}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrincipalMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), keyPrincipal, &PrincipalContainer{})
|
||||
p.handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func SetPrincipal(r *http.Request, user *models.User) {
|
||||
if p := r.Context().Value(keyPrincipal); p != nil {
|
||||
p.(*PrincipalContainer).SetPrincipal(user)
|
||||
}
|
||||
}
|
||||
|
||||
func GetPrincipal(r *http.Request) *models.User {
|
||||
if p := r.Context().Value(keyPrincipal); p != nil {
|
||||
return p.(*PrincipalContainer).GetPrincipal()
|
||||
}
|
||||
return nil
|
||||
}
|
32
middlewares/security.go
Normal file
32
middlewares/security.go
Normal file
@ -0,0 +1,32 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var securityHeaders = map[string]string{
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Content-Security-Policy": "default-src 'self' 'unsafe-inline'; img-src 'self' https: data:; form-action 'self'; block-all-mixed-content;",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
// SecurityMiddleware is a handler to add some basic security headers to responses
|
||||
type SecurityMiddleware struct {
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func NewSecurityMiddleware() func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &SecurityMiddleware{h}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *SecurityMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range securityHeaders {
|
||||
if w.Header().Get(k) == "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
f.handler.ServeHTTP(w, r)
|
||||
}
|
32
migrations/20201103_rename_language_mappings_table.go
Normal file
32
migrations/20201103_rename_language_mappings_table.go
Normal file
@ -0,0 +1,32 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
f := migrationFunc{
|
||||
name: "20201103-rename_language_mappings_table",
|
||||
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) {
|
||||
logbuch.Info("renaming '%s' table to '%s'", oldTableName, newTableName)
|
||||
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logbuch.Info("renaming '%s' index to '%s'", oldIndexName, newIndexName)
|
||||
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
79
migrations/20201106_migration_cascade_constraints.go
Normal file
79
migrations/20201106_migration_cascade_constraints.go
Normal file
@ -0,0 +1,79 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20201106-migration_cascade_constraints"
|
||||
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
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()
|
||||
|
||||
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
|
||||
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !migrator.HasTable(&models.KeyStringValue{}) {
|
||||
logbuch.Info("key-value table not yet existing")
|
||||
return nil
|
||||
}
|
||||
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logbuch.Info("dropping constraint '%s'", name)
|
||||
if err := migrator.DropConstraint(table, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
58
migrations/20210202_fix_cascade_for_alias_user_constraint.go
Normal file
58
migrations/20210202_fix_cascade_for_alias_user_constraint.go
Normal file
@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210202-fix_cascade_for_alias_user_constraint"
|
||||
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
migrator := db.Migrator()
|
||||
|
||||
if cfg.Db.Dialect == config.SQLDialectSqlite {
|
||||
// see 20201106_migration_cascade_constraints
|
||||
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !migrator.HasTable(&models.KeyStringValue{}) {
|
||||
logbuch.Info("key-value table not yet existing")
|
||||
return nil
|
||||
}
|
||||
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if migrator.HasConstraint(&models.Alias{}, "fk_aliases_user") {
|
||||
logbuch.Info("dropping constraint 'fk_aliases_user'")
|
||||
if err := migrator.DropConstraint(&models.Alias{}, "fk_aliases_user"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
55
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal file
55
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal file
@ -0,0 +1,55 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
f := migrationFunc{
|
||||
name: "20210206_drop_badges_column_add_sharing_flags",
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
migrator := db.Migrator()
|
||||
|
||||
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
|
||||
// empty database or already migrated, nothing to migrate
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Db.Dialect == config.SQLDialectSqlite {
|
||||
logbuch.Info("not attempting to drop column 'badges_enabled' on sqlite")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
|
||||
return err
|
||||
}
|
||||
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
41
migrations/20210213_add_has_data_field.go
Normal file
41
migrations/20210213_add_has_data_field.go
Normal file
@ -0,0 +1,41 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210213-add_has_data_field"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE users SET has_data = TRUE WHERE TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
41
migrations/20210221_add_created_date_column.go
Normal file
41
migrations/20210221_add_created_date_column.go
Normal file
@ -0,0 +1,41 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210221-add_created_date_column"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE heartbeats SET created_at = time WHERE TRUE").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
47
migrations/20210411_add_imprint_content.go
Normal file
47
migrations/20210411_add_imprint_content.go
Normal file
@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210411-add_imprint_content"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
|
||||
if err := db.
|
||||
Clauses(clause.OnConflict{UpdateAll: false, DoNothing: true}).
|
||||
Where(condition, imprintKv.Key).
|
||||
Assign(imprintKv).
|
||||
Create(imprintKv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
22
migrations/20210411_drop_migrations_table.go
Normal file
22
migrations/20210411_drop_migrations_table.go
Normal file
@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210411-drop_migrations_table"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if err := db.Migrator().DropTable("gorp_migrations"); err != nil {
|
||||
logbuch.Info("dropped table 'gorp_migrations'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"log"
|
||||
)
|
||||
|
||||
func ApplyFixtures(db *gorm.DB) {
|
||||
cfg := config.Get()
|
||||
|
||||
if err := cfg.GetFixturesFunc(cfg.Db.Dialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
@ -1,25 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
75
migrations/migrations.go
Normal file
75
migrations/migrations.go
Normal file
@ -0,0 +1,75 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type migrationFunc struct {
|
||||
f func(db *gorm.DB, cfg *config.Config) error
|
||||
name string
|
||||
}
|
||||
|
||||
type migrationFuncs []migrationFunc
|
||||
|
||||
var (
|
||||
preMigrations migrationFuncs
|
||||
postMigrations migrationFuncs
|
||||
)
|
||||
|
||||
func registerPreMigration(f migrationFunc) {
|
||||
preMigrations = append(preMigrations, f)
|
||||
}
|
||||
|
||||
func registerPostMigration(f migrationFunc) {
|
||||
postMigrations = append(postMigrations, f)
|
||||
}
|
||||
|
||||
func Run(db *gorm.DB, cfg *config.Config) {
|
||||
RunPreMigrations(db, cfg)
|
||||
RunSchemaMigrations(db, cfg)
|
||||
RunPostMigrations(db, cfg)
|
||||
}
|
||||
|
||||
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
if err := cfg.GetMigrationFunc(cfg.Db.Dialect)(db); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
sort.Sort(preMigrations)
|
||||
|
||||
for _, m := range preMigrations {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
sort.Sort(postMigrations)
|
||||
|
||||
for _, m := range postMigrations {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m migrationFuncs) Len() int {
|
||||
return len(m)
|
||||
}
|
||||
|
||||
func (m migrationFuncs) Less(i, j int) bool {
|
||||
return strings.Compare(m[i].name, m[j].name) < 0
|
||||
}
|
||||
|
||||
func (m migrationFuncs) Swap(i, j int) {
|
||||
m[i], m[j] = m[j], m[i]
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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`;
|
@ -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`;
|
@ -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`;
|
50
mocks/alias_repository.go
Normal file
50
mocks/alias_repository.go
Normal file
@ -0,0 +1,50 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AliasRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetAll() ([]*models.Alias, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
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
50
mocks/alias_service.go
Normal 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)
|
||||
}
|
56
mocks/heartbeat_service.go
Normal file
56
mocks/heartbeat_service.go
Normal file
@ -0,0 +1,56 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
|
||||
args := m.Called(heartbeat)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
args := m.Called(heartbeats)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) Count() (int64, error) {
|
||||
args := m.Called()
|
||||
return int64(args.Int(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(int64), args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
|
||||
args := m.Called(users)
|
||||
return args.Get(0).([]*models.CountByUser), args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
args := m.Called(time, time2, user)
|
||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
|
||||
args := m.Called(s, user)
|
||||
return args.Get(0).(*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||
args := m.Called(time)
|
||||
return args.Error(0)
|
||||
}
|
36
mocks/summary_repository.go
Normal file
36
mocks/summary_repository.go
Normal file
@ -0,0 +1,36 @@
|
||||
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) GetAll() ([]*models.Summary, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(user, time, time2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
89
mocks/user_service.go
Normal file
89
mocks/user_service.go
Normal file
@ -0,0 +1,89 @@
|
||||
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) GetUserByEmail(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByResetToken(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetActive() ([]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) Count() (int64, error) {
|
||||
args := m.Called()
|
||||
return int64(args.Int(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
|
||||
args := m.Called(signup, isAdmin)
|
||||
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
|
||||
}
|
||||
|
||||
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) Delete(user *models.User) error {
|
||||
args := m.Called(user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) FlushCache() {
|
||||
m.Called()
|
||||
}
|
@ -3,7 +3,21 @@ package models
|
||||
type Alias struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
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
|
||||
}
|
||||
|
@ -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.One(); hasFilter {
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
@ -9,25 +9,34 @@ import (
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
|
||||
type AllTimeViewModel struct {
|
||||
Data *allTimeData `json:"data"`
|
||||
Data *AllTimeData `json:"data"`
|
||||
}
|
||||
|
||||
type allTimeData struct {
|
||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||
type AllTimeData struct {
|
||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||
Range *AllTimeRange `json:"range"`
|
||||
}
|
||||
|
||||
type AllTimeRange struct {
|
||||
End string `json:"end"`
|
||||
EndDate string `json:"end_date"`
|
||||
Start string `json:"start"`
|
||||
StartDate string `json:"start_date"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByKey(models.SummaryProject, key)
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &AllTimeViewModel{
|
||||
Data: &allTimeData{
|
||||
Data: &AllTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
|
25
models/compat/wakatime/v1/heartbeat.go
Normal file
25
models/compat/wakatime/v1/heartbeat.go
Normal file
@ -0,0 +1,25 @@
|
||||
package v1
|
||||
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type HeartbeatsViewModel struct {
|
||||
Data []*HeartbeatEntry `json:"data"`
|
||||
}
|
||||
|
||||
// Incomplete, for now, only the subset of fields is implemented
|
||||
// that is actually required for the import
|
||||
|
||||
type HeartbeatEntry struct {
|
||||
Id string `json:"id"`
|
||||
Branch string `json:"branch"`
|
||||
Category string `json:"category"`
|
||||
Entity string `json:"entity"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Language string `json:"language"`
|
||||
Project string `json:"project"`
|
||||
Time models.CustomTime `json:"time"`
|
||||
Type string `json:"type"`
|
||||
UserId string `json:"user_id"`
|
||||
MachineNameId string `json:"machine_name_id"`
|
||||
UserAgentId string `json:"user_agent_id"`
|
||||
}
|
12
models/compat/wakatime/v1/machine.go
Normal file
12
models/compat/wakatime/v1/machine.go
Normal file
@ -0,0 +1,12 @@
|
||||
package v1
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/machine_names
|
||||
|
||||
type MachineViewModel struct {
|
||||
Data []*MachineEntry `json:"data"`
|
||||
}
|
||||
|
||||
type MachineEntry struct {
|
||||
Id string `json:"id"`
|
||||
Value string `json:"value"`
|
||||
}
|
82
models/compat/wakatime/v1/stats.go
Normal file
82
models/compat/wakatime/v1/stats.go
Normal file
@ -0,0 +1,82 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/stats/last_7_days
|
||||
// https://pastr.de/p/f2fxg6ragj7z5e7fhsow9rb6
|
||||
|
||||
type StatsViewModel struct {
|
||||
Data *StatsData `json:"data"`
|
||||
}
|
||||
|
||||
type StatsData struct {
|
||||
Username string `json:"username"`
|
||||
UserId string `json:"user_id"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
DailyAverage float64 `json:"daily_average"`
|
||||
DaysIncludingHolidays int `json:"days_including_holidays"`
|
||||
Editors []*SummariesEntry `json:"editors"`
|
||||
Languages []*SummariesEntry `json:"languages"`
|
||||
Machines []*SummariesEntry `json:"machines"`
|
||||
Projects []*SummariesEntry `json:"projects"`
|
||||
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||
}
|
||||
|
||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||
totalTime := summary.TotalTime()
|
||||
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
||||
if math.IsInf(float64(numDays), 0) {
|
||||
numDays = 0
|
||||
}
|
||||
|
||||
data := &StatsData{
|
||||
Username: summary.UserID,
|
||||
UserId: summary.UserID,
|
||||
Start: summary.FromTime.T(),
|
||||
End: summary.ToTime.T(),
|
||||
TotalSeconds: totalTime.Seconds(),
|
||||
DailyAverage: totalTime.Seconds() / float64(numDays),
|
||||
DaysIncludingHolidays: numDays,
|
||||
}
|
||||
|
||||
editors := make([]*SummariesEntry, len(summary.Editors))
|
||||
for i, e := range summary.Editors {
|
||||
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
|
||||
languages := make([]*SummariesEntry, len(summary.Languages))
|
||||
for i, e := range summary.Languages {
|
||||
languages[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryLanguage))
|
||||
}
|
||||
|
||||
machines := make([]*SummariesEntry, len(summary.Machines))
|
||||
for i, e := range summary.Machines {
|
||||
machines[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryMachine))
|
||||
}
|
||||
|
||||
projects := make([]*SummariesEntry, len(summary.Projects))
|
||||
for i, e := range summary.Projects {
|
||||
projects[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryProject))
|
||||
}
|
||||
|
||||
oss := make([]*SummariesEntry, len(summary.OperatingSystems))
|
||||
for i, e := range summary.OperatingSystems {
|
||||
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
|
||||
data.Editors = editors
|
||||
data.Languages = languages
|
||||
data.Machines = machines
|
||||
data.Projects = projects
|
||||
data.OperatingSystems = oss
|
||||
|
||||
return &StatsViewModel{
|
||||
Data: data,
|
||||
}
|
||||
}
|
@ -13,24 +13,24 @@ import (
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
Data []*summariesData `json:"data"`
|
||||
Data []*SummariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
}
|
||||
|
||||
type summariesData struct {
|
||||
Categories []*summariesEntry `json:"categories"`
|
||||
Dependencies []*summariesEntry `json:"dependencies"`
|
||||
Editors []*summariesEntry `json:"editors"`
|
||||
Languages []*summariesEntry `json:"languages"`
|
||||
Machines []*summariesEntry `json:"machines"`
|
||||
OperatingSystems []*summariesEntry `json:"operating_systems"`
|
||||
Projects []*summariesEntry `json:"projects"`
|
||||
GrandTotal *summariesGrandTotal `json:"grand_total"`
|
||||
Range *summariesRange `json:"range"`
|
||||
type SummariesData struct {
|
||||
Categories []*SummariesEntry `json:"categories"`
|
||||
Dependencies []*SummariesEntry `json:"dependencies"`
|
||||
Editors []*SummariesEntry `json:"editors"`
|
||||
Languages []*SummariesEntry `json:"languages"`
|
||||
Machines []*SummariesEntry `json:"machines"`
|
||||
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||
Projects []*SummariesEntry `json:"projects"`
|
||||
GrandTotal *SummariesGrandTotal `json:"grand_total"`
|
||||
Range *SummariesRange `json:"range"`
|
||||
}
|
||||
|
||||
type summariesEntry struct {
|
||||
type SummariesEntry struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
@ -41,7 +41,7 @@ type summariesEntry struct {
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesGrandTotal struct {
|
||||
type SummariesGrandTotal struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
@ -49,7 +49,7 @@ type summariesGrandTotal struct {
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesRange struct {
|
||||
type SummariesRange struct {
|
||||
Date string `json:"date"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
@ -58,7 +58,7 @@ type summariesRange struct {
|
||||
}
|
||||
|
||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||
data := make([]*summariesData, len(summaries))
|
||||
data := make([]*SummariesData, len(summaries))
|
||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||
|
||||
for i, s := range summaries {
|
||||
@ -79,27 +79,27 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
|
||||
}
|
||||
}
|
||||
|
||||
func newDataFrom(s *models.Summary) *summariesData {
|
||||
func newDataFrom(s *models.Summary) *SummariesData {
|
||||
zone, _ := time.Now().Zone()
|
||||
total := s.TotalTime()
|
||||
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
||||
|
||||
data := &summariesData{
|
||||
Categories: make([]*summariesEntry, 0),
|
||||
Dependencies: make([]*summariesEntry, 0),
|
||||
Editors: make([]*summariesEntry, len(s.Editors)),
|
||||
Languages: make([]*summariesEntry, len(s.Languages)),
|
||||
Machines: make([]*summariesEntry, len(s.Machines)),
|
||||
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
|
||||
Projects: make([]*summariesEntry, len(s.Projects)),
|
||||
GrandTotal: &summariesGrandTotal{
|
||||
data := &SummariesData{
|
||||
Categories: make([]*SummariesEntry, 0),
|
||||
Dependencies: make([]*SummariesEntry, 0),
|
||||
Editors: make([]*SummariesEntry, len(s.Editors)),
|
||||
Languages: make([]*SummariesEntry, len(s.Languages)),
|
||||
Machines: make([]*SummariesEntry, len(s.Machines)),
|
||||
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
|
||||
Projects: make([]*SummariesEntry, len(s.Projects)),
|
||||
GrandTotal: &SummariesGrandTotal{
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Range: &SummariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime.T(),
|
||||
Start: s.FromTime.T(),
|
||||
@ -111,36 +111,35 @@ func newDataFrom(s *models.Summary) *summariesData {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
go func(data *summariesData) {
|
||||
go func(data *SummariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Projects {
|
||||
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
go func(data *SummariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Editors {
|
||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
go func(data *SummariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
go func(data *SummariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.OperatingSystems {
|
||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
go func(data *SummariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Machines {
|
||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||
@ -151,7 +150,7 @@ func newDataFrom(s *models.Summary) *summariesData {
|
||||
return data
|
||||
}
|
||||
|
||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
|
||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry {
|
||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||
// TODO: fix some day, while migrating persisted summary items
|
||||
total := e.Total * time.Second
|
||||
@ -163,7 +162,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
|
||||
percentage = 0
|
||||
}
|
||||
|
||||
return &summariesEntry{
|
||||
return &SummariesEntry{
|
||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||
Hours: hrs,
|
||||
Minutes: mins,
|
||||
|
12
models/compat/wakatime/v1/user_agent.go
Normal file
12
models/compat/wakatime/v1/user_agent.go
Normal file
@ -0,0 +1,12 @@
|
||||
package v1
|
||||
|
||||
type UserAgentsViewModel struct {
|
||||
Data []*UserAgentEntry `json:"data"`
|
||||
}
|
||||
|
||||
type UserAgentEntry struct {
|
||||
Id string `json:"id"`
|
||||
Editor string `json:"editor"`
|
||||
Os string `json:"os"`
|
||||
Value string `json:"value"`
|
||||
}
|
45
models/filters.go
Normal file
45
models/filters.go
Normal file
@ -0,0 +1,45 @@
|
||||
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) One() (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, ""
|
||||
}
|
@ -1,13 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/mitchellh/hashstructure/v2"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
ID uint `gorm:"primary_key" hash:"ignore"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
@ -16,30 +19,80 @@ type Heartbeat struct {
|
||||
Branch string `json:"branch"`
|
||||
Language string `json:"language" gorm:"index:idx_language"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
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"`
|
||||
languageRegex *regexp.Regexp
|
||||
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
||||
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
||||
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||
Origin string `json:"-" hash:"ignore"`
|
||||
OriginId string `json:"-" hash:"ignore"`
|
||||
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Valid() bool {
|
||||
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(`^.+\.(.+)$`)
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
||||
for ending, value := range languageMappings {
|
||||
if ok, prec := strings.HasSuffix(h.Entity, "."+ending), strings.Count(ending, "."); ok && prec > maxPrec {
|
||||
h.Language = value
|
||||
maxPrec = prec
|
||||
}
|
||||
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 2 {
|
||||
return
|
||||
}
|
||||
ending := groups[0][1]
|
||||
if _, ok := customLangs[ending]; !ok {
|
||||
return
|
||||
}
|
||||
h.Language, _ = customLangs[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
|
||||
}
|
||||
|
||||
func (h *Heartbeat) String() string {
|
||||
return fmt.Sprintf(
|
||||
"Heartbeat {user=%s, entity=%s, type=%s, category=%s, project=%s, branch=%s, language=%s, iswrite=%v, editor=%s, os=%s, machine=%s, time=%d}",
|
||||
h.UserID,
|
||||
h.Entity,
|
||||
h.Type,
|
||||
h.Category,
|
||||
h.Project,
|
||||
h.Branch,
|
||||
h.Language,
|
||||
h.IsWrite,
|
||||
h.Editor,
|
||||
h.OperatingSystem,
|
||||
h.Machine,
|
||||
(time.Time(h.Time)).UnixNano(),
|
||||
)
|
||||
}
|
||||
|
||||
// Hash is used to prevent duplicate heartbeats
|
||||
// Using a UNIQUE INDEX over all relevant columns would be more straightforward,
|
||||
// whereas manually computing this kind of hash is quite cumbersome. However,
|
||||
// such a unique index would, according to https://stackoverflow.com/q/65980064/3112139,
|
||||
// essentially double the space required for heartbeats, so we decided to go this way.
|
||||
|
||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
||||
if err != nil {
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
||||
}
|
||||
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||
return h
|
||||
}
|
||||
|
66
models/heartbeat_test.go
Normal file
66
models/heartbeat_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
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",
|
||||
"foo": "Foo Script",
|
||||
"php": "PHP 8",
|
||||
"blade.php": "Blade",
|
||||
}
|
||||
|
||||
sut1, sut2, sut3 := &Heartbeat{
|
||||
Entity: "~/dev/file.py",
|
||||
Language: "Python",
|
||||
}, &Heartbeat{
|
||||
Entity: "~/dev/file.blade.php",
|
||||
Language: "unknown",
|
||||
}, &Heartbeat{
|
||||
Entity: "~/dev/file.php",
|
||||
Language: "PHP",
|
||||
}
|
||||
|
||||
sut1.Augment(testMappings)
|
||||
sut2.Augment(testMappings)
|
||||
sut3.Augment(testMappings)
|
||||
|
||||
assert.Equal(t, "Python3", sut1.Language)
|
||||
assert.Equal(t, "Blade", sut2.Language)
|
||||
assert.Equal(t, "PHP 8", sut3.Language)
|
||||
}
|
||||
|
||||
func TestHeartbeat_GetKey(t *testing.T) {
|
||||
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
38
models/heartbeats.go
Normal 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]
|
||||
}
|
46
models/interval.go
Normal file
46
models/interval.go
Normal file
@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
// Support Wakapi and WakaTime range / interval identifiers
|
||||
// See https://wakatime.com/developers/#summaries
|
||||
var (
|
||||
IntervalToday = &IntervalKey{"today", "Today"}
|
||||
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
|
||||
IntervalThisWeek = &IntervalKey{"week", "This Week"}
|
||||
IntervalLastWeek = &IntervalKey{"Last Week"}
|
||||
IntervalThisMonth = &IntervalKey{"month", "This Month"}
|
||||
IntervalLastMonth = &IntervalKey{"Last Month"}
|
||||
IntervalThisYear = &IntervalKey{"year"}
|
||||
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
|
||||
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
||||
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
||||
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"}
|
||||
IntervalAny = &IntervalKey{"any"}
|
||||
)
|
||||
|
||||
var AllIntervals = []*IntervalKey{
|
||||
IntervalToday,
|
||||
IntervalYesterday,
|
||||
IntervalThisWeek,
|
||||
IntervalLastWeek,
|
||||
IntervalThisMonth,
|
||||
IntervalLastMonth,
|
||||
IntervalThisYear,
|
||||
IntervalPast7Days,
|
||||
IntervalPast7DaysYesterday,
|
||||
IntervalPast14Days,
|
||||
IntervalPast30Days,
|
||||
IntervalPast12Months,
|
||||
IntervalAny,
|
||||
}
|
||||
|
||||
type IntervalKey []string
|
||||
|
||||
func (k *IntervalKey) HasAlias(s string) bool {
|
||||
for _, e := range *k {
|
||||
if e == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
21
models/language_mapping.go
Normal file
21
models/language_mapping.go
Normal 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
|
||||
}
|
45
models/mail.go
Normal file
45
models/mail.go
Normal file
@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mail struct {
|
||||
From MailAddress
|
||||
To MailAddresses
|
||||
Subject string
|
||||
Body string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (m *Mail) WithText(text string) *Mail {
|
||||
m.Body = text
|
||||
m.Type = "text/plain; charset=UTF-8"
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mail) WithHTML(html string) *Mail {
|
||||
m.Body = html
|
||||
m.Type = "text/html; charset=UTF-8"
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mail) String() string {
|
||||
return fmt.Sprintf("To: %s\r\n"+
|
||||
"From: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"Content-Type: %s\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n",
|
||||
strings.Join(m.To.RawStrings(), ", "),
|
||||
m.From.String(),
|
||||
m.Subject,
|
||||
m.Type,
|
||||
m.Body,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Mail) Reader() *strings.Reader {
|
||||
return strings.NewReader(m.String())
|
||||
}
|
66
models/mail_address.go
Normal file
66
models/mail_address.go
Normal file
@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "regexp"
|
||||
|
||||
const (
|
||||
MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
|
||||
EmailAddrPattern = ".*\\s<(" + MailPattern + ")>|(" + MailPattern + ")"
|
||||
)
|
||||
|
||||
var (
|
||||
mailRegex *regexp.Regexp
|
||||
emailAddrRegex *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
mailRegex = regexp.MustCompile(MailPattern)
|
||||
emailAddrRegex = regexp.MustCompile(EmailAddrPattern)
|
||||
}
|
||||
|
||||
type MailAddress string
|
||||
|
||||
type MailAddresses []MailAddress
|
||||
|
||||
func (m MailAddress) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func (m MailAddress) Raw() string {
|
||||
match := emailAddrRegex.FindStringSubmatch(string(m))
|
||||
if len(match) == 3 {
|
||||
if match[2] != "" {
|
||||
return match[2]
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m MailAddress) Valid() bool {
|
||||
return emailAddrRegex.Match([]byte(m))
|
||||
}
|
||||
|
||||
func (m MailAddresses) Strings() []string {
|
||||
out := make([]string, len(m))
|
||||
for i, s := range m {
|
||||
out[i] = s.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m MailAddresses) RawStrings() []string {
|
||||
out := make([]string, len(m))
|
||||
for i, s := range m {
|
||||
out[i] = s.Raw()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m MailAddresses) AllValid() bool {
|
||||
for _, a := range m {
|
||||
if !a.Valid() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
88
models/mail_address_test.go
Normal file
88
models/mail_address_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMailAddress_SingleRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{
|
||||
"john.doe@example.org",
|
||||
"john.doe@example.org",
|
||||
},
|
||||
{
|
||||
"John Doe <john.doe@example.org>",
|
||||
"john.doe@example.org",
|
||||
},
|
||||
{
|
||||
"invalid",
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := MailAddress(test.in).Raw()
|
||||
assert.Equal(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAddress_AllRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
in []string
|
||||
out []string
|
||||
}{
|
||||
{
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
},
|
||||
{
|
||||
[]string{"John Doe <john.doe@example.org>", "foo@bar.com"},
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
},
|
||||
{
|
||||
[]string{"john.doe@example.org", "invalid"},
|
||||
[]string{"john.doe@example.org", ""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := castAddresses(test.in).RawStrings()
|
||||
assert.EqualValues(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAddress_AllValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
in []string
|
||||
out bool
|
||||
}{
|
||||
{
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
[]string{"John Doe <john.doe@example.org>", "ínvalid"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
[]string{"", "invalid"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := castAddresses(test.in).AllValid()
|
||||
assert.EqualValues(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func castAddresses(addresses []string) (m MailAddresses) {
|
||||
for _, a := range addresses {
|
||||
m = append(m, MailAddress(a))
|
||||
}
|
||||
return m
|
||||
}
|
22
models/metrics/counter_metric.go
Normal file
22
models/metrics/counter_metric.go
Normal file
@ -0,0 +1,22 @@
|
||||
package metrics
|
||||
|
||||
import "fmt"
|
||||
|
||||
type CounterMetric struct {
|
||||
Name string
|
||||
Value int
|
||||
Desc string
|
||||
Labels Labels
|
||||
}
|
||||
|
||||
func (c CounterMetric) Key() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c CounterMetric) Print() string {
|
||||
return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value)
|
||||
}
|
||||
|
||||
func (c CounterMetric) Header() string {
|
||||
return fmt.Sprintf("# HELP %s %s\n# TYPE %s counter", c.Name, c.Desc, c.Name)
|
||||
}
|
28
models/metrics/label.go
Normal file
28
models/metrics/label.go
Normal file
@ -0,0 +1,28 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Labels []Label
|
||||
|
||||
type Label struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (l Labels) Print() string {
|
||||
printedLabels := make([]string, len(l))
|
||||
for i, e := range l {
|
||||
printedLabels[i] = e.Print()
|
||||
}
|
||||
if len(l) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("{%s}", strings.Join(printedLabels, ","))
|
||||
}
|
||||
|
||||
func (l Label) Print() string {
|
||||
return fmt.Sprintf("%s=\"%s\"", l.Key, l.Value)
|
||||
}
|
43
models/metrics/metric.go
Normal file
43
models/metrics/metric.go
Normal file
@ -0,0 +1,43 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Hand-crafted Prometheus metrics
|
||||
// Since we're only using very simple counters in this application,
|
||||
// we don't actually need the official client SDK as a dependency
|
||||
|
||||
type Metrics []Metric
|
||||
|
||||
func (m Metrics) Print() (output string) {
|
||||
printedMetrics := make(map[string]bool)
|
||||
for _, m := range m {
|
||||
if _, ok := printedMetrics[m.Key()]; !ok {
|
||||
output += fmt.Sprintf("%s\n", m.Header())
|
||||
printedMetrics[m.Key()] = true
|
||||
}
|
||||
output += fmt.Sprintf("%s\n", m.Print())
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func (m Metrics) Len() int {
|
||||
return len(m)
|
||||
}
|
||||
|
||||
func (m Metrics) Less(i, j int) bool {
|
||||
return strings.Compare(m[i].Key(), m[j].Key()) < 0
|
||||
}
|
||||
|
||||
func (m Metrics) Swap(i, j int) {
|
||||
m[i], m[j] = m[j], m[i]
|
||||
}
|
||||
|
||||
type Metric interface {
|
||||
Key() string
|
||||
Header() string
|
||||
Print() string
|
||||
}
|
@ -2,10 +2,10 @@ package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"math"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -24,59 +24,29 @@ 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
|
||||
}
|
||||
|
||||
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(j.T())
|
||||
}
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
s := strings.Trim(string(b), "\"")
|
||||
ts, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
||||
t := time.Unix(0, int64(ts*1e9)) // ms to ns
|
||||
*j = CustomTime(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
||||
func (j *CustomTime) Scan(value interface{}) error {
|
||||
var (
|
||||
t time.Time
|
||||
@ -85,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
// with sqlite, some queries (like GetLastByUser()) return dates as strings,
|
||||
// however, most of the time they are returned as time.Time
|
||||
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||
}
|
||||
case int64:
|
||||
t = time.Unix(0, value.(int64))
|
||||
break
|
||||
case time.Time:
|
||||
t = value.(time.Time)
|
||||
break
|
||||
@ -110,11 +79,18 @@ func (j CustomTime) Value() (driver.Value, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
return j.T().String()
|
||||
}
|
||||
|
||||
func (j CustomTime) T() time.Time {
|
||||
return time.Time(j)
|
||||
}
|
||||
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -13,44 +14,30 @@ const (
|
||||
SummaryMachine uint8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalYesterday string = "day"
|
||||
IntervalThisWeek string = "week"
|
||||
IntervalThisMonth string = "month"
|
||||
IntervalThisYear string = "year"
|
||||
IntervalPast7Days string = "7_days"
|
||||
IntervalPast30Days string = "30_days"
|
||||
IntervalPast12Months string = "12_months"
|
||||
IntervalAny string = "any"
|
||||
)
|
||||
|
||||
func Intervals() []string {
|
||||
return []string{
|
||||
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
|
||||
}
|
||||
}
|
||||
|
||||
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 CustomTime `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
ToTime CustomTime `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" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
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:"-"`
|
||||
Type uint8 `json:"-" gorm:"index:idx_type"`
|
||||
Key string `json:"key"`
|
||||
Total time.Duration `json:"total"`
|
||||
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
|
||||
}
|
||||
|
||||
type SummaryItemContainer struct {
|
||||
@ -60,10 +47,15 @@ type SummaryItemContainer struct {
|
||||
|
||||
type SummaryViewModel struct {
|
||||
*Summary
|
||||
*SummaryParams
|
||||
User *User
|
||||
LanguageColors map[string]string
|
||||
EditorColors map[string]string
|
||||
OSColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
ApiKey string
|
||||
RawQuery string
|
||||
}
|
||||
|
||||
type SummaryParams struct {
|
||||
@ -73,16 +65,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,
|
||||
@ -91,6 +94,10 @@ func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
|
||||
return s.MappedItems()[summaryType]
|
||||
}
|
||||
|
||||
/* Augments the summary in a way that at least one item is present for every type.
|
||||
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||
@ -147,31 +154,95 @@ 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(filters *Filters) time.Duration {
|
||||
do, typeId, key := filters.One()
|
||||
if do {
|
||||
return s.TotalTimeByKey(typeId, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
202
models/summary_test.go
Normal file
202
models/summary_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Specifying filters about multiple entites is not supported at the moment
|
||||
// as the current, very rudimentary, time calculation logic wouldn't make sense then.
|
||||
// Evaluating a filter like (project="wakapi", language="go") can only be realized
|
||||
// before computing the summary in the first place, because afterwards we can't know
|
||||
// what time coded in "Go" was in the "Wakapi" project
|
||||
// See https://github.com/muety/wakapi/issues/108
|
||||
|
||||
filters1 := &Filters{Project: "wakapi"}
|
||||
filters2 := &Filters{Language: "Go"}
|
||||
filters3 := &Filters{}
|
||||
|
||||
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
|
||||
assert.Equal(t, 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)
|
||||
}
|
106
models/user.go
106
models/user.go
@ -1,12 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
mailRegex = regexp.MustCompile(MailPattern)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique"`
|
||||
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"`
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ShareDataMaxDays int `json:"-" gorm:"default:0"`
|
||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"`
|
||||
ResetToken string `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -16,8 +36,20 @@ type Login struct {
|
||||
|
||||
type Signup struct {
|
||||
Username string `schema:"username"`
|
||||
Email string `schema:"email"`
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
Token string `schema:"token"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Email string `schema:"email"`
|
||||
}
|
||||
|
||||
type CredentialsReset struct {
|
||||
@ -26,21 +58,71 @@ type CredentialsReset struct {
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
type UserDataUpdate struct {
|
||||
Email string `schema:"email"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
User string
|
||||
Time CustomTime
|
||||
}
|
||||
|
||||
type CountByUser struct {
|
||||
User string
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (u *User) TZ() *time.Location {
|
||||
if u.Location == "" {
|
||||
u.Location = "Local"
|
||||
}
|
||||
tz, err := time.LoadLocation(u.Location)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return tz
|
||||
}
|
||||
|
||||
func (u *User) TZOffset() time.Duration {
|
||||
_, offset := time.Now().In(u.TZ()).Zone()
|
||||
return time.Duration(offset * int(time.Second))
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return validatePassword(c.PasswordNew) &&
|
||||
return ValidatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
}
|
||||
|
||||
func (c *SetPasswordRequest) IsValid() bool {
|
||||
return ValidatePassword(c.Password) &&
|
||||
c.Password == c.PasswordRepeat
|
||||
}
|
||||
|
||||
func (s *Signup) IsValid() bool {
|
||||
return validateUsername(s.Username) &&
|
||||
validatePassword(s.Password) &&
|
||||
return ValidateUsername(s.Username) &&
|
||||
ValidateEmail(s.Email) &&
|
||||
ValidatePassword(s.Password) &&
|
||||
s.Password == s.PasswordRepeat
|
||||
}
|
||||
|
||||
func validateUsername(username string) bool {
|
||||
return len(username) >= 3 && username != "current"
|
||||
func (r *UserDataUpdate) IsValid() bool {
|
||||
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
|
||||
}
|
||||
|
||||
func validatePassword(password string) bool {
|
||||
func ValidateUsername(username string) bool {
|
||||
return len(username) >= 1 && username != "current"
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) bool {
|
||||
return len(password) >= 6
|
||||
}
|
||||
|
||||
func ValidateEmail(email string) bool {
|
||||
return email == "" || mailRegex.Match([]byte(email))
|
||||
}
|
||||
|
||||
func ValidateTimezone(tz string) bool {
|
||||
_, err := time.LoadLocation(tz)
|
||||
return err == nil
|
||||
}
|
||||
|
19
models/user_test.go
Normal file
19
models/user_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUser_TZ(t *testing.T) {
|
||||
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
|
||||
pst, _ := time.LoadLocation("America/Los_Angeles")
|
||||
_, offset := time.Now().Zone()
|
||||
|
||||
assert.Equal(t, time.Local, sut1.TZ())
|
||||
assert.Equal(t, pst, sut2.TZ())
|
||||
|
||||
assert.InDelta(t, time.Duration(offset*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||
assert.InDelta(t, time.Duration(-7*int(time.Hour)), sut2.TZOffset(), float64(1*time.Second))
|
||||
}
|
18
models/view/home.go
Normal file
18
models/view/home.go
Normal 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
22
models/view/imprint.go
Normal 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
|
||||
}
|
22
models/view/login.go
Normal file
22
models/view/login.go
Normal file
@ -0,0 +1,22 @@
|
||||
package view
|
||||
|
||||
type LoginViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
TotalUsers int
|
||||
}
|
||||
|
||||
type SetPasswordViewModel struct {
|
||||
LoginViewModel
|
||||
Token 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
27
models/view/settings.go
Normal 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
16
models/view/summary.go
Normal 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
|
||||
}
|
346
postman/Wakapi.postman_collection.json
Normal file
346
postman/Wakapi.postman_collection.json
Normal file
@ -0,0 +1,346 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "3dcc346d-a9a8-4699-8a52-459eb978b382",
|
||||
"name": "Wakapi",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Misc",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get health",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/health",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"health"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get metrics",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/metrics",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"metrics"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Heartbeats",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create heartbeat",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "X-Machine-Name",
|
||||
"value": "devmachine",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "User-Agent",
|
||||
"value": "wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "[{\n \"entity\": \"/home/user1/dev/proejct1/main.go\",\n \"project\": \"Project 1\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1616680499.113417\n}]",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/heartbeat",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"heartbeat"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Summary",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get summary",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/summary?interval=last_7_days",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"summary"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "interval",
|
||||
"value": "last_7_days"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Shields",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Shields data",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/shields/v1/n1try/interval:today/language:Go",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"shields",
|
||||
"v1",
|
||||
"n1try",
|
||||
"interval:today",
|
||||
"language:Go"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "WakaTime",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get all time",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/all_time_since_today",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"all_time_since_today"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get stats",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/stats",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"stats"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get stats with range",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/stats/last_7_days",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"stats",
|
||||
"last_7_days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get summaries",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/summaries?start=2020-03-01T15:04:05Z&end=2020-03-31T15:04:05Z",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"compat",
|
||||
"wakatime",
|
||||
"v1",
|
||||
"users",
|
||||
"current",
|
||||
"summaries"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "start",
|
||||
"value": "2020-03-01T15:04:05Z"
|
||||
},
|
||||
{
|
||||
"key": "end",
|
||||
"value": "2020-03-31T15:04:05Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const apiKey = pm.variables.get('API_KEY')",
|
||||
"",
|
||||
"if (!apiKey) {",
|
||||
" throw new Error('no api key given')",
|
||||
"}",
|
||||
"",
|
||||
"const token = base64encode(apiKey)",
|
||||
"pm.variables.set('TOKEN', token)",
|
||||
"",
|
||||
"function base64encode(str) {",
|
||||
" return Buffer.from(str, 'utf-8').toString('base64')",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "BASE_URL",
|
||||
"value": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"key": "API_KEY",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
97
repositories/alias.go
Normal file
97
repositories/alias.go
Normal file
@ -0,0 +1,97 @@
|
||||
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) GetAll() ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
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
|
||||
}
|
134
repositories/heartbeart.go
Normal file
134
repositories/heartbeart.go
Normal file
@ -0,0 +1,134 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
return &HeartbeatRepository{db: db}
|
||||
}
|
||||
|
||||
// Use with caution!!
|
||||
func (r *HeartbeatRepository) GetAll() ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
if err := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(&heartbeats).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
|
||||
var heartbeat models.Heartbeat
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Where(&models.Heartbeat{
|
||||
UserID: user.ID,
|
||||
Origin: origin,
|
||||
}).
|
||||
Order("time desc").
|
||||
First(&heartbeat).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &heartbeat, 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.Local()).
|
||||
Where("time < ?", to.Local()).
|
||||
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) GetLastByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
|
||||
var counts []*models.CountByUser
|
||||
|
||||
userIds := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIds[i] = u.ID
|
||||
}
|
||||
|
||||
if err := r.db.
|
||||
Model(&models.User{}).
|
||||
Select("users.id as user, count(heartbeats.id) as count").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Where("user_id in ?", userIds).
|
||||
Group("user").
|
||||
Find(&counts).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t.Local()).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
66
repositories/key_value.go
Normal file
66
repositories/key_value.go
Normal file
@ -0,0 +1,66 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type KeyValueRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
||||
return &KeyValueRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetAll() ([]*models.KeyStringValue, error) {
|
||||
var keyValues []*models.KeyStringValue
|
||||
if err := r.db.Find(&keyValues).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyValues, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := r.db.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
60
repositories/language_mapping.go
Normal file
60
repositories/language_mapping.go
Normal file
@ -0,0 +1,60 @@
|
||||
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) GetAll() ([]*models.LanguageMapping, error) {
|
||||
var mappings []*models.LanguageMapping
|
||||
if err := r.db.Find(&mappings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
mapping := &models.LanguageMapping{}
|
||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||
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
|
||||
}
|
69
repositories/repositories.go
Normal file
69
repositories/repositories.go
Normal file
@ -0,0 +1,69 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAliasRepository interface {
|
||||
Insert(*models.Alias) (*models.Alias, error)
|
||||
Delete(uint) error
|
||||
DeleteBatch([]uint) error
|
||||
GetAll() ([]*models.Alias, error)
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKey(string, string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetByUserAndTypeAndValue(string, uint8, string) (*models.Alias, error)
|
||||
}
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAll() ([]*models.Heartbeat, error)
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLastByUsers() ([]*models.TimeByUser, error)
|
||||
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
|
||||
Count() (int64, error)
|
||||
CountByUser(*models.User) (int64, error)
|
||||
CountByUsers([]*models.User) ([]*models.CountByUser, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
type IKeyValueRepository interface {
|
||||
GetAll() ([]*models.KeyStringValue, error)
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
GetAll() ([]*models.LanguageMapping, error)
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
Delete(uint) error
|
||||
}
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetAll() ([]*models.Summary, error)
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
}
|
||||
|
||||
type IUserRepository interface {
|
||||
GetById(string) (*models.User, error)
|
||||
GetByIds([]string) ([]*models.User, error)
|
||||
GetByApiKey(string) (*models.User, error)
|
||||
GetByEmail(string) (*models.User, error)
|
||||
GetByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetByLoggedInAfter(time.Time) ([]*models.User, error)
|
||||
GetByLastActiveAfter(time.Time) ([]*models.User, error)
|
||||
Count() (int64, error)
|
||||
InsertOrGet(*models.User) (*models.User, bool, error)
|
||||
Update(*models.User) (*models.User, error)
|
||||
UpdateField(*models.User, string, interface{}) (*models.User, error)
|
||||
Delete(*models.User) error
|
||||
}
|
74
repositories/summary.go
Normal file
74
repositories/summary.go
Normal file
@ -0,0 +1,74 @@
|
||||
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) GetAll() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) Insert(summary *models.Summary) error {
|
||||
if err := r.db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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.Local()).
|
||||
Where("to_time <= ?", to.Local()).
|
||||
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
|
||||
}
|
175
repositories/user.go
Normal file
175
repositories/user.go
Normal file
@ -0,0 +1,175 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
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) GetByIds(userIds []string) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Model(&models.User{}).
|
||||
Where("id in ?", userIds).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
|
||||
if resetToken == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
if email == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Where(&models.User{}).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Where("last_logged_in_at >= ?", t.Local()).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Returns a list of user ids, whose last heartbeat is not older than t
|
||||
// NOTE: Only ID field will be populated
|
||||
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
|
||||
subQuery1 := r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user")
|
||||
|
||||
var userIds []string
|
||||
if err := r.db.
|
||||
Select("user as id").
|
||||
Table("(?) as q", subQuery1).
|
||||
Where("time >= ?", t.Local()).
|
||||
Scan(&userIds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetByIds(userIds)
|
||||
}
|
||||
|
||||
func (r *UserRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.
|
||||
Model(&models.User{}).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
||||
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
|
||||
if err := result.Error; err != nil {
|
||||
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) {
|
||||
updateMap := map[string]interface{}{
|
||||
"api_key": user.ApiKey,
|
||||
"password": user.Password,
|
||||
"email": user.Email,
|
||||
"last_logged_in_at": user.LastLoggedInAt,
|
||||
"share_data_max_days": user.ShareDataMaxDays,
|
||||
"share_editors": user.ShareEditors,
|
||||
"share_languages": user.ShareLanguages,
|
||||
"share_oss": user.ShareOSs,
|
||||
"share_projects": user.ShareProjects,
|
||||
"share_machines": user.ShareMachines,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
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
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(user *models.User) error {
|
||||
return r.db.Delete(user).Error
|
||||
}
|
39
routes/api/health.go
Normal file
39
routes/api/health.go
Normal file
@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type HealthApiHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHealthApiHandler(db *gorm.DB) *HealthApiHandler {
|
||||
return &HealthApiHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/health").Subrouter()
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Check the application's health status
|
||||
// @ID get-health
|
||||
// @Tags misc
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string
|
||||
// @Router /health [get]
|
||||
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
var dbStatus int
|
||||
if sqlDb, err := h.db.DB(); err == nil {
|
||||
if err := sqlDb.Ping(); err == nil {
|
||||
dbStatus = 1
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte(fmt.Sprintf("app=1\ndb=%d", dbStatus)))
|
||||
}
|
118
routes/api/heartbeat.go
Normal file
118
routes/api/heartbeat.go
Normal file
@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
customMiddleware "github.com/muety/wakapi/middlewares/custom"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type HeartbeatApiHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatApiHandler(userService services.IUserService, heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatApiHandler {
|
||||
return &HeartbeatApiHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
type heartbeatResponseVm struct {
|
||||
Responses [][]interface{} `json:"responses"`
|
||||
}
|
||||
|
||||
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/heartbeat").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
customMiddleware.NewWakatimeRelayMiddleware().Handler,
|
||||
)
|
||||
r.Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||
}
|
||||
|
||||
// @Summary Push a new heartbeat
|
||||
// @ID post-heartbeat
|
||||
// @Tags heartbeat
|
||||
// @Accept json
|
||||
// @Param heartbeat body models.Heartbeat true "A heartbeat"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 201
|
||||
// @Router /heartbeat [post]
|
||||
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
user := middlewares.GetPrincipal(r)
|
||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if err := dec.Decode(&heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
for _, hb := range heartbeats {
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
hb.Machine = machineName
|
||||
hb.User = user
|
||||
hb.UserID = user.ID
|
||||
|
||||
if !hb.Valid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid heartbeat object"))
|
||||
return
|
||||
}
|
||||
|
||||
hb.Hashed()
|
||||
}
|
||||
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
conf.Log().Request(r).Error("failed to batch-insert heartbeats – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasData {
|
||||
user.HasData = true
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
conf.Log().Request(r).Error("failed to update user – %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
// to make the cli consider all heartbeats to having been successfully saved
|
||||
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
|
||||
func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||
responses := make([][]interface{}, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
r := make([]interface{}, 2)
|
||||
r[0] = nil
|
||||
r[1] = http.StatusCreated
|
||||
responses[i] = r
|
||||
}
|
||||
|
||||
return &heartbeatResponseVm{
|
||||
Responses: responses,
|
||||
}
|
||||
}
|
273
routes/api/metrics.go
Normal file
273
routes/api/metrics.go
Normal file
@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
mm "github.com/muety/wakapi/models/metrics"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MetricsPrefix = "wakatime"
|
||||
|
||||
DescHeartbeats = "Total number of tracked heartbeats."
|
||||
DescAllTime = "Total seconds (all time)."
|
||||
DescTotal = "Total seconds."
|
||||
DescEditors = "Total seconds for each editor."
|
||||
DescProjects = "Total seconds for each project."
|
||||
DescLanguages = "Total seconds for each language."
|
||||
DescOperatingSystems = "Total seconds for each operating system."
|
||||
DescMachines = "Total seconds for each machine."
|
||||
|
||||
DescAdminTotalTime = "Total seconds (all users, all time)."
|
||||
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
|
||||
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
|
||||
DescAdminTotalUsers = "Total number of registered users."
|
||||
DescAdminActiveUsers = "Number of active users."
|
||||
)
|
||||
|
||||
type MetricsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
keyValueSrvc services.IKeyValueService
|
||||
}
|
||||
|
||||
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
|
||||
return &MetricsHandler{
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
keyValueSrvc: keyValueService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) RegisterRoutes(router *mux.Router) {
|
||||
if !h.config.Security.ExposeMetrics {
|
||||
return
|
||||
}
|
||||
|
||||
logbuch.Info("exposing prometheus metrics under /api/metrics")
|
||||
|
||||
r := router.PathPrefix("/metrics").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
reqUser := middlewares.GetPrincipal(r)
|
||||
if reqUser == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(conf.ErrUnauthorized))
|
||||
return
|
||||
}
|
||||
|
||||
var metrics mm.Metrics
|
||||
|
||||
if userMetrics, err := h.getUserMetrics(reqUser); err != nil {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
return
|
||||
} else {
|
||||
for _, m := range *userMetrics {
|
||||
metrics = append(metrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
if reqUser.IsAdmin {
|
||||
if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
return
|
||||
} else {
|
||||
for _, m := range *adminMetrics {
|
||||
metrics = append(metrics, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(metrics)
|
||||
|
||||
w.Header().Set("content-type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(metrics.Print()))
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) {
|
||||
var metrics mm.Metrics
|
||||
|
||||
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, false)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
|
||||
|
||||
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
heartbeatCount, err := h.heartbeatSrvc.CountByUser(user)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to count heartbeats for user '%s' for metric", user.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// User Metrics
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_cumulative_seconds_total",
|
||||
Desc: DescAllTime,
|
||||
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_seconds_total",
|
||||
Desc: DescTotal,
|
||||
Value: int(summaryToday.TotalTime().Seconds()),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_heartbeats_total",
|
||||
Desc: DescHeartbeats,
|
||||
Value: int(heartbeatCount),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
for _, p := range summaryToday.Projects {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_project_seconds_total",
|
||||
Desc: DescProjects,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: p.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
for _, l := range summaryToday.Languages {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_language_seconds_total",
|
||||
Desc: DescLanguages,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: l.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
for _, e := range summaryToday.Editors {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_editor_seconds_total",
|
||||
Desc: DescEditors,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: e.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
for _, o := range summaryToday.OperatingSystems {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_operating_system_seconds_total",
|
||||
Desc: DescOperatingSystems,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: o.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
for _, m := range summaryToday.Machines {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_machine_seconds_total",
|
||||
Desc: DescMachines,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: m.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error) {
|
||||
var metrics mm.Metrics
|
||||
|
||||
if !user.IsAdmin {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
var totalSeconds int
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := time.ParseDuration(t.Value); err == nil {
|
||||
totalSeconds = int(d.Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
totalUsers, _ := h.userSrvc.Count()
|
||||
totalHeartbeats, _ := h.heartbeatSrvc.Count()
|
||||
|
||||
activeUsers, err := h.userSrvc.GetActive()
|
||||
if err != nil {
|
||||
logbuch.Error("failed to retrieve active users for metric – %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_seconds_total",
|
||||
Desc: DescAdminTotalTime,
|
||||
Value: totalSeconds,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_heartbeats_total",
|
||||
Desc: DescAdminTotalHeartbeats,
|
||||
Value: int(totalHeartbeats),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_users_total",
|
||||
Desc: DescAdminTotalUsers,
|
||||
Value: int(totalUsers),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_users_active_total",
|
||||
Desc: DescAdminActiveUsers,
|
||||
Value: len(activeUsers),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
// Count per-user heartbeats
|
||||
|
||||
userCounts, err := h.heartbeatSrvc.CountByUsers(activeUsers)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to count heartbeats for active users", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, uc := range userCounts {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_user_heartbeats_total",
|
||||
Desc: DescAdminUserHeartbeats,
|
||||
Value: int(uc.Count),
|
||||
Labels: []mm.Label{{Key: "user", Value: uc.User}},
|
||||
})
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
55
routes/api/summary.go
Normal file
55
routes/api/summary.go
Normal file
@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
su "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SummaryApiHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummaryApiHandler(userService services.IUserService, summaryService services.ISummaryService) *SummaryApiHandler {
|
||||
return &SummaryApiHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/summary").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Retrieve a summary
|
||||
// @ID get-summary
|
||||
// @Tags summary
|
||||
// @Produce json
|
||||
// @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param from query string false "Start date (e.g. '2021-02-07')"
|
||||
// @Param to query string false "End date (e.g. '2021-02-08')"
|
||||
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} models.Summary
|
||||
// @Router /summary [get]
|
||||
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusOK, summary)
|
||||
}
|
@ -1,15 +1,17 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -18,35 +20,41 @@ const (
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
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: config2.Get(),
|
||||
cache: cache.New(time.Hour, time.Hour),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
|
||||
// no auth middleware here, handler itself resolves the user
|
||||
r := router.PathPrefix("/compat/shields/v1/{user}").Subrouter()
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Get badge data
|
||||
// @Description Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.
|
||||
// @ID get-badge
|
||||
// @Tags badges
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for"
|
||||
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
|
||||
// @Success 200 {object} v1.BadgeData
|
||||
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
|
||||
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
intervalReg := regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
if userAgent := r.Header.Get("user-agent"); !strings.HasPrefix(userAgent, "Shields.io/") && !h.config.IsDev() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
if err != nil || !user.BadgesEnabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
@ -54,21 +62,47 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
interval = groups[1]
|
||||
if i, err := utils.ParseInterval(groups[1]); err == nil {
|
||||
interval = i
|
||||
}
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("requested time range too broad"))
|
||||
return
|
||||
}
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
@ -79,11 +113,12 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
h.cache.SetDefault(cacheKey, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
@ -94,7 +129,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, summaryParams.Recompute)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -2,7 +2,8 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -13,23 +14,42 @@ import (
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
func NewAllTimeHandler(userService services.IUserService, summaryService services.ISummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/all_time_since_today").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Retrieve summary for all time
|
||||
// @Description Mimics https://wakatime.com/developers#all_time_since_today
|
||||
// @ID get-all-time
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} v1.AllTimeViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
|
||||
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
authorizedUser := middlewares.GetPrincipal(r)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@ -43,8 +63,8 @@ func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
@ -55,7 +75,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, summaryParams.Recompute)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
123
routes/compat/wakatime/v1/stats.go
Normal file
123
routes/compat/wakatime/v1/stats.go
Normal file
@ -0,0 +1,123 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StatsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewStatsHandler(userService services.IUserService, summaryService services.ISummaryService) *StatsHandler {
|
||||
return &StatsHandler{
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
|
||||
)
|
||||
r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
|
||||
// Also works without range, see https://github.com/anuraghazra/github-readme-stats/issues/865#issuecomment-776186592
|
||||
r.Path("/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
r.Path("/compat/wakatime/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
|
||||
|
||||
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
var vars = mux.Vars(r)
|
||||
var authorizedUser, requestedUser *models.User
|
||||
|
||||
authorizedUser = middlewares.GetPrincipal(r)
|
||||
if authorizedUser != nil && vars["user"] == "current" {
|
||||
vars["user"] = authorizedUser.ID
|
||||
}
|
||||
|
||||
requestedUser, err := h.userSrvc.GetUserById(vars["user"])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("user not found"))
|
||||
return
|
||||
}
|
||||
|
||||
rangeParam := vars["range"]
|
||||
if rangeParam == "" {
|
||||
rangeParam = (*models.IntervalPast7Days)[0]
|
||||
}
|
||||
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid range"))
|
||||
return
|
||||
}
|
||||
|
||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
|
||||
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("requested time range too broad"))
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
||||
|
||||
// post filter stats according to user's given sharing permissions
|
||||
if !requestedUser.ShareEditors {
|
||||
stats.Data.Editors = nil
|
||||
}
|
||||
if !requestedUser.ShareLanguages {
|
||||
stats.Data.Languages = nil
|
||||
}
|
||||
if !requestedUser.ShareProjects {
|
||||
stats.Data.Projects = nil
|
||||
}
|
||||
if !requestedUser.ShareOSs {
|
||||
stats.Data.OperatingSystems = nil
|
||||
}
|
||||
if !requestedUser.ShareMachines {
|
||||
stats.Data.Machines = nil
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, false)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
@ -3,7 +3,8 @@ package v1
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -14,27 +15,48 @@ import (
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
func NewSummariesHandler(userService services.IUserService, summaryService services.ISummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: support parameters: project, branches, timeout, writes_only, timezone
|
||||
https://wakatime.com/developers#summaries
|
||||
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
|
||||
*/
|
||||
func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/summaries").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
|
||||
// See https://wakatime.com/developers#summaries.
|
||||
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
|
||||
// Requires https://github.com/muety/wakapi/issues/108.
|
||||
|
||||
// @Summary Retrieve WakaTime-compatible summaries
|
||||
// @Description Mimics https://wakatime.com/developers#summaries.
|
||||
// @ID get-wakatime-summaries
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Param range query string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param start query string false "Start date (e.g. '2021-02-07')"
|
||||
// @Param end query string false "End date (e.g. '2021-02-08')"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} v1.SummariesViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
|
||||
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
authorizedUser := middlewares.GetPrincipal(r)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@ -48,28 +70,41 @@ func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
filters := &models.Filters{}
|
||||
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
|
||||
filters.Project = projectQuery
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, filters)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
user := middlewares.GetPrincipal(r)
|
||||
params := r.URL.Query()
|
||||
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
|
||||
|
||||
var start, end time.Time
|
||||
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
|
||||
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
|
||||
start = utils.StartOfToday()
|
||||
end = time.Now()
|
||||
if rangeParam != "" {
|
||||
// range param takes precedence
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
||||
}
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
|
||||
// also accept start param to be a range param
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
// eventually, consider start and end params a date
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
|
||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), user.TZ())
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
|
||||
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), user.TZ())
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
@ -86,7 +121,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, false)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
7
routes/handler.go
Normal file
7
routes/handler.go
Normal file
@ -0,0 +1,7 @@
|
||||
package routes
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
type Handler interface {
|
||||
RegisterRoutes(router *mux.Router)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user