mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
412 Commits
Author | SHA1 | Date | |
---|---|---|---|
1e9d3f9e80 | |||
2ce720c20f | |||
ef87445e43 | |||
dec5849661 | |||
5609c0ada3 | |||
1632cea949 | |||
23759d526a | |||
82a565738f | |||
1989a69926 | |||
7a07c9d4fc | |||
a27fe04919 | |||
1d7ff4bc2a | |||
b3fa032bde | |||
94377a8dea | |||
dba4da8641 | |||
4a22a19cb0 | |||
13a3d9f03a | |||
beffe71ea6 | |||
0ab7faf7b6 | |||
a2ac049578 | |||
b287c4ca36 | |||
018cc50fb8 | |||
1d4156bdfe | |||
147c79db60 | |||
f204ca888d | |||
e28070b288 | |||
4d217a83c1 | |||
9e0581b311 | |||
ffb529f4cf | |||
c9aac2a273 | |||
dd8658e33e | |||
e399af1f1f | |||
4c1f4ed39b | |||
7e5c00d0ae | |||
cec2a84e2d | |||
ffb0b84d78 | |||
8a7333b899 | |||
dd3b9c9b9c | |||
d2b62e21c5 | |||
9505773165 | |||
4bfc8a9e9f | |||
df5fe6e623 | |||
037ad7b9b1 | |||
ec10cc922c | |||
acb76e1ab1 | |||
252a304ba8 | |||
c863cf6dc5 | |||
373d969734 | |||
99a3e8f5da | |||
4302cfcbd6 | |||
7cca0055fe | |||
20993a1182 | |||
d5a85639b1 | |||
b6a8185957 | |||
c5da5e4622 | |||
a0f69a371f | |||
2f0cb112dd | |||
2173954b84 | |||
991e64b961 | |||
affff0c386 | |||
099cdaddbc | |||
409405117e | |||
af89ecc9c1 | |||
be354fa790 | |||
a1c4c5da6b | |||
33509beaf7 | |||
ab6ccbdfbe | |||
77e6cd9faa | |||
34bc38cecf | |||
69d3e0494b | |||
a3136ebb13 | |||
4a4d0dad4b | |||
3b87511f48 | |||
f5fba04097 | |||
ad566993ad | |||
5f1e498454 | |||
2e0f79df3b | |||
4a4e19fcbd | |||
45d4ba89f5 | |||
29b3e619ca | |||
1a85ebc0f7 | |||
4bd58789f4 | |||
09d1124794 | |||
41584bdd82 | |||
1b7baf6fc9 | |||
a76db3e95f | |||
74a5226e73 | |||
d245b1e5d0 | |||
d5eff46651 | |||
30a65b4de9 | |||
9048a8eb7a | |||
1f19c5e93c | |||
4b0a3cf0d6 | |||
d778612242 | |||
ff7d595a86 | |||
9d7688957f | |||
179042f81b | |||
e6441f124c | |||
15c391d1d4 | |||
91c765202c | |||
5276f68918 | |||
e774039831 | |||
40067d252e | |||
1a47243f70 | |||
a1f6c2884b | |||
977420c68d | |||
3ae66a3898 | |||
5c5c462035 | |||
f6cc489425 | |||
5aae18e241 | |||
8a731a252a | |||
8fc0d78f64 | |||
a675417ab9 | |||
408d9086e7 | |||
8f933d8648 | |||
bbc85de34b | |||
ec70d024fa | |||
eae45baf38 | |||
4cea50b5c8 | |||
e4814431e0 | |||
91b4cb2c13 | |||
a3acdc7041 | |||
e7e5254673 | |||
8e558d8dee | |||
b763c4acc6 | |||
d1bd7b96b8 | |||
8c65da9031 | |||
647bf1781d | |||
85515d6cb5 | |||
1258ec0438 | |||
965d8e22b3 | |||
ed6e51b4df | |||
af879f8d57 | |||
f15efcd6f2 | |||
22e91ad362 | |||
932ba111cc | |||
23d00d574b | |||
d4b15e7959 | |||
42808fa38a | |||
52269c780f | |||
302eb33b1b | |||
784adec3c1 | |||
d2cdd35fff | |||
33d65fb33a | |||
6d762f5fd6 | |||
222024dabb | |||
660a09475e | |||
5cc932177f | |||
ac9d96c563 | |||
3758eecc96 | |||
e21788b8b5 | |||
e7f3432113 | |||
7159df30c2 | |||
fce3a3ea20 | |||
bd2a8c5a7f | |||
632a3d4a91 | |||
8a344ce4a2 | |||
cbbb592143 | |||
67f0d19a65 | |||
03b104a390 | |||
4a3fe48cce | |||
1033343702 | |||
31c462c275 | |||
0a7ebc4dc7 | |||
91768cf927 | |||
e967a74e36 | |||
8e6719f0b7 | |||
bcbd6236df | |||
d1cbabf662 | |||
ad4d251154 | |||
2bb3b886c2 | |||
4f183ed637 | |||
b66f9b5cf5 | |||
bf7f93fcd4 | |||
36c96dafca | |||
8f87c4e283 | |||
247aef5ef3 | |||
c0dada7e7a | |||
8b8c5675af | |||
f69dce39d8 | |||
c2d3426bcd | |||
bb0d0569fd | |||
c4c62f31e4 | |||
2bc53e6f11 | |||
fd6c36832e | |||
6f9015d3d8 | |||
cbcdd938eb | |||
bf82935849 | |||
fe3ba79d54 | |||
d80c1a4c4b | |||
a279548c89 | |||
8a3e6f0179 | |||
a72af7d57e | |||
ec236909c9 | |||
92f6d44606 | |||
e14f8c1463 | |||
80252ff701 | |||
374e578a7c | |||
aebfdc535d | |||
c217f8e664 | |||
ba54e7bb96 | |||
1e505b91f3 | |||
26825b07de | |||
6a5f08dc95 | |||
62e3decf0f | |||
0557a5000f | |||
7b7fa8bdf3 | |||
4e7322c985 | |||
af0d2e84e1 | |||
44a2e609fb | |||
ee501ca3c5 | |||
148f581906 | |||
acf16421a6 | |||
0039f67a2f | |||
c8a07cee36 | |||
15c8838fea | |||
f363135261 | |||
d561ce1766 | |||
6712f0a390 | |||
9950da3e7e | |||
c7e12ba3b5 | |||
aaa907a7b2 | |||
0ee52662d3 | |||
e1daf1406e | |||
7dd0967451 | |||
d6aa2c4405 | |||
821ae94c1e | |||
adcd7b35ae | |||
b0bd26f0ec | |||
259f711f2d | |||
1c0477f861 | |||
28a3418ad5 | |||
c5db2c235f | |||
9cbddaeedf | |||
485dfe2888 | |||
78a26dbf3c | |||
b2c72c6420 | |||
6852494d36 | |||
305166ce68 | |||
400f25c23e | |||
3aacd3461d | |||
7e2460e1f0 | |||
57175ae7f8 | |||
5df0f48303 | |||
76a7cf7e80 | |||
7cae3c43d0 | |||
5fc87dd143 | |||
7329f6a34e | |||
3b96bd3723 | |||
2c7977cf63 | |||
782da0b49e | |||
ed9a7ccd5a | |||
9451848ad4 | |||
6c0145b149 | |||
a94092e31c | |||
52744dbcd0 | |||
cc11226eab | |||
8d073aaef2 | |||
d2f078443e | |||
c6e1651d9e | |||
630090e38a | |||
5394349c73 | |||
5cd3bf83a6 | |||
13cf911edf | |||
fe0f41cecb | |||
265080453a | |||
f9fb7c7a8a | |||
90477dbb01 | |||
35926a19e2 | |||
84dc594548 | |||
2f9b8fbcfe | |||
9235c1ca78 | |||
a869897f80 | |||
2f9cafc88c | |||
816d0c8cdc | |||
1ab29b22e1 | |||
cafe4133e4 | |||
5a0a3c40ca | |||
9b5f00ea5d | |||
7a418aa519 | |||
d96a48d5dc | |||
fa4512f79b | |||
398b4c16d6 | |||
d1577fc6be | |||
23f8a5cf7f | |||
81835a3d88 | |||
30de96950b | |||
11291b0d6c | |||
f0ac0f6153 | |||
6aad1633e1 | |||
c07a4d71a0 | |||
dff0b742fc | |||
4f65f94766 | |||
825663acde | |||
f399fd4ea7 | |||
87fadf46f7 | |||
69f5d510dc | |||
0542813ed6 | |||
c962a3891d | |||
2088987a0c | |||
9e3203ac41 | |||
58719182c4 | |||
a8df25be08 | |||
391cc1e5b4 | |||
3bb22e5e84 | |||
93bdb48d95 | |||
533b5d62fc | |||
0af5fab75f | |||
fecc8b3b5f | |||
24b8ff6381 | |||
180e75a5eb | |||
f48b49d26e | |||
47b9cacb26 | |||
23fc1b62cc | |||
74f6a255a8 | |||
7a5dce29bd | |||
0e1596fe70 | |||
48513b660d | |||
69f73fc0ea | |||
0e788b0777 | |||
181aefa2f9 | |||
407925ec53 | |||
5e96e2a601 | |||
4d2a160ccb | |||
c3957ec0c8 | |||
312dfb36d8 | |||
c66605d463 | |||
3c12df52d9 | |||
dd6a040171 | |||
9f1266957b | |||
466f2e1786 | |||
82b8951437 | |||
25464e9519 | |||
650fffa344 | |||
69627fbe11 | |||
561198b203 | |||
7c4a2024b6 | |||
7bcd6890d1 | |||
1e4e530c21 | |||
490cca05eb | |||
3780ae4255 | |||
628ea0b9dd | |||
0d64858721 | |||
c1c78d8d5b | |||
538b9d2463 | |||
f4612fd542 | |||
fb643571d2 | |||
101fdfb957 | |||
a4d47fb566 | |||
1a808f9197 | |||
ee31212cdd | |||
712949afc7 | |||
9dbc2039fc | |||
f3b738b250 | |||
cf3d293688 | |||
0fbb554fc3 | |||
11b224fc24 | |||
0673c26043 | |||
8dc69c58cb | |||
99d8349277 | |||
cf14fc46ef | |||
ef9303e61e | |||
a4e7158db2 | |||
29c04c3ac5 | |||
1beca82875 | |||
b16f777cc7 | |||
cead20a505 | |||
5a8287a06b | |||
37d4d58b57 | |||
7d03a9b12d | |||
331ace3c1e | |||
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 |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
* text=auto
|
||||||
|
*.db -text
|
||||||
|
*.png -text
|
||||||
|
*.br -text
|
||||||
|
*.ico -text
|
||||||
|
*.woff2 -text
|
19
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is. Please briefly describe how to reproduce the bug as well as _expected_ vs. _actual_ behavior. Optionally include screenshots and server logs, if helpful.
|
||||||
|
|
||||||
|
**System information**
|
||||||
|
Please provide information on:
|
||||||
|
* Wakapi version
|
||||||
|
* Operating system
|
||||||
|
* If Linux: which distro?
|
||||||
|
* If Docker: which image and tag?
|
||||||
|
* Database (SQLite, MySQL, ... ?)
|
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: Other (feature request, question, ...)
|
||||||
|
about: Anything else
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
103
.github/workflows/ci.yml
vendored
Normal file
103
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: 'Unit- & API tests'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Set up Go 1.x
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ^1.18
|
||||||
|
id: go
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: go get
|
||||||
|
|
||||||
|
- name: Unit Tests
|
||||||
|
run: go test ./... -run ./...
|
||||||
|
|
||||||
|
- name: API Tests
|
||||||
|
run: |
|
||||||
|
npm -g install newman
|
||||||
|
./testing/run_api_tests.sh
|
||||||
|
|
||||||
|
mapi:
|
||||||
|
name: 'Automated pen-tests with Mayhem for API'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Go 1.x
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ^1.18
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: go get
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -v .
|
||||||
|
|
||||||
|
- name: start wakapi
|
||||||
|
run: ./wakapi --config config.default.yml &
|
||||||
|
|
||||||
|
- name: create a trivial testing user
|
||||||
|
run: sqlite3 wakapi_db.db "insert into users (id, api_key) values ('mapi', 'test-api-key')"
|
||||||
|
|
||||||
|
- name: Run Mayhem for API
|
||||||
|
uses: ForAllSecure/mapi-action@v1
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
mapi-token: ${{ secrets.MAPI_TOKEN }}
|
||||||
|
api-url: http://localhost:3000/api/
|
||||||
|
api-spec: static/docs/swagger.yaml
|
||||||
|
target: muety/wakapi
|
||||||
|
duration: 1min
|
||||||
|
sarif-report: mapi.sarif
|
||||||
|
run-args: |
|
||||||
|
--header-auth
|
||||||
|
Authorization: Basic dGVzdC1hcGkta2V5
|
||||||
|
|
||||||
|
- name: Upload SARIF file
|
||||||
|
uses: github/codeql-action/upload-sarif@v1
|
||||||
|
with:
|
||||||
|
sarif_file: mapi.sarif
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: 'Build (Win, Linux, Mac)'
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Go 1.x
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ^1.18
|
||||||
|
id: go
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: go get
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -v .
|
57
.github/workflows/docker.yml
vendored
57
.github/workflows/docker.yml
vendored
@ -8,35 +8,58 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-publish:
|
docker-publish:
|
||||||
|
name: 'Build and publish Docker image'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# https://stackoverflow.com/questions/58177786
|
- name: Checkout
|
||||||
- name: Get version
|
uses: actions/checkout@v3
|
||||||
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
|
||||||
|
- name: Set version
|
||||||
|
run: |
|
||||||
|
(git describe --tags --exact-match \
|
||||||
|
|| git symbolic-ref -q --short HEAD \
|
||||||
|
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
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
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push to Docker Hub
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v3
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
n1try/wakapi
|
||||||
|
tags: |
|
||||||
|
latest
|
||||||
|
alpine
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
n1try/wakapi:${{ env.GIT_TAG }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
n1try/wakapi:latest
|
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
|
||||||
|
48
.github/workflows/linux-build-on-release.yml
vendored
48
.github/workflows/linux-build-on-release.yml
vendored
@ -1,48 +0,0 @@
|
|||||||
name: Build Wakapi on Linux
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
pull_request:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-release:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- name: Set up Go 1.x
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: ^1.16
|
|
||||||
id: go
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: |
|
|
||||||
go get
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: GO111MODULE=on go build -v .
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
|
||||||
asset_path: release.zip
|
|
||||||
asset_name: wakapi_linux_amd64.zip
|
|
||||||
asset_content_type: application/gzip
|
|
77
.github/workflows/release.yml
vendored
Normal file
77
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: 'Build, package and release to GitHub'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: ubuntu-18.04
|
||||||
|
GOOS: linux
|
||||||
|
GOARCH: amd64
|
||||||
|
- platform: ubuntu-18.04
|
||||||
|
GOOS: linux
|
||||||
|
GOARCH: arm64
|
||||||
|
- platform: windows-latest
|
||||||
|
GOOS: windows
|
||||||
|
GOARCH: amd64
|
||||||
|
- platform: macos-latest
|
||||||
|
GOOS: darwin
|
||||||
|
GOARCH: amd64
|
||||||
|
- platform: macos-latest
|
||||||
|
GOOS: darwin
|
||||||
|
GOARCH: arm64
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Set up Go 1.x
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ^1.18
|
||||||
|
id: go
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
(git describe --tags --exact-match \
|
||||||
|
|| git symbolic-ref -q --short HEAD \
|
||||||
|
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/ && cd dist/
|
||||||
|
cp ../config.default.yml config.yml
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: ./dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} CGO_ENABLED=0 \
|
||||||
|
go build -v -ldflags '-w -s' ../
|
||||||
|
|
||||||
|
- name: Compress working folder (Windows PowerShell)
|
||||||
|
working-directory: ./dist
|
||||||
|
if: "${{ matrix.GOOS == 'windows' }}"
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip
|
||||||
|
|
||||||
|
- name: Compress working folder
|
||||||
|
working-directory: ./dist
|
||||||
|
if: "${{ matrix.GOOS != 'windows' }}"
|
||||||
|
run: |
|
||||||
|
zip -9 wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip *
|
||||||
|
|
||||||
|
- name: Upload built executable to Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: ./dist/*.zip
|
51
.github/workflows/win-build-on-release.yml
vendored
51
.github/workflows/win-build-on-release.yml
vendored
@ -1,51 +0,0 @@
|
|||||||
name: Build Wakapi on Windows
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
pull_request:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-release:
|
|
||||||
name: Build
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- name: Set up Go 1.x
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: ^1.16
|
|
||||||
id: go
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: |
|
|
||||||
go get
|
|
||||||
|
|
||||||
- name: Enable Go 1.11 modules
|
|
||||||
run: cmd /c "set GO111MODULE=on"
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build -v .
|
|
||||||
|
|
||||||
- name: Compress working folder
|
|
||||||
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 }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
|
||||||
asset_path: release.zip
|
|
||||||
asset_name: wakapi_win_amd64.zip
|
|
||||||
asset_content_type: application/gzip
|
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,4 +7,7 @@ build
|
|||||||
*.db
|
*.db
|
||||||
config*.yml
|
config*.yml
|
||||||
!config.default.yml
|
!config.default.yml
|
||||||
|
!testing/config.testing.yml
|
||||||
pkged.go
|
pkged.go
|
||||||
|
package-lock.json
|
||||||
|
node_modules
|
6
.gitpod.yml
Normal file
6
.gitpod.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# List the start up tasks. Learn more https://www.gitpod.io/docs/config-start-tasks/
|
||||||
|
tasks:
|
||||||
|
- before: printf "\n[settings]\napi_key = $WAKA_TIME_API_KEY\napi_url = $WAKA_TIME_API_URL\n" > ~/.wakatime.cfg
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
visibility: public
|
58
Dockerfile
58
Dockerfile
@ -1,23 +1,22 @@
|
|||||||
# Build Stage
|
FROM golang:1.18-alpine AS build-env
|
||||||
|
|
||||||
FROM golang:1.16 AS build-env
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
ADD ./go.mod .
|
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
|
|
||||||
chmod +x wait-for-it.sh
|
chmod +x wait-for-it.sh
|
||||||
|
|
||||||
|
ADD ./go.mod ./go.sum ./
|
||||||
|
RUN go mod download
|
||||||
ADD . .
|
ADD . .
|
||||||
RUN go build -o wakapi
|
|
||||||
|
|
||||||
WORKDIR /app
|
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -v -o wakapi main.go
|
||||||
RUN cp /src/wakapi . && \
|
|
||||||
cp /src/config.default.yml config.yml && \
|
WORKDIR /staging
|
||||||
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
|
RUN mkdir ./data ./app && \
|
||||||
cp /src/wait-for-it.sh . && \
|
cp /src/wakapi app/ && \
|
||||||
cp /src/entrypoint.sh .
|
cp /src/config.default.yml app/config.yml && \
|
||||||
|
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' app/config.yml && \
|
||||||
|
cp /src/wait-for-it.sh app/ && \
|
||||||
|
cp /src/entrypoint.sh app/
|
||||||
|
|
||||||
# Run Stage
|
# Run Stage
|
||||||
|
|
||||||
@ -25,26 +24,25 @@ RUN cp /src/wakapi . && \
|
|||||||
# to override config values using `-e` syntax.
|
# to override config values using `-e` syntax.
|
||||||
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
||||||
|
|
||||||
FROM debian
|
FROM alpine:3
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt update && \
|
RUN apk add --no-cache bash ca-certificates tzdata
|
||||||
apt install -y ca-certificates
|
|
||||||
|
|
||||||
# See README.md and config.default.yml for all config options
|
# See README.md and config.default.yml for all config options
|
||||||
ENV ENVIRONMENT prod
|
ENV ENVIRONMENT=prod \
|
||||||
ENV WAKAPI_DB_TYPE sqlite3
|
WAKAPI_DB_TYPE=sqlite3 \
|
||||||
ENV WAKAPI_DB_USER ''
|
WAKAPI_DB_USER='' \
|
||||||
ENV WAKAPI_DB_PASSWORD ''
|
WAKAPI_DB_PASSWORD='' \
|
||||||
ENV WAKAPI_DB_HOST ''
|
WAKAPI_DB_HOST='' \
|
||||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
WAKAPI_DB_NAME=/data/wakapi.db \
|
||||||
ENV WAKAPI_PASSWORD_SALT ''
|
WAKAPI_PASSWORD_SALT='' \
|
||||||
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
|
WAKAPI_LISTEN_IPV4='0.0.0.0' \
|
||||||
ENV WAKAPI_INSECURE_COOKIES 'true'
|
WAKAPI_INSECURE_COOKIES='true' \
|
||||||
ENV WAKAPI_ALLOW_SIGNUP 'true'
|
WAKAPI_ALLOW_SIGNUP='true'
|
||||||
|
|
||||||
COPY --from=build-env /app .
|
COPY --from=build-env /staging /
|
||||||
|
|
||||||
VOLUME /data
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT ./entrypoint.sh
|
ENTRYPOINT /app/entrypoint.sh
|
||||||
|
388
README.md
388
README.md
@ -4,15 +4,11 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||||
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
|
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a>
|
||||||
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
||||||
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
|
<img src="https://wakapi.dev/api/badge/n1try/interval:any/project:wakapi?label=wakapi">
|
||||||
</p>
|
|
||||||
|
|
||||||
<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">
|
<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://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
||||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -24,7 +20,7 @@
|
|||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="#-features">Features</a>
|
<a href="#-features">Features</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="#-how-to-use">How to use</a>
|
<a href="#%EF%B8%8F-how-to-use">How to use</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="https://github.com/muety/wakapi/issues">Issues</a>
|
<a href="https://github.com/muety/wakapi/issues">Issues</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
@ -33,32 +29,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="static/assets/images/screenshot.png" width="500px">
|
<img src="static/assets/images/screenshot.webp" width="500px">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Table of Contents
|
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||||
* [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)
|
|
||||||
|
|
||||||
Further instructions can be found in the [Wiki](https://github.com/muety/wakapi/wiki).
|
|
||||||
|
|
||||||
## 📬 **User Survey**
|
|
||||||
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
* ✅ 100 % free and open-source
|
* ✅ 100 % free and open-source
|
||||||
* ✅ Built by developers for developers
|
* ✅ Built by developers for developers
|
||||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||||
* ✅ Badges
|
* ✅ Badges
|
||||||
|
* ✅ Weekly E-Mail reports
|
||||||
* ✅ REST API
|
* ✅ REST API
|
||||||
* ✅ Partially compatible with WakaTime
|
* ✅ Partially compatible with WakaTime
|
||||||
* ✅ WakaTime integration
|
* ✅ WakaTime integration
|
||||||
@ -67,80 +49,80 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
|
|||||||
* ✅ Self-hosted
|
* ✅ Self-hosted
|
||||||
|
|
||||||
## 🚧 Roadmap
|
## 🚧 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).
|
|
||||||
|
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), [#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?
|
## ⌨️ 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.
|
|
||||||
|
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)
|
### ☁️ 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 ❕
|
If you want to try out a free, hosted cloud service, all you need to do is create an account and then set up your client-side tooling (see below).
|
||||||
|
|
||||||
|
### 📦 Option 2: Quick-run a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -L https://wakapi.dev/get | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatively** using [eget](https://github.com/zyedidia/eget):
|
||||||
|
```bash
|
||||||
|
$ eget muety/wakapi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🐳 Option 3: Use Docker
|
||||||
|
|
||||||
### 🐳 Option 2: Use Docker
|
|
||||||
```bash
|
```bash
|
||||||
# Create a persistent volume
|
# Create a persistent volume
|
||||||
$ docker volume create wakapi-data
|
$ docker volume create wakapi-data
|
||||||
|
|
||||||
|
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
|
||||||
|
|
||||||
# Run the container
|
# Run the container
|
||||||
$ docker run -d \
|
$ docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
|
-e "WAKAPI_PASSWORD_SALT=$SALT" \
|
||||||
-v wakapi-data:/data \
|
-v wakapi-data:/data \
|
||||||
--name wakapi n1try/wakapi
|
--name wakapi \
|
||||||
|
ghcr.io/muety/wakapi:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**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.
|
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
|
||||||
|
|
||||||
### 📦 Option 3: Run a release
|
### 🧑💻 Option 4: Compile and run from source
|
||||||
```bash
|
|
||||||
# Download the release and unpack it
|
|
||||||
$ wget https://github.com/muety/wakapi/releases/download/1.20.2/wakapi_linux_amd64.zip
|
|
||||||
$ unzip wakapi_linux_amd64.zip
|
|
||||||
|
|
||||||
# Optionally adapt config to your needs
|
```bash
|
||||||
$ vi config.yml
|
# Build and install
|
||||||
|
# Alternatively: go build -o wakapi
|
||||||
|
$ go install github.com/muety/wakapi@latest
|
||||||
|
|
||||||
|
# Get default config and customize
|
||||||
|
$ curl -o wakapi.yml https://raw.githubusercontent.com/muety/wakapi/master/config.default.yml
|
||||||
|
$ vi wakapi.yml
|
||||||
|
|
||||||
# Run it
|
# Run it
|
||||||
$ ./wakapi
|
$ ./wakapi -config wakapi.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🧑💻 Option 4: Run from source
|
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
|
||||||
#### 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)
|
|
||||||
|
|
||||||
#### Compile & Run
|
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
|
||||||
```bash
|
|
||||||
# Adapt config to your needs
|
|
||||||
$ cp config.default.yml config.yml
|
|
||||||
$ vi config.yml
|
|
||||||
|
|
||||||
# Build the executable
|
### 💻 Client setup
|
||||||
$ go build -o wakapi
|
|
||||||
|
|
||||||
# Run it
|
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics for Wakapi, you need to set them up.
|
||||||
$ ./wakapi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies = true` in `config.yml`.
|
|
||||||
|
|
||||||
### 💻 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)
|
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
|
2. **Edit your local `~/.wakatime.cfg`** file as follows.
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[settings]
|
[settings]
|
||||||
|
|
||||||
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
|
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
|
||||||
api_url = http://localhost:3000/api/heartbeat
|
api_url = http://localhost:3000/api
|
||||||
|
|
||||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||||
@ -148,64 +130,86 @@ 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.
|
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
|
||||||
|
|
||||||
## 🔧 Configuration Options
|
## 🔧 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 |
|
You can specify configuration options either via a config file (default: `config.yml`, customizable through the `-c` argument) or via environment variables. Here is an overview of all options.
|
||||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
|
||||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
| YAML key / Env. variable | Default | Description |
|
||||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
|
||||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
|
||||||
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
| `app.import_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
|
||||||
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
|
||||||
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
|
||||||
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
| `app.custom_languages` | - | Map from file endings to language names |
|
||||||
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
| `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
|
||||||
| `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 |
|
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||||
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
|
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||||
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
|
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||||
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
|
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
|
||||||
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
|
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
|
||||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
||||||
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
|
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||||
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
| `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
|
||||||
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
||||||
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
||||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
| `security.cookie_max_age` /<br> `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 |
|
||||||
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
| `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
|
||||||
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
|
||||||
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
|
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
|
||||||
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details |
|
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
|
||||||
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
|
||||||
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||||
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||||
| `sentry.sample_rate_heartbats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
||||||
|
| `db.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
||||||
|
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||||
|
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||||
|
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
||||||
|
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
||||||
|
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `Wakapi <noreply@wakapi.dev>` | Default sender address for outgoing mails (ignored for MailWhale) |
|
||||||
|
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
||||||
|
| `mail.smtp.host` /<br> `WAKAPI_MAIL_SMTP_HOST` | - | SMTP server address for sending mail (if using `smtp` mail provider) |
|
||||||
|
| `mail.smtp.port` /<br> `WAKAPI_MAIL_SMTP_PORT` | - | SMTP server port (usually 465) |
|
||||||
|
| `mail.smtp.username` /<br> `WAKAPI_MAIL_SMTP_USER` | - | SMTP server authentication username |
|
||||||
|
| `mail.smtp.password` /<br> `WAKAPI_MAIL_SMTP_PASS` | - | SMTP server authentication password |
|
||||||
|
| `mail.smtp.tls` /<br> `WAKAPI_MAIL_SMTP_TLS` | `false` | Whether the SMTP server requires TLS encryption (`false` for STARTTLS or no encryption) |
|
||||||
|
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider) |
|
||||||
|
| `mail.mailwhale.client_id` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_ID` | - | MailWhale API client ID |
|
||||||
|
| `mail.mailwhale.client_secret` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET` | - | MailWhale API client secret |
|
||||||
|
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
||||||
|
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
||||||
|
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||||
|
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeat request in Sentry |
|
||||||
|
| `quick_start` /<br> `WAKAPI_QUICK_START` | `false` | Whether to skip initial boot tasks. Use only for development purposes! |
|
||||||
|
|
||||||
### Supported databases
|
### Supported databases
|
||||||
|
|
||||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||||
|
|
||||||
* [SQLite](https://sqlite.org/) (_default, easy setup_)
|
* [SQLite](https://sqlite.org/) (_default, easy setup_)
|
||||||
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
|
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
|
||||||
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
|
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
|
||||||
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
|
* [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_)
|
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
|
||||||
|
|
||||||
### Client-side proxy (`optional`)
|
## 🔧 API endpoints
|
||||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
|
||||||
|
|
||||||
## 🔧 API Endpoints
|
|
||||||
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
||||||
|
|
||||||
### Generating Swagger docs
|
### Generating Swagger docs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ go get -u github.com/swaggo/swag/cmd/swag
|
$ go install github.com/swaggo/swag/cmd/swag@latest
|
||||||
$ swag init -o static/docs
|
$ swag init -o static/docs
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Integrations
|
## 🤝 Integrations
|
||||||
### Prometheus Export
|
|
||||||
|
### Prometheus export
|
||||||
|
|
||||||
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
|
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -220,6 +224,7 @@ $ echo "<YOUR_API_KEY>" | base64
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Scrape config example
|
#### Scrape config example
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
# prometheus.yml
|
# prometheus.yml
|
||||||
# (assuming your Wakapi instance listens at localhost, port 3000)
|
# (assuming your Wakapi instance listens at localhost, port 3000)
|
||||||
@ -233,69 +238,166 @@ scrape_configs:
|
|||||||
- targets: ['localhost:3000']
|
- targets: ['localhost:3000']
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Grafana
|
#### 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).
|
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
|
### 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.
|
|
||||||
|
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
|
||||||
|
|
||||||
### 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.
|
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
|
<details>
|
||||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
<summary>Click to view code</summary>
|
||||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
|
||||||
|
|
||||||
## 🤓 Developer Notes
|
```markdown
|
||||||
### Running tests
|

|
||||||
```bash
|
|
||||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building Tailwind
|
</details>
|
||||||
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.
|
<br>
|
||||||
|
|
||||||
```bash
|
### Github Readme Metrics integration
|
||||||
$ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🙏 Support
|
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
|
||||||
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
|
Preview:
|
||||||
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>
|
<details>
|
||||||
<summary><b>What data is sent to Wakapi?</b></summary>
|
<summary>Click to view code</summary>
|
||||||
|
|
||||||
|
```yml
|
||||||
|
- uses: lowlighter/metrics@latest
|
||||||
|
with:
|
||||||
|
# ... other options
|
||||||
|
plugin_wakatime: yes
|
||||||
|
plugin_wakatime_token: ${{ secrets.WAKATIME_TOKEN }} # Required
|
||||||
|
plugin_wakatime_days: 7 # Display last week stats
|
||||||
|
plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs
|
||||||
|
plugin_wakatime_limit: 4 # Show 4 entries per graph
|
||||||
|
plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint
|
||||||
|
plugin_wakatime_user: .user.login # User
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 👍 Best practices
|
||||||
|
|
||||||
|
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), 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`.
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
|
||||||
|
|
||||||
|
#### How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### API tests
|
||||||
|
|
||||||
|
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
|
||||||
|
|
||||||
|
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
|
||||||
|
|
||||||
|
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
|
||||||
|
|
||||||
|
#### Prerequisites (Linux only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. sqlite (cli)
|
||||||
|
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
|
||||||
|
|
||||||
|
# 2. newman
|
||||||
|
$ npm install -g newman
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How to run (Linux only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./testing/run_api_tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤓 Developer notes
|
||||||
|
|
||||||
|
### Building web assets
|
||||||
|
|
||||||
|
To keep things minimal, all JS and CSS assets are included as static files and checked in to Git. [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) and [Iconify](https://iconify.design/docs/icon-bundles/) require an additional build step. To only require this at the time of development, the compiled assets are checked in to Git as well.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ yarn
|
||||||
|
$ yarn build # or: yarn watch
|
||||||
|
```
|
||||||
|
|
||||||
|
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
||||||
|
|
||||||
|
#### Precompression
|
||||||
|
|
||||||
|
As explained in [#284](https://github.com/muety/wakapi/issues/284), precompressed (using Brotli) versions of some of the assets are delivered to save additional bandwidth. This was inspired by Caddy's [`precompressed`](https://caddyserver.com/docs/caddyfile/directives/file_server) directive. [`gzipped.FileServer`](https://github.com/muety/wakapi/blob/07a367ce0a97c7738ba8e255e9c72df273fd43a3/main.go#L249) checks for every static file's `.br` or `.gz` equivalents and, if present, delivers those instead of the actual file, alongside `Content-Encoding: br`. Currently, compressed assets are simply checked in to Git. Later we might want to have this be part of a new build step.
|
||||||
|
|
||||||
|
To pre-compress files, run this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install brotli first
|
||||||
|
$ sudo apt install brotli # or: sudo dnf install brotli
|
||||||
|
|
||||||
|
# Watch, build and compress
|
||||||
|
$ yarn watch:compress
|
||||||
|
|
||||||
|
# Alternatively: build and compress only
|
||||||
|
$ yarn build:all:compress
|
||||||
|
|
||||||
|
# Alternatively: compress only
|
||||||
|
$ yarn compress
|
||||||
|
```
|
||||||
|
|
||||||
|
## ❔ FAQs
|
||||||
|
|
||||||
|
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) largely apply to Wakapi as well. You might find answers there.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>What data are sent to Wakapi?</b></summary>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>File names</li>
|
<li>File names</li>
|
||||||
<li>Project names</li>
|
<li>Project names</li>
|
||||||
<li>Editor names</li>
|
<li>Editor names</li>
|
||||||
<li>You computer's host name</li>
|
<li>Your computer's host name</li>
|
||||||
<li>Timestamps for every action you take in your editor</li>
|
<li>Timestamps for every action you take in your editor</li>
|
||||||
<li>...</li>
|
<li>...</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
|
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.
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>What happens if I'm offline?</b></summary>
|
<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.
|
All data are cached locally on your machine and sent in batches once you're online again.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>How did Wakapi come about?</b></summary>
|
<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!
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@ -308,27 +410,27 @@ Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime
|
|||||||
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
|
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
|
||||||
<li>Personal Goals</li>
|
<li>Personal Goals</li>
|
||||||
<li>Team- / Organization Support</li>
|
<li>Team- / Organization Support</li>
|
||||||
<li>Integrations (with GitLab, etc.)</li>
|
<li>Additional Integrations (with GitLab, etc.)</li>
|
||||||
<li>Richer API</li>
|
<li>Richer API</li>
|
||||||
</ul>
|
</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.
|
WakaTime is worth the price. However, if you only need basic statistics and like to keep sovereignty over your data, you might want to go with Wakapi.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>How are durations calculated?</b></summary>
|
<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.
|
Inferring a measure for your coding time from heartbeats works a bit differently 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):
|
Here is an example (circles are heartbeats):
|
||||||
|
|
||||||
```
|
```text
|
||||||
|---o---o--------------o---o---|
|
|---o---o--------------o---o---|
|
||||||
| |10s| 3m |10s| |
|
| |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.
|
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 staring at the screen trying to find a solution, but not actually typing code?
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
|
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
|
||||||
@ -339,8 +441,22 @@ It is unclear how to handle the three minutes in between. Did the developer do a
|
|||||||
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## 🌳 Treeware
|
||||||
|
|
||||||
|
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats.
|
||||||
|
|
||||||
|
## 👏 Support
|
||||||
|
|
||||||
|
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So 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 my motivation to keep improving Wakapi!
|
||||||
|
|
||||||
## 🙏 Thanks
|
## 🙏 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.
|
|
||||||
|
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
|
||||||
|
|
||||||
|
Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 📓 License
|
## 📓 License
|
||||||
|
|
||||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||||
|
@ -1,57 +1,77 @@
|
|||||||
env: development
|
env: production
|
||||||
|
|
||||||
server:
|
server:
|
||||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||||
tls_cert_path: # leave blank to not use https
|
listen_socket: # leave blank to disable unix sockets
|
||||||
tls_key_path: # leave blank to not use https
|
timeout_sec: 30 # request timeout
|
||||||
|
tls_cert_path: # leave blank to not use https
|
||||||
|
tls_key_path: # leave blank to not use https
|
||||||
port: 3000
|
port: 3000
|
||||||
base_path: /
|
base_path: /
|
||||||
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
||||||
|
|
||||||
app:
|
app:
|
||||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
leaderboard_generation_time: '06:00;18:00' # time at which to run daily aggregation batch jobs
|
||||||
|
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
|
||||||
|
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||||
|
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
|
||||||
|
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
|
||||||
custom_languages:
|
custom_languages:
|
||||||
vue: Vue
|
vue: Vue
|
||||||
jsx: JSX
|
jsx: JSX
|
||||||
svelte: Svelte
|
svelte: Svelte
|
||||||
|
|
||||||
|
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||||
|
# available variable placeholders are: username, username_hash, email, email_hash
|
||||||
|
# defaults to wakapi's internal avatar rendering powered by https://codeberg.org/Codeberg/avatars
|
||||||
|
avatar_url_template: api/avatar/{username_hash}.svg
|
||||||
|
|
||||||
db:
|
db:
|
||||||
host: # leave blank when using sqlite3
|
host: # leave blank when using sqlite3
|
||||||
port: # leave blank when using sqlite3
|
port: # leave blank when using sqlite3
|
||||||
user: # leave blank when using sqlite3
|
user: # leave blank when using sqlite3
|
||||||
password: # leave blank when using sqlite3
|
password: # leave blank when using sqlite3
|
||||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||||
charset: utf8mb4 # only used for mysql connections
|
charset: utf8mb4 # only used for mysql connections
|
||||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||||
|
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||||
|
|
||||||
security:
|
security:
|
||||||
password_salt: # CHANGE !
|
password_salt: # change this
|
||||||
insecure_cookies: false # You need to set this to 'true' when on localhost
|
insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
|
||||||
cookie_max_age: 172800
|
cookie_max_age: 172800
|
||||||
allow_signup: true
|
allow_signup: true
|
||||||
expose_metrics: false
|
expose_metrics: false
|
||||||
|
enable_proxy: false # only intended for production instance at wakapi.dev
|
||||||
|
|
||||||
sentry:
|
sentry:
|
||||||
dsn: # leave blank to disable sentry integration
|
dsn: # leave blank to disable sentry integration
|
||||||
enable_tracing: true # whether to use performance monitoring
|
enable_tracing: true # whether to use performance monitoring
|
||||||
sample_rate: 0.75 # probability of tracing a request
|
sample_rate: 0.75 # probability of tracing a request
|
||||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
||||||
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
||||||
smtp: # smtp settings when sending mails via smtp
|
sender: Wakapi <noreply@wakapi.dev> # ignored for mailwhale
|
||||||
|
|
||||||
|
# smtp settings when sending mails via smtp
|
||||||
|
smtp:
|
||||||
host:
|
host:
|
||||||
port:
|
port:
|
||||||
username:
|
username:
|
||||||
password:
|
password:
|
||||||
tls:
|
tls:
|
||||||
sender: Wakapi <noreply@wakapi.dev>
|
|
||||||
mailwhale: # mailwhale.dev settings when using mailwhale as sending service
|
# mailwhale.dev settings when using mailwhale as sending service
|
||||||
|
mailwhale:
|
||||||
url:
|
url:
|
||||||
client_id:
|
client_id:
|
||||||
client_secret:
|
client_secret:
|
||||||
|
|
||||||
|
quick_start: false # whether to skip initial tasks on application startup, like summary generation
|
||||||
|
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
|
285
config/config.go
285
config/config.go
@ -4,18 +4,19 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/gorilla/securecookie"
|
||||||
"github.com/jinzhu/configor"
|
"github.com/jinzhu/configor"
|
||||||
"github.com/muety/wakapi/data"
|
"github.com/muety/wakapi/data"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/driver/mysql"
|
uuid "github.com/satori/go.uuid"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,11 +30,13 @@ const (
|
|||||||
KeyLatestTotalTime = "latest_total_time"
|
KeyLatestTotalTime = "latest_total_time"
|
||||||
KeyLatestTotalUsers = "latest_total_users"
|
KeyLatestTotalUsers = "latest_total_users"
|
||||||
KeyLastImportImport = "last_import"
|
KeyLastImportImport = "last_import"
|
||||||
|
KeyNewsbox = "newsbox"
|
||||||
|
|
||||||
SimpleDateFormat = "2006-01-02"
|
SimpleDateFormat = "2006-01-02"
|
||||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
ErrUnauthorized = "401 unauthorized"
|
ErrUnauthorized = "401 unauthorized"
|
||||||
|
ErrBadRequest = "400 bad request"
|
||||||
ErrInternalServerError = "500 internal server error"
|
ErrInternalServerError = "500 internal server error"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -59,19 +62,26 @@ var emailProviders = []string{
|
|||||||
|
|
||||||
var cfg *Config
|
var cfg *Config
|
||||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||||
|
var env string
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"06:00;18:00" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
|
||||||
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||||
|
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
|
||||||
|
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||||
|
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
|
||||||
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type securityConfig struct {
|
type securityConfig struct {
|
||||||
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
|
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
|
||||||
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
|
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
|
||||||
|
EnableProxy bool `yaml:"enable_proxy" default:"false" env:"WAKAPI_ENABLE_PROXY"` // only intended for production instance at wakapi.dev
|
||||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||||
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
||||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||||
@ -80,26 +90,29 @@ type securityConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dbConfig struct {
|
type dbConfig struct {
|
||||||
Host string `env:"WAKAPI_DB_HOST"`
|
Host string `env:"WAKAPI_DB_HOST"`
|
||||||
Port uint `env:"WAKAPI_DB_PORT"`
|
Port uint `env:"WAKAPI_DB_PORT"`
|
||||||
User string `env:"WAKAPI_DB_USER"`
|
User string `env:"WAKAPI_DB_USER"`
|
||||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||||
Dialect string `yaml:"-"`
|
Dialect string `yaml:"-"`
|
||||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||||
|
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
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"`
|
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
|
||||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
|
||||||
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
|
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
|
||||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_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 {
|
type sentryConfig struct {
|
||||||
@ -114,6 +127,7 @@ type mailConfig struct {
|
|||||||
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
|
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
|
||||||
MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
|
MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
|
||||||
Smtp SMTPMailConfig `yaml:"smtp"`
|
Smtp SMTPMailConfig `yaml:"smtp"`
|
||||||
|
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailwhaleMailConfig struct {
|
type MailwhaleMailConfig struct {
|
||||||
@ -128,26 +142,28 @@ type SMTPMailConfig struct {
|
|||||||
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
|
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
|
||||||
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
|
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
|
||||||
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
|
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
|
||||||
Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string `default:"dev" env:"ENVIRONMENT"`
|
Env string `default:"dev" env:"ENVIRONMENT"`
|
||||||
Version string `yaml:"-"`
|
Version string `yaml:"-"`
|
||||||
App appConfig
|
QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
|
||||||
Security securityConfig
|
SkipMigrations bool `yaml:"skip_migrations" env:"WAKAPI_SKIP_MIGRATIONS"`
|
||||||
Db dbConfig
|
InstanceId string `yaml:"-"` // only temporary, changes between runs
|
||||||
Server serverConfig
|
App appConfig
|
||||||
Sentry sentryConfig
|
Security securityConfig
|
||||||
Mail mailConfig
|
Db dbConfig
|
||||||
|
Server serverConfig
|
||||||
|
Sentry sentryConfig
|
||||||
|
Mail mailConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
|
func (c *Config) CreateCookie(name, value string) *http.Cookie {
|
||||||
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
|
return c.createCookie(name, value, c.Server.BasePath, c.Security.CookieMaxAgeSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
|
func (c *Config) GetClearCookie(name string) *http.Cookie {
|
||||||
return c.createCookie(name, "", path, -1)
|
return c.createCookie(name, "", c.Server.BasePath, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
|
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
|
||||||
@ -174,68 +190,41 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
|||||||
switch dbDialect {
|
switch dbDialect {
|
||||||
default:
|
default:
|
||||||
return func(db *gorm.DB) error {
|
return func(db *gorm.DB) error {
|
||||||
db.AutoMigrate(&models.User{})
|
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
db.AutoMigrate(&models.KeyStringValue{})
|
return err
|
||||||
db.AutoMigrate(&models.Alias{})
|
}
|
||||||
db.AutoMigrate(&models.Heartbeat{})
|
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
db.AutoMigrate(&models.Summary{})
|
return err
|
||||||
db.AutoMigrate(&models.SummaryItem{})
|
}
|
||||||
db.AutoMigrate(&models.LanguageMapping{})
|
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
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
|
||||||
switch c.Dialect {
|
|
||||||
case SQLDialectMysql:
|
|
||||||
return mysql.New(mysql.Config{
|
|
||||||
DriverName: c.Dialect,
|
|
||||||
DSN: mysqlConnectionString(c),
|
|
||||||
})
|
|
||||||
case SQLDialectPostgres:
|
|
||||||
return postgres.New(postgres.Config{
|
|
||||||
DSN: postgresConnectionString(c),
|
|
||||||
})
|
|
||||||
case SQLDialectSqlite:
|
|
||||||
return sqlite.Open(sqliteConnectionString(c))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mysqlConnectionString(config *dbConfig) string {
|
|
||||||
//location, _ := time.LoadLocation("Local")
|
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
|
||||||
config.User,
|
|
||||||
config.Password,
|
|
||||||
config.Host,
|
|
||||||
config.Port,
|
|
||||||
config.Name,
|
|
||||||
config.Charset,
|
|
||||||
"Local",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postgresConnectionString(config *dbConfig) string {
|
|
||||||
sslmode := "disable"
|
|
||||||
if config.Ssl {
|
|
||||||
sslmode = "require"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
|
||||||
config.Host,
|
|
||||||
config.Port,
|
|
||||||
config.User,
|
|
||||||
config.Name,
|
|
||||||
config.Password,
|
|
||||||
sslmode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqliteConnectionString(config *dbConfig) string {
|
|
||||||
return config.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||||
return cloneStringMap(c.CustomLanguages, false)
|
return cloneStringMap(c.CustomLanguages, false)
|
||||||
}
|
}
|
||||||
@ -252,6 +241,32 @@ func (c *appConfig) GetOSColors() map[string]string {
|
|||||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
|
||||||
|
s := strings.Split(c.ReportTimeWeekly, ",")[0]
|
||||||
|
return parseWeekday(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *appConfig) GetWeeklyReportTime() string {
|
||||||
|
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
|
||||||
|
d, _ := time.ParseDuration(c.HeartbeatMaxAge)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dbConfig) IsSQLite() bool {
|
||||||
|
return c.Dialect == "sqlite3"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dbConfig) IsMySQL() bool {
|
||||||
|
return c.Dialect == "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dbConfig) IsPostgres() bool {
|
||||||
|
return c.Dialect == "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *serverConfig) GetPublicUrl() string {
|
func (c *serverConfig) GetPublicUrl() string {
|
||||||
return strings.TrimSuffix(c.PublicUrl, "/")
|
return strings.TrimSuffix(c.PublicUrl, "/")
|
||||||
}
|
}
|
||||||
@ -267,14 +282,20 @@ func IsDev(env string) bool {
|
|||||||
func readColors() map[string]map[string]string {
|
func readColors() map[string]map[string]string {
|
||||||
// Read language colors
|
// Read language colors
|
||||||
// Source:
|
// Source:
|
||||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
// - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||||
// – https://wakatime.com/colors/operating_systems
|
// - https://wakatime.com/colors/operating_systems
|
||||||
// - https://wakatime.com/colors/editors
|
// - https://wakatime.com/colors/editors
|
||||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
// - $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
// - $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||||
|
|
||||||
|
raw := data.ColorsFile
|
||||||
|
if IsDev(env) {
|
||||||
|
raw, _ = ioutil.ReadFile("data/colors.json")
|
||||||
|
}
|
||||||
|
|
||||||
var colors = make(map[string]map[string]string)
|
var colors = make(map[string]map[string]string)
|
||||||
if err := json.Unmarshal(data.ColorsFile, &colors); err != nil {
|
if err := json.Unmarshal(raw, &colors); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,6 +313,12 @@ func resolveDbDialect(dbType string) string {
|
|||||||
if dbType == "cockroach" {
|
if dbType == "cockroach" {
|
||||||
return "postgres"
|
return "postgres"
|
||||||
}
|
}
|
||||||
|
if dbType == "sqlite" {
|
||||||
|
return "sqlite3"
|
||||||
|
}
|
||||||
|
if dbType == "mariadb" {
|
||||||
|
return "mysql"
|
||||||
|
}
|
||||||
return dbType
|
return dbType
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,6 +331,26 @@ func findString(needle string, haystack []string, defaultVal string) string {
|
|||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseWeekday(s string) time.Weekday {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "mon", strings.ToLower(time.Monday.String()):
|
||||||
|
return time.Monday
|
||||||
|
case "tue", strings.ToLower(time.Tuesday.String()):
|
||||||
|
return time.Tuesday
|
||||||
|
case "wed", strings.ToLower(time.Wednesday.String()):
|
||||||
|
return time.Wednesday
|
||||||
|
case "thu", strings.ToLower(time.Thursday.String()):
|
||||||
|
return time.Thursday
|
||||||
|
case "fri", strings.ToLower(time.Friday.String()):
|
||||||
|
return time.Friday
|
||||||
|
case "sat", strings.ToLower(time.Saturday.String()):
|
||||||
|
return time.Saturday
|
||||||
|
case "sun", strings.ToLower(time.Sunday.String()):
|
||||||
|
return time.Sunday
|
||||||
|
}
|
||||||
|
return time.Monday
|
||||||
|
}
|
||||||
|
|
||||||
func Set(config *Config) {
|
func Set(config *Config) {
|
||||||
cfg = config
|
cfg = config
|
||||||
}
|
}
|
||||||
@ -321,7 +368,15 @@ func Load(version string) *Config {
|
|||||||
logbuch.Fatal("failed to read config: %v", err)
|
logbuch.Fatal("failed to read config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env = config.Env
|
||||||
|
|
||||||
config.Version = strings.TrimSpace(version)
|
config.Version = strings.TrimSpace(version)
|
||||||
|
tagVersionMatch, _ := regexp.MatchString(`\d+\.\d+\.\d+`, version)
|
||||||
|
if tagVersionMatch {
|
||||||
|
config.Version = "v" + config.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
config.InstanceId = uuid.NewV4().String()
|
||||||
config.App.Colors = readColors()
|
config.App.Colors = readColors()
|
||||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||||
config.Security.SecureCookie = securecookie.New(
|
config.Security.SecureCookie = securecookie.New(
|
||||||
@ -339,22 +394,34 @@ func Load(version string) *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
|
||||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Db.MaxConn <= 0 {
|
|
||||||
logbuch.Fatal("you must allow at least one database connection")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Sentry.Dsn != "" {
|
if config.Sentry.Dsn != "" {
|
||||||
logbuch.Info("enabling sentry integration")
|
logbuch.Info("enabling sentry integration")
|
||||||
initSentry(config.Sentry, config.IsDev())
|
initSentry(config.Sentry, config.IsDev())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// some validation checks
|
||||||
|
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
|
||||||
|
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
|
||||||
|
}
|
||||||
|
if config.Db.MaxConn <= 0 {
|
||||||
|
logbuch.Fatal("you must allow at least one database connection")
|
||||||
|
}
|
||||||
|
if config.Db.MaxConn > 1 && config.Db.IsSQLite() {
|
||||||
|
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
|
||||||
|
config.Db.MaxConn = 1
|
||||||
|
}
|
||||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||||
}
|
}
|
||||||
|
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
|
||||||
|
logbuch.Fatal("invalid interval set for report_time_weekly")
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
|
||||||
|
logbuch.Fatal("invalid interval set for aggregation_time")
|
||||||
|
}
|
||||||
|
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
|
||||||
|
logbuch.Fatal("invalid duration set for heartbeat_max_age")
|
||||||
|
}
|
||||||
|
|
||||||
Set(config)
|
Set(config)
|
||||||
return Get()
|
return Get()
|
||||||
|
86
config/db.go
Normal file
86
config/db.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"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
|
||||||
|
}
|
32
config/eventbus.go
Normal file
32
config/eventbus.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "github.com/leandro-lugaresi/hub"
|
||||||
|
|
||||||
|
type ApplicationEvent struct {
|
||||||
|
Type string
|
||||||
|
Payload interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopicUser = "user.*"
|
||||||
|
TopicHeartbeat = "heartbeat.*"
|
||||||
|
TopicProjectLabel = "project_label.*"
|
||||||
|
EventUserUpdate = "user.update"
|
||||||
|
EventHeartbeatCreate = "heartbeat.create"
|
||||||
|
EventProjectLabelCreate = "project_label.create"
|
||||||
|
EventProjectLabelDelete = "project_label.delete"
|
||||||
|
EventWakatimeFailure = "wakatime.failure"
|
||||||
|
FieldPayload = "payload"
|
||||||
|
FieldUser = "user"
|
||||||
|
FieldUserId = "user.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var eventHub *hub.Hub
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
eventHub = hub.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func EventBus() *hub.Hub {
|
||||||
|
return eventHub
|
||||||
|
}
|
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
|
||||||
|
}
|
131
config/sentry.go
131
config/sentry.go
@ -4,28 +4,114 @@ import (
|
|||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SentryErrorWriter struct{}
|
// How to: Logging
|
||||||
|
// Use logbuch.[Debug|Info|Warn|Error|Fatal]() by default
|
||||||
|
// Use config.Log().[Debug|Info|Warn|Error|Fatal]() when wanting the log to appear in Sentry as well
|
||||||
|
|
||||||
// TODO: extend sentry error logging to include context and stacktrace
|
type capturingWriter struct {
|
||||||
// see https://github.com/muety/wakapi/issues/169
|
Writer io.Writer
|
||||||
func (s *SentryErrorWriter) Write(p []byte) (n int, err error) {
|
Message string
|
||||||
sentry.CaptureMessage(string(p))
|
|
||||||
return os.Stderr.Write(p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func (c *capturingWriter) Clear() {
|
||||||
logbuch.SetOutput(os.Stdout, &SentryErrorWriter{})
|
c.Message = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capturingWriter) Write(p []byte) (n int, err error) {
|
||||||
|
c.Message = string(p)
|
||||||
|
return c.Writer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentryWrapperLogger is a wrapper around a logbuch.Logger that forwards events to Sentry in addition and optionally allows to attach a request context
|
||||||
|
type SentryWrapperLogger struct {
|
||||||
|
*logbuch.Logger
|
||||||
|
req *http.Request
|
||||||
|
outWriter *capturingWriter
|
||||||
|
errWriter *capturingWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func Log() *SentryWrapperLogger {
|
||||||
|
ow, ew := &capturingWriter{Writer: os.Stdout}, &capturingWriter{Writer: os.Stderr}
|
||||||
|
return &SentryWrapperLogger{
|
||||||
|
Logger: logbuch.NewLogger(ow, ew),
|
||||||
|
outWriter: ow,
|
||||||
|
errWriter: ew,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Request(req *http.Request) *SentryWrapperLogger {
|
||||||
|
l.req = req
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Debug(msg string, params ...interface{}) {
|
||||||
|
l.outWriter.Clear()
|
||||||
|
l.Logger.Debug(msg, params...)
|
||||||
|
l.log(l.errWriter.Message, sentry.LevelDebug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Info(msg string, params ...interface{}) {
|
||||||
|
l.outWriter.Clear()
|
||||||
|
l.Logger.Info(msg, params...)
|
||||||
|
l.log(l.errWriter.Message, sentry.LevelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Warn(msg string, params ...interface{}) {
|
||||||
|
l.outWriter.Clear()
|
||||||
|
l.Logger.Warn(msg, params...)
|
||||||
|
l.log(l.errWriter.Message, sentry.LevelWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Error(msg string, params ...interface{}) {
|
||||||
|
l.errWriter.Clear()
|
||||||
|
l.Logger.Error(msg, params...)
|
||||||
|
l.log(l.errWriter.Message, sentry.LevelError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) Fatal(msg string, params ...interface{}) {
|
||||||
|
l.errWriter.Clear()
|
||||||
|
l.Logger.Fatal(msg, params...)
|
||||||
|
l.log(l.errWriter.Message, sentry.LevelFatal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
|
||||||
|
event := sentry.NewEvent()
|
||||||
|
event.Level = level
|
||||||
|
event.Message = msg
|
||||||
|
|
||||||
|
if l.req != nil {
|
||||||
|
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
||||||
|
hub := h.(*sentry.Hub)
|
||||||
|
hub.Scope().SetRequest(l.req)
|
||||||
|
if u := getPrincipal(l.req); u != nil {
|
||||||
|
hub.Scope().SetUser(sentry.User{ID: u.ID})
|
||||||
|
}
|
||||||
|
hub.CaptureEvent(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sentry.CaptureEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludedRoutes = []string{
|
||||||
|
"GET /assets",
|
||||||
|
"GET /api/health",
|
||||||
|
"GET /swagger-ui",
|
||||||
|
"GET /docs",
|
||||||
}
|
}
|
||||||
|
|
||||||
func initSentry(config sentryConfig, debug bool) {
|
func initSentry(config sentryConfig, debug bool) {
|
||||||
if err := sentry.Init(sentry.ClientOptions{
|
if err := sentry.Init(sentry.ClientOptions{
|
||||||
Dsn: config.Dsn,
|
Dsn: config.Dsn,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
|
AttachStacktrace: true,
|
||||||
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
||||||
if !config.EnableTracing {
|
if !config.EnableTracing {
|
||||||
return sentry.SampledFalse
|
return sentry.SampledFalse
|
||||||
@ -34,8 +120,10 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
||||||
txName := hub.Scope().Transaction()
|
txName := hub.Scope().Transaction()
|
||||||
|
|
||||||
if strings.HasPrefix(txName, "GET /assets") || strings.HasPrefix(txName, "GET /api/health") {
|
for _, ex := range excludedRoutes {
|
||||||
return sentry.SampledFalse
|
if strings.HasPrefix(txName, ex) {
|
||||||
|
return sentry.SampledFalse
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if txName == "POST /api/heartbeat" {
|
if txName == "POST /api/heartbeat" {
|
||||||
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
|
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
|
||||||
@ -43,19 +131,26 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
|
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
|
||||||
}),
|
}),
|
||||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||||
type principalGetter interface {
|
|
||||||
GetPrincipal() *models.User
|
|
||||||
}
|
|
||||||
if hint.Context != nil {
|
if hint.Context != nil {
|
||||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||||
if p := req.Context().Value("principal"); p != nil {
|
if u := getPrincipal(req); u != nil {
|
||||||
event.User.ID = p.(principalGetter).GetPrincipal().ID
|
event.User.ID = u.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
logbuch.Fatal("failed to initialized sentry - %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPrincipal(r *http.Request) *models.User {
|
||||||
|
type principalGetter interface {
|
||||||
|
GetPrincipal() *models.User
|
||||||
|
}
|
||||||
|
if p := r.Context().Value("principal"); p != nil {
|
||||||
|
return p.(principalGetter).GetPrincipal()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -9,4 +9,5 @@ const (
|
|||||||
ResetPasswordTemplate = "reset-password.tpl.html"
|
ResetPasswordTemplate = "reset-password.tpl.html"
|
||||||
SettingsTemplate = "settings.tpl.html"
|
SettingsTemplate = "settings.tpl.html"
|
||||||
SummaryTemplate = "summary.tpl.html"
|
SummaryTemplate = "summary.tpl.html"
|
||||||
|
LeaderboardTemplate = "leaderboard.tpl.html"
|
||||||
)
|
)
|
||||||
|
File diff suppressed because it is too large
Load Diff
347
data/colors.json
347
data/colors.json
@ -2,291 +2,396 @@
|
|||||||
"languages": {
|
"languages": {
|
||||||
"1C Enterprise": "#814CCC",
|
"1C Enterprise": "#814CCC",
|
||||||
"ABAP": "#E8274B",
|
"ABAP": "#E8274B",
|
||||||
"ActionScript": "#882B0F",
|
|
||||||
"Ada": "#02f88c",
|
|
||||||
"Agda": "#315665",
|
|
||||||
"AGS Script": "#B9D9FF",
|
"AGS Script": "#B9D9FF",
|
||||||
"Alloy": "#64C800",
|
"AL": "#3AA2B5",
|
||||||
"AMPL": "#E6EFBB",
|
"AMPL": "#E6EFBB",
|
||||||
"AngelScript": "#C7D7DC",
|
|
||||||
"ANTLR": "#9DC3FF",
|
"ANTLR": "#9DC3FF",
|
||||||
"API Blueprint": "#2ACCA8",
|
"API Blueprint": "#2ACCA8",
|
||||||
"APL": "#5A8164",
|
"APL": "#8a0707",
|
||||||
"AppleScript": "#101F1F",
|
"ASP.NET": "#9400ff",
|
||||||
"Arc": "#aa2afe",
|
|
||||||
"ASP": "#6a40fd",
|
|
||||||
"AspectJ": "#a957b0",
|
|
||||||
"Assembly": "#6E4C13",
|
|
||||||
"Asymptote": "#4a0c0c",
|
|
||||||
"ATS": "#1ac620",
|
"ATS": "#1ac620",
|
||||||
|
"ActionScript": "#e3491a",
|
||||||
|
"Ada": "#02f88c",
|
||||||
|
"Agda": "#467C91",
|
||||||
|
"Alloy": "#cc5c24",
|
||||||
|
"AngelScript": "#C7D7DC",
|
||||||
|
"Apex": "#1797c0",
|
||||||
|
"Apollo Guidance Computer": "#0B3D91",
|
||||||
|
"AppleScript": "#101F1F",
|
||||||
|
"Arc": "#ca2afe",
|
||||||
|
"Arduino": "#bd79d1",
|
||||||
|
"AspectJ": "#1957b0",
|
||||||
|
"Assembly": "#6E4C13",
|
||||||
|
"Asymptote": "#ff0000",
|
||||||
|
"Augeas": "#62331f",
|
||||||
"AutoHotkey": "#6594b9",
|
"AutoHotkey": "#6594b9",
|
||||||
"AutoIt": "#1C3552",
|
"AutoIt": "#36699B",
|
||||||
"Ballerina": "#FF5000",
|
"Ballerina": "#FF5000",
|
||||||
"Batchfile": "#C1F12E",
|
"Batchfile": "#C1F12E",
|
||||||
|
"Beef": "#a52f4e",
|
||||||
|
"Bison": "#6A463F",
|
||||||
|
"Blade": "#f7523f",
|
||||||
"BlitzMax": "#cd6400",
|
"BlitzMax": "#cd6400",
|
||||||
"Boo": "#d4bec1",
|
"Boo": "#d4bec1",
|
||||||
|
"Boogie": "#c80fa0",
|
||||||
"Brainfuck": "#2F2530",
|
"Brainfuck": "#2F2530",
|
||||||
|
"Browserslist": "#ffd539",
|
||||||
"C": "#555555",
|
"C": "#555555",
|
||||||
"C#": "#178600",
|
"C Sharp": "#178600",
|
||||||
|
"C#": "#5a25a2",
|
||||||
"C++": "#f34b7d",
|
"C++": "#f34b7d",
|
||||||
|
"CSON": "#244776",
|
||||||
|
"CSS": "#563d7c",
|
||||||
"Ceylon": "#dfa535",
|
"Ceylon": "#dfa535",
|
||||||
"Chapel": "#8dc63f",
|
"Chapel": "#8dc63f",
|
||||||
"Cirru": "#ccccff",
|
"Cirru": "#aaaaff",
|
||||||
"Clarion": "#db901e",
|
"Clarion": "#db901e",
|
||||||
"Clean": "#3F85AF",
|
"Classic ASP": "#6a40fd",
|
||||||
|
"Clean": "#3a81ad",
|
||||||
"Click": "#E4E6F3",
|
"Click": "#E4E6F3",
|
||||||
"Clojure": "#db5855",
|
"Clojure": "#db5855",
|
||||||
|
"Closure Templates": "#0d948f",
|
||||||
"CoffeeScript": "#244776",
|
"CoffeeScript": "#244776",
|
||||||
"ColdFusion": "#ed2cd6",
|
"ColdFusion": "#ed2cd6",
|
||||||
|
"ColdFusion CFC": "#ed2cd6",
|
||||||
"Common Lisp": "#3fb68b",
|
"Common Lisp": "#3fb68b",
|
||||||
"Common Workflow Language": "#B5314C",
|
"Common Workflow Language": "#B5314C",
|
||||||
"Component Pascal": "#B0CE4E",
|
"Component Pascal": "#b0ce4e",
|
||||||
"Crystal": "#000100",
|
"Crystal": "#000100",
|
||||||
"CSS": "#563d7c",
|
|
||||||
"Cuda": "#3A4E3A",
|
"Cuda": "#3A4E3A",
|
||||||
"D": "#ba595e",
|
"D": "#fcd46d",
|
||||||
"Dart": "#00B4AB",
|
"DM": "#075ff1",
|
||||||
|
"Dafny": "#FFEC25",
|
||||||
|
"Dart": "#98BAD6",
|
||||||
"DataWeave": "#003a52",
|
"DataWeave": "#003a52",
|
||||||
"DM": "#447265",
|
"Denizen": "#faf094",
|
||||||
|
"Dhall": "#dfafff",
|
||||||
"Dockerfile": "#384d54",
|
"Dockerfile": "#384d54",
|
||||||
"Docker": "#384d54",
|
|
||||||
"Dogescript": "#cca760",
|
"Dogescript": "#cca760",
|
||||||
"Dylan": "#6c616e",
|
"Dylan": "#3ebc27",
|
||||||
"E": "#ccce35",
|
"E": "#ccce35",
|
||||||
"eC": "#913960",
|
|
||||||
"ECL": "#8a1267",
|
"ECL": "#8a1267",
|
||||||
|
"EJS": "#a91e50",
|
||||||
|
"EQ": "#a78649",
|
||||||
|
"Eagle": "#3994bc",
|
||||||
"Eiffel": "#946d57",
|
"Eiffel": "#946d57",
|
||||||
"Elixir": "#6e4a7e",
|
"Elixir": "#6e4a7e",
|
||||||
"Elm": "#60B5CC",
|
"Elm": "#60B5CC",
|
||||||
"Emacs Lisp": "#c065db",
|
"Emacs Lisp": "#c065db",
|
||||||
"EmberScript": "#FFF4F3",
|
"EmberScript": "#f64e3e",
|
||||||
"EQ": "#a78649",
|
"Erlang": "#0faf8d",
|
||||||
"Erlang": "#B83998",
|
|
||||||
"F#": "#b845fc",
|
"F#": "#b845fc",
|
||||||
"F*": "#572e30",
|
"F*": "#572e30",
|
||||||
|
"FLUX": "#33CCFF",
|
||||||
|
"FORTRAN": "#4d41b1",
|
||||||
"Factor": "#636746",
|
"Factor": "#636746",
|
||||||
"Fancy": "#7b9db4",
|
"Fancy": "#7b9db4",
|
||||||
"Fantom": "#14253c",
|
"Fantom": "#dbded5",
|
||||||
"FLUX": "#88ccff",
|
"Faust": "#c37240",
|
||||||
"Forth": "#341708",
|
"Forth": "#341708",
|
||||||
"Fortran": "#4d41b1",
|
"Fortran": "#4d41b1",
|
||||||
"FreeMarker": "#0050b2",
|
"FreeMarker": "#0050b2",
|
||||||
"Frege": "#00cafe",
|
"Frege": "#00cafe",
|
||||||
"Game Maker Language": "#71b417",
|
"Futhark": "#5f021f",
|
||||||
|
"G-code": "#D08CF2",
|
||||||
|
"GAML": "#FFC766",
|
||||||
"GDScript": "#355570",
|
"GDScript": "#355570",
|
||||||
|
"Game Maker Language": "#8ad353",
|
||||||
"Genie": "#fb855d",
|
"Genie": "#fb855d",
|
||||||
"Gherkin": "#5B2063",
|
"Gherkin": "#5B2063",
|
||||||
"Glyph": "#c1ac7f",
|
"Glyph": "#e4cc98",
|
||||||
"Gnuplot": "#f0a9f0",
|
"Gnuplot": "#f0a9f0",
|
||||||
"Go": "#00ADD8",
|
"Go": "#375eab",
|
||||||
"Golo": "#88562A",
|
"Golo": "#f6a51f",
|
||||||
"Gosu": "#82937f",
|
"Gosu": "#82937f",
|
||||||
"Grammatical Framework": "#79aa7a",
|
"Grammatical Framework": "#ff0000",
|
||||||
|
"GraphQL": "#e10098",
|
||||||
"Groovy": "#e69f56",
|
"Groovy": "#e69f56",
|
||||||
|
"HTML": "#e44b23",
|
||||||
"Hack": "#878787",
|
"Hack": "#878787",
|
||||||
|
"Haml": "#ece2a9",
|
||||||
|
"Handlebars": "#f7931e",
|
||||||
"Harbour": "#0e60e3",
|
"Harbour": "#0e60e3",
|
||||||
"Haskell": "#5e5086",
|
"Haskell": "#29b544",
|
||||||
"Haxe": "#df7900",
|
"Haxe": "#f7941e",
|
||||||
"HiveQL": "#dce200",
|
"HiveQL": "#dce200",
|
||||||
"HTML": "#e34c26",
|
"HolyC": "#ffefaf",
|
||||||
"Hy": "#7790B2",
|
"Hy": "#7891b1",
|
||||||
"IDL": "#a3522f",
|
"IDL": "#e3592c",
|
||||||
|
"IGOR Pro": "#0000cc",
|
||||||
"Idris": "#b30000",
|
"Idris": "#b30000",
|
||||||
|
"ImageJ Macro": "#99AAFF",
|
||||||
"Io": "#a9188d",
|
"Io": "#a9188d",
|
||||||
"Ioke": "#078193",
|
"Ioke": "#078193",
|
||||||
"Isabelle": "#FEFE00",
|
"Isabelle": "#fdcd00",
|
||||||
"J": "#9EEDFF",
|
"J": "#9EEDFF",
|
||||||
|
"JFlex": "#DBCA00",
|
||||||
|
"JSONiq": "#40d47e",
|
||||||
"Java": "#b07219",
|
"Java": "#b07219",
|
||||||
"JavaScript": "#f1e05a",
|
"JavaScript": "#f1e05a",
|
||||||
"Jolie": "#843179",
|
"Jolie": "#843179",
|
||||||
"JSONiq": "#40d47e",
|
|
||||||
"Jsonnet": "#0064bd",
|
"Jsonnet": "#0064bd",
|
||||||
"Julia": "#a270ba",
|
"Julia": "#a270ba",
|
||||||
"Jupyter Notebook": "#DA5B0B",
|
"Jupyter Notebook": "#DA5B0B",
|
||||||
|
"KRL": "#f5c800",
|
||||||
|
"Kaitai Struct": "#773b37",
|
||||||
"Kotlin": "#F18E33",
|
"Kotlin": "#F18E33",
|
||||||
"KRL": "#28430A",
|
"LFE": "#004200",
|
||||||
"Lasso": "#999999",
|
|
||||||
"Lex": "#DBCA00",
|
|
||||||
"LFE": "#4C3023",
|
|
||||||
"LiveScript": "#499886",
|
|
||||||
"LLVM": "#185619",
|
"LLVM": "#185619",
|
||||||
"LOLCODE": "#cc9900",
|
"LOLCODE": "#cc9900",
|
||||||
"LookML": "#652B81",
|
|
||||||
"LSL": "#3d9970",
|
"LSL": "#3d9970",
|
||||||
"Lua": "#000080",
|
"Lark": "#0b130f",
|
||||||
"Makefile": "#427819",
|
"Lasso": "#2584c3",
|
||||||
"Mask": "#f97732",
|
"Latte": "#A8FF97",
|
||||||
|
"Less": "#1d365d",
|
||||||
|
"Lex": "#DBCA00",
|
||||||
|
"Liquid": "#67b8de",
|
||||||
|
"LiveScript": "#499886",
|
||||||
|
"LookML": "#652B81",
|
||||||
|
"Lua": "#fa1fa1",
|
||||||
"MATLAB": "#e16737",
|
"MATLAB": "#e16737",
|
||||||
"Max": "#c4a79c",
|
|
||||||
"MAXScript": "#00a6a6",
|
"MAXScript": "#00a6a6",
|
||||||
"mcfunction": "#E22837",
|
"MLIR": "#5EC8DB",
|
||||||
"Mercury": "#ff2b2b",
|
"MQL4": "#62A8D6",
|
||||||
|
"MQL5": "#4A76B8",
|
||||||
|
"MTML": "#0095d9",
|
||||||
|
"Macaulay2": "#d8ffff",
|
||||||
|
"Makefile": "#427819",
|
||||||
|
"Markdown": "#083fa1",
|
||||||
|
"Marko": "#42bff2",
|
||||||
|
"Mask": "#f97732",
|
||||||
|
"Matlab": "#bb92ac",
|
||||||
|
"Max": "#ce279c",
|
||||||
|
"Mercury": "#abcdef",
|
||||||
"Meson": "#007800",
|
"Meson": "#007800",
|
||||||
"Metal": "#8f14e9",
|
"Metal": "#8f14e9",
|
||||||
"Mirah": "#c7a938",
|
"Mirah": "#c7a938",
|
||||||
"Modula-3": "#223388",
|
"Modula-3": "#223388",
|
||||||
"MQL4": "#62A8D6",
|
"Mustache": "#724b3b",
|
||||||
"MQL5": "#4A76B8",
|
|
||||||
"MTML": "#b7e1f4",
|
|
||||||
"NCL": "#28431f",
|
"NCL": "#28431f",
|
||||||
|
"NWScript": "#111522",
|
||||||
"Nearley": "#990000",
|
"Nearley": "#990000",
|
||||||
"Nemerle": "#3d3c6e",
|
"Nemerle": "#0d3c6e",
|
||||||
"nesC": "#94B0C7",
|
|
||||||
"NetLinx": "#0aa0ff",
|
"NetLinx": "#0aa0ff",
|
||||||
"NetLinx+ERB": "#747faa",
|
"NetLinx+ERB": "#747faa",
|
||||||
"NetLogo": "#ff6375",
|
"NetLogo": "#ff2b2b",
|
||||||
"NewLisp": "#87AED7",
|
"NewLisp": "#eedd66",
|
||||||
"Nextflow": "#3ac486",
|
"Nextflow": "#3ac486",
|
||||||
"Nim": "#37775b",
|
"Nim": "#ffc200",
|
||||||
"Nit": "#009917",
|
"Nimrod": "#37775b",
|
||||||
"Nix": "#7e7eff",
|
"Nit": "#0d8921",
|
||||||
|
"Nix": "#7070ff",
|
||||||
"Nu": "#c9df40",
|
"Nu": "#c9df40",
|
||||||
"Objective-C": "#438eff",
|
"NumPy": "#9C8AF9",
|
||||||
"Objective-C++": "#6866fb",
|
"Nunjucks": "#3d8137",
|
||||||
"Objective-J": "#ff0c5a",
|
|
||||||
"OCaml": "#3be133",
|
"OCaml": "#3be133",
|
||||||
|
"ObjectScript": "#424893",
|
||||||
|
"Objective-C": "#438eff",
|
||||||
|
"Objective-C++": "#4886FC",
|
||||||
|
"Objective-J": "#ff0c5a",
|
||||||
|
"Odin": "#60AFFE",
|
||||||
"Omgrofl": "#cabbff",
|
"Omgrofl": "#cabbff",
|
||||||
"ooc": "#b0b77e",
|
|
||||||
"Opal": "#f7ede0",
|
"Opal": "#f7ede0",
|
||||||
"Oxygene": "#cdd0e3",
|
"OpenQASM": "#AA70FF",
|
||||||
"Oz": "#fab738",
|
"Org": "#77aa99",
|
||||||
|
"Oxygene": "#5a63a3",
|
||||||
|
"Oz": "#fcaf3e",
|
||||||
"P4": "#7055b5",
|
"P4": "#7055b5",
|
||||||
|
"PAWN": "#dbb284",
|
||||||
|
"PHP": "#4F5D95",
|
||||||
|
"PLSQL": "#dad8d8",
|
||||||
"Pan": "#cc0000",
|
"Pan": "#cc0000",
|
||||||
"Papyrus": "#6600cc",
|
"Papyrus": "#6600cc",
|
||||||
"Parrot": "#f3ca0a",
|
"Parrot": "#f3ca0a",
|
||||||
"Pascal": "#E3F171",
|
"Pascal": "#b0ce4e",
|
||||||
"Pawn": "#dbb284",
|
"Pawn": "#dbb284",
|
||||||
"Pep8": "#C76F5B",
|
"Pep8": "#C76F5B",
|
||||||
"Perl": "#0298c3",
|
"Perl": "#0298c3",
|
||||||
"Perl 6": "#0000fb",
|
"Perl6": "#0298c3",
|
||||||
"PHP": "#4F5D95",
|
|
||||||
"PigLatin": "#fcd7de",
|
"PigLatin": "#fcd7de",
|
||||||
"Pike": "#005390",
|
"Pike": "#066ab2",
|
||||||
"PLSQL": "#dad8d8",
|
|
||||||
"PogoScript": "#d80074",
|
"PogoScript": "#d80074",
|
||||||
"PostScript": "#da291c",
|
"PostScript": "#da291c",
|
||||||
"PowerBuilder": "#8f0f8d",
|
"PowerBuilder": "#8f0f8d",
|
||||||
"PowerShell": "#012456",
|
"PowerShell": "#012456",
|
||||||
"Processing": "#0096D8",
|
"Prisma": "#0c344b",
|
||||||
|
"Processing": "#2779ab",
|
||||||
"Prolog": "#74283c",
|
"Prolog": "#74283c",
|
||||||
"Propeller Spin": "#7fa2a7",
|
"Propeller Spin": "#2b446d",
|
||||||
"Puppet": "#302B6D",
|
"Pug": "#a86454",
|
||||||
|
"Puppet": "#cc5555",
|
||||||
|
"Pure Data": "#91de79",
|
||||||
"PureBasic": "#5a6986",
|
"PureBasic": "#5a6986",
|
||||||
"PureScript": "#1D222D",
|
"PureScript": "#bcdc53",
|
||||||
"Python": "#3572A5",
|
"Python": "#3581ba",
|
||||||
"q": "#0040cd",
|
"Q#": "#fed659",
|
||||||
"QML": "#44a51c",
|
"QML": "#44a51c",
|
||||||
|
"Qt Script": "#00b841",
|
||||||
"Quake": "#882233",
|
"Quake": "#882233",
|
||||||
"R": "#198CE7",
|
"R": "#198ce7",
|
||||||
"Racket": "#3c5caa",
|
|
||||||
"Ragel": "#9d5200",
|
|
||||||
"RAML": "#77d9fb",
|
"RAML": "#77d9fb",
|
||||||
|
"RUNOFF": "#665a4e",
|
||||||
|
"Racket": "#ae17ff",
|
||||||
|
"Ragel": "#9d5200",
|
||||||
|
"Ragel in Ruby Host": "#ff9c2e",
|
||||||
|
"Raku": "#0000fb",
|
||||||
"Rascal": "#fffaa0",
|
"Rascal": "#fffaa0",
|
||||||
|
"ReScript": "#ed5051",
|
||||||
|
"Reason": "#ff5847",
|
||||||
"Rebol": "#358a5b",
|
"Rebol": "#358a5b",
|
||||||
"Red": "#f50000",
|
"Record Jar": "#0673ba",
|
||||||
|
"Red": "#ee0000",
|
||||||
"Ren'Py": "#ff7f7f",
|
"Ren'Py": "#ff7f7f",
|
||||||
"Ring": "#2D54CB",
|
"Ring": "#2D54CB",
|
||||||
|
"Riot": "#A71E49",
|
||||||
"Roff": "#ecdebe",
|
"Roff": "#ecdebe",
|
||||||
"Rouge": "#cc0088",
|
"Rouge": "#cc0088",
|
||||||
"Ruby": "#701516",
|
"Ruby": "#701516",
|
||||||
"RUNOFF": "#665a4e",
|
|
||||||
"Rust": "#dea584",
|
"Rust": "#dea584",
|
||||||
|
"SAS": "#1E90FF",
|
||||||
|
"SCSS": "#c6538c",
|
||||||
|
"SQF": "#FFCB1F",
|
||||||
|
"SRecode Template": "#348a34",
|
||||||
|
"SVG": "#ff9900",
|
||||||
"SaltStack": "#646464",
|
"SaltStack": "#646464",
|
||||||
"SAS": "#B34936",
|
"Sass": "#a53b70",
|
||||||
"Scala": "#c22d40",
|
"Scala": "#7dd3b0",
|
||||||
|
"Scaml": "#bd181a",
|
||||||
"Scheme": "#1e4aec",
|
"Scheme": "#1e4aec",
|
||||||
"sed": "#64b970",
|
|
||||||
"Self": "#0579aa",
|
"Self": "#0579aa",
|
||||||
"Shell": "#89e051",
|
"Shell": "#5861ce",
|
||||||
"Shen": "#120F14",
|
"Shen": "#120F14",
|
||||||
"Slash": "#007eff",
|
"Slash": "#007eff",
|
||||||
"Slice": "#003fa2",
|
"Slice": "#003fa2",
|
||||||
|
"Slim": "#ff8877",
|
||||||
|
"SmPL": "#c94949",
|
||||||
"Smalltalk": "#596706",
|
"Smalltalk": "#596706",
|
||||||
"Solidity": "#AA6746",
|
"Solidity": "#AA6746",
|
||||||
"SourcePawn": "#5c7611",
|
"SourcePawn": "#f69e1d",
|
||||||
"SQF": "#3F3F3F",
|
|
||||||
"Squirrel": "#800000",
|
"Squirrel": "#800000",
|
||||||
"SRecode Template": "#348a34",
|
|
||||||
"Stan": "#b2011d",
|
"Stan": "#b2011d",
|
||||||
"Standard ML": "#dc566d",
|
"Standard ML": "#dc566d",
|
||||||
|
"Starlark": "#76d275",
|
||||||
|
"Stylus": "#ff6347",
|
||||||
"SuperCollider": "#46390b",
|
"SuperCollider": "#46390b",
|
||||||
"Svelte": "#ff3e00",
|
"Svelte": "#ff3e00",
|
||||||
"Swift": "#ffac45",
|
"Swift": "#ffac45",
|
||||||
"SystemVerilog": "#DAE1C2",
|
"SystemVerilog": "#343761",
|
||||||
"Tcl": "#e4cc98",
|
|
||||||
"Terra": "#00004c",
|
|
||||||
"TeX": "#3D6117",
|
|
||||||
"TI Program": "#A0AA87",
|
"TI Program": "#A0AA87",
|
||||||
"Turing": "#cf142b",
|
"Tcl": "#e4cc98",
|
||||||
"TypeScript": "#2b7489",
|
"TeX": "#3D6117",
|
||||||
|
"Terra": "#00004c",
|
||||||
|
"Turing": "#45f715",
|
||||||
|
"Twig": "#c1d026",
|
||||||
|
"TypeScript": "#31859c",
|
||||||
|
"Unified Parallel C": "#755223",
|
||||||
|
"Uno": "#9933cc",
|
||||||
"UnrealScript": "#a54c4d",
|
"UnrealScript": "#a54c4d",
|
||||||
"Vala": "#fbe5cd",
|
"V": "#4f87c4",
|
||||||
"VCL": "#148AA8",
|
"VBA": "#867db1",
|
||||||
"Verilog": "#b2b7f8",
|
"VBScript": "#15dcdc",
|
||||||
"VHDL": "#adb2cb",
|
"VCL": "#0298c3",
|
||||||
|
"VHDL": "#543978",
|
||||||
|
"Vala": "#ee7d06",
|
||||||
|
"Verilog": "#848bf3",
|
||||||
"Vim script": "#199f4b",
|
"Vim script": "#199f4b",
|
||||||
|
"VimL": "#199c4b",
|
||||||
"Visual Basic": "#945db7",
|
"Visual Basic": "#945db7",
|
||||||
"Volt": "#1F1F1F",
|
"Visual Basic .NET": "#945db7",
|
||||||
|
"Volt": "#0098db",
|
||||||
"Vue": "#2c3e50",
|
"Vue": "#2c3e50",
|
||||||
"wdl": "#42f1f4",
|
"Web Ontology Language": "#3994bc",
|
||||||
"WebAssembly": "#04133b",
|
"WebAssembly": "#04133b",
|
||||||
"wisp": "#7582D1",
|
"Wollok": "#a23738",
|
||||||
"X10": "#4B6BEF",
|
"X10": "#4B6BEF",
|
||||||
"xBase": "#403a40",
|
|
||||||
"XC": "#99DA07",
|
"XC": "#99DA07",
|
||||||
"XQuery": "#5232e7",
|
"XQuery": "#2700e2",
|
||||||
"XSLT": "#EB8CEB",
|
"XSLT": "#EB8CEB",
|
||||||
"Yacc": "#4B6C4B",
|
"YAML": "#cb171e",
|
||||||
"YARA": "#220000",
|
"YARA": "#220000",
|
||||||
"YASnippet": "#32AB90",
|
"YASnippet": "#32AB90",
|
||||||
|
"Yacc": "#4B6C4B",
|
||||||
"ZAP": "#0d665e",
|
"ZAP": "#0d665e",
|
||||||
|
"ZIL": "#dc75e5",
|
||||||
|
"ZenScript": "#00BCD1",
|
||||||
"Zephir": "#118f9e",
|
"Zephir": "#118f9e",
|
||||||
"Zig": "#ec915c",
|
"Zig": "#ec915c",
|
||||||
"ZIL": "#dc75e5"
|
"cpp": "#f34b7d",
|
||||||
|
"eC": "#913960",
|
||||||
|
"edn": "#db5855",
|
||||||
|
"mIRC Script": "#3d57c3",
|
||||||
|
"mcfunction": "#E22837",
|
||||||
|
"nesC": "#ffce3b",
|
||||||
|
"ooc": "#b0b77e",
|
||||||
|
"q": "#0040cd",
|
||||||
|
"sed": "#64b970",
|
||||||
|
"wdl": "#42f1f4",
|
||||||
|
"wisp": "#7582D1",
|
||||||
|
"xBase": "#3a4040",
|
||||||
|
"Other": "#1f9aef"
|
||||||
},
|
},
|
||||||
"editors": {
|
"editors": {
|
||||||
|
"Adobe XD": "#fd27bc",
|
||||||
"Android Studio": "#99cd00",
|
"Android Studio": "#99cd00",
|
||||||
"AppCode": "#04dbde",
|
"AppCode": "#04dbde",
|
||||||
"Aptana": "#ec8623",
|
"Aptana": "#ec8623",
|
||||||
"Atom": "#49b77e",
|
"Atom": "#49b77e",
|
||||||
"Azure Data Studio": "#0271c6",
|
"Azure Data Studio": "#0271c6",
|
||||||
"Blender": "#fb8007",
|
"Blender": "#fb8007",
|
||||||
|
"BlueJ": "#5d89af",
|
||||||
"Brackets": "#067dc3",
|
"Brackets": "#067dc3",
|
||||||
"Chrome": "#fdd308",
|
"Chrome": "#fdd308",
|
||||||
"CLion": "#14c9a5",
|
"CLion": "#14c9a5",
|
||||||
"Cloud9": "#25a6d9",
|
"Cloud9": "#25a6d9",
|
||||||
"Coda": "#3e8e1c",
|
"Coda": "#3e8e1c",
|
||||||
|
"Code: :Blocks": "#d0ce71",
|
||||||
|
"Code::Blocks": "#d0ce71",
|
||||||
|
"CodeLite": "#1892e5",
|
||||||
"CodeTasty": "#7368a8",
|
"CodeTasty": "#7368a8",
|
||||||
"DataGrip": "#907cf2",
|
"DataGrip": "#907cf2",
|
||||||
"DBeaver": "#897363",
|
"DBeaver": "#897363",
|
||||||
"Eclipse": "#443582",
|
"Eclipse": "#443582",
|
||||||
"Emacs": "#8c76c3",
|
"Emacs": "#8c76c3",
|
||||||
|
"Embarcadero Delphi": "#d9242a",
|
||||||
|
"EmEditor": "#ed3103",
|
||||||
"Eric": "#423f13",
|
"Eric": "#423f13",
|
||||||
"Excel": "#0f753c",
|
"Excel": "#0f753c",
|
||||||
|
"Figma": "#c7b9ff",
|
||||||
|
"Firefox": "#d96527",
|
||||||
"Flash Builder": "#aca3a4",
|
"Flash Builder": "#aca3a4",
|
||||||
|
"Geany": "#fbec75",
|
||||||
"Gedit": "#872114",
|
"Gedit": "#872114",
|
||||||
"GoLand": "#bd4ffc",
|
"GoLand": "#bd4ffc",
|
||||||
"HBuilder X": "#1ba334",
|
"HBuilder X": "#1ba334",
|
||||||
"IntelliJ IDEA": "#237ce2",
|
"IntelliJ IDEA": "#2876e1",
|
||||||
"IntelliJ": "#237ce2",
|
"IntelliJ": "#2876e1",
|
||||||
"Kakoune": "#dd5f4a",
|
"Kakoune": "#dd5f4a",
|
||||||
"Kate": "#3f4040",
|
"Kate": "#3f4040",
|
||||||
|
"KDevelop": "#22a273",
|
||||||
"Komodo": "#fcb414",
|
"Komodo": "#fcb414",
|
||||||
|
"Light Table": "#007ac1",
|
||||||
|
"MacRabbit Espresso": "#e6593f",
|
||||||
"Micro": "#2c3494",
|
"Micro": "#2c3494",
|
||||||
"MonoDevelop": "#6185b3",
|
"MonoDevelop": "#6185b3",
|
||||||
|
"MySQL Workbench": "#245279",
|
||||||
|
"Neovim": "#068304",
|
||||||
"NetBeans": "#f1f6e2",
|
"NetBeans": "#f1f6e2",
|
||||||
"Notepad++": "#9ecf54",
|
"Notepad++": "#9ecf54",
|
||||||
"Nova": "#ff054a",
|
"Nova": "#ff054a",
|
||||||
"Onivim": "#ee848e",
|
"Onivim": "#ee848e",
|
||||||
|
"Photoshop": "#0a0054",
|
||||||
"PhpStorm": "#d93ac1",
|
"PhpStorm": "#d93ac1",
|
||||||
"PowerPoint": "#c6421f",
|
"PowerPoint": "#c6421f",
|
||||||
"Processing": "#6a7152",
|
"Processing": "#6a7152",
|
||||||
"PyCharm": "#d2ee5c",
|
"PyCharm": "#d2ee5c",
|
||||||
"Pymakr": "#323d4f",
|
"Pymakr": "#323d4f",
|
||||||
|
"QtCreator": "#7fc342",
|
||||||
"Rider": "#f7a415",
|
"Rider": "#f7a415",
|
||||||
|
"RStudio": "#2369c7",
|
||||||
"RubyMine": "#ff6336",
|
"RubyMine": "#ff6336",
|
||||||
"Sketch": "#fdad00",
|
"Sketch": "#fdad00",
|
||||||
"SlickEdit": "#57ca57",
|
"SlickEdit": "#57ca57",
|
||||||
|
"Spyder": "#ee181e",
|
||||||
"SQL Server Management Studio": "#ffb901",
|
"SQL Server Management Studio": "#ffb901",
|
||||||
"Sublime Text": "#ff9800",
|
"Sublime Text": "#ff9800",
|
||||||
"Terminal": "#133f1c",
|
"Terminal": "#133f1c",
|
||||||
@ -297,29 +402,17 @@
|
|||||||
"Visual Studio": "#9460cd",
|
"Visual Studio": "#9460cd",
|
||||||
"VS Code": "#027acd",
|
"VS Code": "#027acd",
|
||||||
"VSCode": "#027acd",
|
"VSCode": "#027acd",
|
||||||
|
"WebMatrix": "#aeaeae",
|
||||||
"WebStorm": "#00c6d7",
|
"WebStorm": "#00c6d7",
|
||||||
|
"Wing": "#b3b3b3",
|
||||||
"Word": "#0f4091",
|
"Word": "#0f4091",
|
||||||
"WPS Office": "#fc6143",
|
"WPS Office": "#fc6143",
|
||||||
"Xamarin": "#3598db",
|
"Xamarin": "#3598db",
|
||||||
"Xcode": "#3fa7e4",
|
"Xcode": "#3fa7e4"
|
||||||
"Adobe XD": "#fd27bc",
|
|
||||||
"Code::Blocks": "#d0ce71",
|
|
||||||
"Embarcadero Delphi": "#d9242a",
|
|
||||||
"EmEditor": "#ed3103",
|
|
||||||
"Figma": "#c7b9ff",
|
|
||||||
"Firefox": "#d96527",
|
|
||||||
"Geany": "#fbec75",
|
|
||||||
"Light Table": "#007ac1",
|
|
||||||
"MacRabbit Espresso": "#e6593f",
|
|
||||||
"MySQL Workbench": "#245279",
|
|
||||||
"Photoshop": "#0a0054",
|
|
||||||
"QtCreator": "#7fc342",
|
|
||||||
"RStudio": "#2369c7",
|
|
||||||
"WebMatrix": "#aeaeae"
|
|
||||||
},
|
},
|
||||||
"operating_systems": {
|
"operating_systems": {
|
||||||
"Linux": "#f0b912",
|
"Linux": "#f0b912",
|
||||||
"Windows": "#00b7ee",
|
"Windows": "#00b7ee",
|
||||||
"Mac": "#4d66cb"
|
"Mac": "#4d66cb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,3 +22,8 @@ services:
|
|||||||
POSTGRES_USER: "wakapi"
|
POSTGRES_USER: "wakapi"
|
||||||
POSTGRES_PASSWORD: "choose-a-password"
|
POSTGRES_PASSWORD: "choose-a-password"
|
||||||
POSTGRES_DB: "wakapi"
|
POSTGRES_DB: "wakapi"
|
||||||
|
volumes:
|
||||||
|
- wakapi-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
wakapi-db-data: {}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
|
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
|
||||||
./wakapi
|
exec ./wakapi
|
||||||
else
|
else
|
||||||
echo "Waiting for database to come up"
|
echo "Waiting for database to come up"
|
||||||
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
exec ./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||||
fi
|
fi
|
||||||
|
26
etc/Caddyfile
Normal file
26
etc/Caddyfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
wakapi.yourdomain.tld {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=2592000; includeSubDomains"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/wakapi.dev.access.log
|
||||||
|
format single_field common_log
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy http://[::1]:3000
|
||||||
|
|
||||||
|
@api path_regexp "^/api.*"
|
||||||
|
@notapi not path_regexp "^/api.*"
|
||||||
|
|
||||||
|
push @notapi /assets/vendor/source-sans-3.css
|
||||||
|
push @notapi /assets/css/app.dist.css
|
||||||
|
push @notapi /assets/vendor/petite-vue.min.js
|
||||||
|
push @notapi /assets/vendor/chart.min.js
|
||||||
|
push @notapi /assets/vendor/iconify.basic.min.js
|
||||||
|
push @notapi /assets/js/icons.dist.js
|
||||||
|
push @notapi /assets/js/base.js
|
||||||
|
push @notapi /assets/images/logo.svg
|
||||||
|
}
|
53
etc/wakapi.service
Normal file
53
etc/wakapi.service
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Wakapi
|
||||||
|
StartLimitIntervalSec=400
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
# Optional, in case you're running MySQL / Postgres with Systemd, too
|
||||||
|
Requires=mysql.service
|
||||||
|
After=mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
# Assuming Wakapi executable is under /opt/wakapi and config file at /etc
|
||||||
|
# Feel free to change this
|
||||||
|
WorkingDirectory=/opt/wakapi
|
||||||
|
ExecStart=/opt/wakapi/wakapi -config /etc/wakapi.yml
|
||||||
|
|
||||||
|
# Environment variables, see README for more
|
||||||
|
Environment=WAKAPI_DB_HOST=localhost
|
||||||
|
Environment=WAKAPI_DB_USER=wakapi
|
||||||
|
Environment=WAKAPI_DB_NAME=wakapi
|
||||||
|
Environment=WAKAPI_DB_PASSWORD=secretpassword
|
||||||
|
Environment=WAKAPI_PASSWORD_SALT=somerandomstring
|
||||||
|
|
||||||
|
# TODO: Use Systemd's credentials management (https://systemd.io/CREDENTIALS/) introduced in v247 (%d syntax in v250) once more established
|
||||||
|
|
||||||
|
# sudo groupadd wakapi
|
||||||
|
# sudo useradd -g wakapi wakapi
|
||||||
|
User=wakapi
|
||||||
|
Group=wakapi
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=90
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateUsers=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
PrivateDevices=true
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
ProtectClock=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
98
go.mod
98
go.mod
@ -1,34 +1,82 @@
|
|||||||
module github.com/muety/wakapi
|
module github.com/muety/wakapi
|
||||||
|
|
||||||
go 1.16
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
codeberg.org/Codeberg/avatars v1.0.0
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
github.com/duke-git/lancet/v2 v2.1.6
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/emvi/logbuch v1.1.1
|
github.com/emvi/logbuch v1.2.0
|
||||||
github.com/getsentry/sentry-go v0.10.0
|
github.com/getsentry/sentry-go v0.13.0
|
||||||
github.com/go-co-op/gocron v0.3.3
|
github.com/glebarez/sqlite v1.4.7
|
||||||
github.com/go-openapi/spec v0.20.2 // indirect
|
github.com/go-co-op/gocron v1.17.0
|
||||||
github.com/gorilla/handlers v1.4.2
|
github.com/gorilla/handlers v1.5.1
|
||||||
github.com/gorilla/mux v1.7.3
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/schema v1.1.0
|
github.com/gorilla/schema v1.2.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
github.com/jinzhu/configor v1.2.0
|
github.com/hashicorp/golang-lru v0.5.4
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/jinzhu/configor v1.2.1
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
github.com/leandro-lugaresi/hub v1.1.1
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.1
|
github.com/lpar/gzipped/v2 v2.1.0
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||||
|
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/satori/go.uuid v1.2.0
|
github.com/satori/go.uuid v1.2.0
|
||||||
github.com/stretchr/testify v1.6.1
|
github.com/stretchr/testify v1.8.0
|
||||||
github.com/swaggo/swag v1.7.0
|
github.com/swaggo/http-swagger v1.3.3
|
||||||
go.uber.org/atomic v1.6.0
|
github.com/swaggo/swag v1.8.6
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
go.uber.org/atomic v1.10.0
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
|
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
||||||
golang.org/x/tools v0.1.0 // indirect
|
gorm.io/driver/mysql v1.3.6
|
||||||
gorm.io/driver/mysql v1.0.3
|
gorm.io/driver/postgres v1.3.10
|
||||||
gorm.io/driver/postgres v1.0.5
|
gorm.io/driver/sqlite v1.3.6
|
||||||
gorm.io/driver/sqlite v1.1.3
|
gorm.io/gorm v1.23.10
|
||||||
gorm.io/gorm v1.20.11
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.2.0 // indirect
|
||||||
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.18.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||||
|
github.com/go-openapi/spec v0.20.7 // indirect
|
||||||
|
github.com/go-openapi/swag v0.22.3 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgconn v1.13.0 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||||
|
github.com/jackc/pgtype v1.12.0 // indirect
|
||||||
|
github.com/jackc/pgx/v4 v4.17.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
github.com/stretchr/objx v0.4.0 // indirect
|
||||||
|
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
||||||
|
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
|
||||||
|
golang.org/x/net v0.0.0-20220927171203-f486391704dc // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
|
||||||
|
golang.org/x/text v0.3.7 // indirect
|
||||||
|
golang.org/x/tools v0.1.12 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.20.0 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.4.0 // indirect
|
||||||
|
modernc.org/sqlite v1.19.1 // indirect
|
||||||
)
|
)
|
||||||
|
462
go.sum
462
go.sum
@ -1,114 +1,78 @@
|
|||||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
||||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
|
||||||
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
|
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
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/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
|
||||||
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 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
github.com/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-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
github.com/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.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
|
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||||
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/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-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
|
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
|
||||||
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
|
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
|
||||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||||
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
|
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
|
||||||
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
|
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
|
||||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
|
||||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
|
||||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
|
||||||
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 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
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.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
|
||||||
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
|
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||||
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
|
||||||
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||||
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.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.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
|
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||||
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
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/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
|
||||||
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||||
|
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
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/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 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.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 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
@ -116,277 +80,228 @@ github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgO
|
|||||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
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-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 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.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||||
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||||
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||||
github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
|
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
|
||||||
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
|
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
|
|
||||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 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.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-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/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
|
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
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-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
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.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||||
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
|
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
|
||||||
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
|
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||||
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-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||||
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
|
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||||
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
|
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
|
||||||
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
|
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
|
||||||
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-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||||
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/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.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
|
github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8=
|
||||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
|
||||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
github.com/lpar/gzipped/v2 v2.1.0 h1:87/ug239roEqXLVOnXZg6NjDfFvMwmkGTKnFWJPUA9U=
|
||||||
|
github.com/lpar/gzipped/v2 v2.1.0/go.mod h1:G3UlFoFYzjCx6NV4zDmD1BIWMNBaJuKoUvxrEWJuZ3Y=
|
||||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
|
||||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
|
||||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
|
||||||
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/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/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/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/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.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
|
||||||
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 h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
|
||||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
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 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
|
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
|
||||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
|
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
github.com/shopspring/decimal v1.2.0/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.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
|
||||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
|
||||||
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.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.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
|
||||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
|
||||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
|
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
|
||||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
||||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
|
||||||
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
|
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
|
||||||
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/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/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
|
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||||
|
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
|
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
|
||||||
|
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
|
||||||
|
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
|
||||||
|
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
|
||||||
|
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/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-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-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-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-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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
|
||||||
|
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-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-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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-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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@ -396,35 +311,68 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
|
|||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/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/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/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
|
||||||
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
|
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||||
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
|
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
|
||||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||||
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
|
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||||
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||||
|
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||||
|
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
|
||||||
|
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||||
|
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||||
|
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||||
|
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
|
||||||
|
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
||||||
|
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||||
|
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||||
|
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||||
|
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
|
||||||
|
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
|
||||||
|
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
|
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||||
|
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||||
|
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
|
||||||
|
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
|
||||||
|
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
|
||||||
|
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||||
|
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||||
|
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||||
|
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||||
|
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
|
||||||
|
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||||
|
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||||
|
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||||
|
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||||
|
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
|
||||||
|
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
|
||||||
|
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||||
|
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
|
||||||
|
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||||
|
177
main.go
177
main.go
@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"github.com/muety/wakapi/static/docs"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -11,31 +13,37 @@ import (
|
|||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/lpar/gzipped/v2"
|
||||||
|
"github.com/swaggo/http-swagger"
|
||||||
|
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/migrations"
|
"github.com/muety/wakapi/migrations"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/routes/api"
|
|
||||||
"github.com/muety/wakapi/services/mail"
|
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/muety/wakapi/middlewares"
|
|
||||||
"github.com/muety/wakapi/routes"
|
"github.com/muety/wakapi/routes"
|
||||||
|
"github.com/muety/wakapi/routes/api"
|
||||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||||
|
"github.com/muety/wakapi/routes/relay"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
|
"github.com/muety/wakapi/services/mail"
|
||||||
|
fsutils "github.com/muety/wakapi/utils/fs"
|
||||||
|
|
||||||
_ "gorm.io/driver/mysql"
|
_ "gorm.io/driver/mysql"
|
||||||
_ "gorm.io/driver/postgres"
|
_ "gorm.io/driver/postgres"
|
||||||
_ "gorm.io/driver/sqlite"
|
_ "gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Embed version.txt
|
// Embed version.txt
|
||||||
|
//
|
||||||
//go:embed version.txt
|
//go:embed version.txt
|
||||||
var version string
|
var version string
|
||||||
|
|
||||||
// Embed static files
|
// Embed static files
|
||||||
|
//
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
@ -49,8 +57,12 @@ var (
|
|||||||
heartbeatRepository repositories.IHeartbeatRepository
|
heartbeatRepository repositories.IHeartbeatRepository
|
||||||
userRepository repositories.IUserRepository
|
userRepository repositories.IUserRepository
|
||||||
languageMappingRepository repositories.ILanguageMappingRepository
|
languageMappingRepository repositories.ILanguageMappingRepository
|
||||||
|
projectLabelRepository repositories.IProjectLabelRepository
|
||||||
summaryRepository repositories.ISummaryRepository
|
summaryRepository repositories.ISummaryRepository
|
||||||
|
leaderboardRepository *repositories.LeaderboardRepository
|
||||||
keyValueRepository repositories.IKeyValueRepository
|
keyValueRepository repositories.IKeyValueRepository
|
||||||
|
diagnosticsRepository repositories.IDiagnosticsRepository
|
||||||
|
metricsRepository *repositories.MetricsRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -58,10 +70,15 @@ var (
|
|||||||
heartbeatService services.IHeartbeatService
|
heartbeatService services.IHeartbeatService
|
||||||
userService services.IUserService
|
userService services.IUserService
|
||||||
languageMappingService services.ILanguageMappingService
|
languageMappingService services.ILanguageMappingService
|
||||||
|
projectLabelService services.IProjectLabelService
|
||||||
|
durationService services.IDurationService
|
||||||
summaryService services.ISummaryService
|
summaryService services.ISummaryService
|
||||||
|
leaderboardService services.ILeaderboardService
|
||||||
aggregationService services.IAggregationService
|
aggregationService services.IAggregationService
|
||||||
mailService services.IMailService
|
mailService services.IMailService
|
||||||
keyValueService services.IKeyValueService
|
keyValueService services.IKeyValueService
|
||||||
|
reportService services.IReportService
|
||||||
|
diagnosticsService services.IDiagnosticsService
|
||||||
miscService services.IMiscService
|
miscService services.IMiscService
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -86,10 +103,12 @@ var (
|
|||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
|
|
||||||
// @BasePath /api
|
|
||||||
func main() {
|
func main() {
|
||||||
config = conf.Load(version)
|
config = conf.Load(version)
|
||||||
|
|
||||||
|
// Configure Swagger docs
|
||||||
|
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
|
||||||
|
|
||||||
// Set log level
|
// Set log level
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
logbuch.SetLevel(logbuch.LevelDebug)
|
logbuch.SetLevel(logbuch.LevelDebug)
|
||||||
@ -110,14 +129,18 @@ func main() {
|
|||||||
// Connect to database
|
// Connect to database
|
||||||
var err error
|
var err error
|
||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||||
if config.Db.Dialect == "sqlite3" {
|
if err != nil {
|
||||||
db.Raw("PRAGMA foreign_keys = ON;")
|
logbuch.Error(err.Error())
|
||||||
|
logbuch.Fatal("could not open database")
|
||||||
|
}
|
||||||
|
if config.Db.IsSQLite() {
|
||||||
|
db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
db = db.Debug()
|
db = db.Debug()
|
||||||
}
|
}
|
||||||
sqlDb, _ := db.DB()
|
sqlDb, err := db.DB()
|
||||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -127,30 +150,43 @@ func main() {
|
|||||||
defer sqlDb.Close()
|
defer sqlDb.Close()
|
||||||
|
|
||||||
// Migrate database schema
|
// Migrate database schema
|
||||||
migrations.Run(db, config)
|
if !config.SkipMigrations {
|
||||||
|
migrations.Run(db, config)
|
||||||
|
}
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
aliasRepository = repositories.NewAliasRepository(db)
|
aliasRepository = repositories.NewAliasRepository(db)
|
||||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||||
userRepository = repositories.NewUserRepository(db)
|
userRepository = repositories.NewUserRepository(db)
|
||||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||||
|
projectLabelRepository = repositories.NewProjectLabelRepository(db)
|
||||||
summaryRepository = repositories.NewSummaryRepository(db)
|
summaryRepository = repositories.NewSummaryRepository(db)
|
||||||
|
leaderboardRepository = repositories.NewLeaderboardRepository(db)
|
||||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||||
|
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
|
||||||
|
metricsRepository = repositories.NewMetricsRepository(db)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
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()
|
mailService = mail.NewMailService()
|
||||||
|
aliasService = services.NewAliasService(aliasRepository)
|
||||||
|
userService = services.NewUserService(mailService, userRepository)
|
||||||
|
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||||
|
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
|
||||||
|
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||||
|
durationService = services.NewDurationService(heartbeatService)
|
||||||
|
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
|
||||||
|
leaderboardService = services.NewLeaderboardService(leaderboardRepository, summaryService, userService)
|
||||||
|
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||||
|
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||||
|
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
|
||||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||||
|
|
||||||
// Schedule background tasks
|
// Schedule background tasks
|
||||||
go aggregationService.Schedule()
|
go aggregationService.Schedule()
|
||||||
|
go leaderboardService.ScheduleDefault()
|
||||||
go miscService.ScheduleCountTotalTime()
|
go miscService.ScheduleCountTotalTime()
|
||||||
|
go reportService.Schedule()
|
||||||
|
|
||||||
routes.Init()
|
routes.Init()
|
||||||
|
|
||||||
@ -158,67 +194,102 @@ func main() {
|
|||||||
healthApiHandler := api.NewHealthApiHandler(db)
|
healthApiHandler := api.NewHealthApiHandler(db)
|
||||||
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
||||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository)
|
||||||
|
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
||||||
|
avatarHandler := api.NewAvatarHandler()
|
||||||
|
badgeHandler := api.NewBadgeHandler(userService, summaryService)
|
||||||
|
|
||||||
// Compat Handlers
|
// Compat Handlers
|
||||||
|
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
|
||||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
|
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
|
||||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
|
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
|
||||||
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
|
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
|
||||||
|
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
|
||||||
|
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
|
||||||
|
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
|
||||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||||
|
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
|
||||||
|
// Other Handlers
|
||||||
|
relayHandler := relay.NewRelayHandler()
|
||||||
|
|
||||||
// Setup Routers
|
// Setup Routers
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
rootRouter := router.PathPrefix("/").Subrouter()
|
rootRouter := router.PathPrefix("/").Subrouter()
|
||||||
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
||||||
|
|
||||||
|
// https://github.com/gorilla/mux/issues/416
|
||||||
|
router.NotFoundHandler = router.NewRoute().BuildOnly().HandlerFunc(http.NotFound).GetHandler()
|
||||||
|
router.NotFoundHandler = middlewares.NewLoggingMiddleware(logbuch.Info, []string{
|
||||||
|
"/assets",
|
||||||
|
"/favicon",
|
||||||
|
"/service-worker.js",
|
||||||
|
})(router.NotFoundHandler)
|
||||||
|
|
||||||
// Globally used middlewares
|
// Globally used middlewares
|
||||||
router.Use(middlewares.NewPrincipalMiddleware())
|
router.Use(middlewares.NewPrincipalMiddleware())
|
||||||
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
|
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets", "/api/health"}))
|
||||||
router.Use(handlers.RecoveryHandler())
|
router.Use(handlers.RecoveryHandler())
|
||||||
if config.Sentry.Dsn != "" {
|
if config.Sentry.Dsn != "" {
|
||||||
router.Use(middlewares.NewSentryMiddleware())
|
router.Use(middlewares.NewSentryMiddleware())
|
||||||
}
|
}
|
||||||
|
rootRouter.Use(middlewares.NewSecurityMiddleware())
|
||||||
|
|
||||||
// Route registrations
|
// Route registrations
|
||||||
homeHandler.RegisterRoutes(rootRouter)
|
homeHandler.RegisterRoutes(rootRouter)
|
||||||
loginHandler.RegisterRoutes(rootRouter)
|
loginHandler.RegisterRoutes(rootRouter)
|
||||||
imprintHandler.RegisterRoutes(rootRouter)
|
imprintHandler.RegisterRoutes(rootRouter)
|
||||||
summaryHandler.RegisterRoutes(rootRouter)
|
summaryHandler.RegisterRoutes(rootRouter)
|
||||||
|
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||||
settingsHandler.RegisterRoutes(rootRouter)
|
settingsHandler.RegisterRoutes(rootRouter)
|
||||||
|
relayHandler.RegisterRoutes(rootRouter)
|
||||||
|
|
||||||
// API route registrations
|
// API route registrations
|
||||||
summaryApiHandler.RegisterRoutes(apiRouter)
|
summaryApiHandler.RegisterRoutes(apiRouter)
|
||||||
healthApiHandler.RegisterRoutes(apiRouter)
|
healthApiHandler.RegisterRoutes(apiRouter)
|
||||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||||
metricsHandler.RegisterRoutes(apiRouter)
|
metricsHandler.RegisterRoutes(apiRouter)
|
||||||
|
diagnosticsHandler.RegisterRoutes(apiRouter)
|
||||||
|
avatarHandler.RegisterRoutes(apiRouter)
|
||||||
|
badgeHandler.RegisterRoutes(apiRouter)
|
||||||
|
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
||||||
|
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
|
||||||
|
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
|
||||||
|
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
|
||||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||||
|
|
||||||
// Static Routes
|
// Static Routes
|
||||||
// https://github.com/golang/go/issues/43431
|
// https://github.com/golang/go/issues/43431
|
||||||
static, _ := fs.Sub(staticFiles, "static")
|
embeddedStatic, _ := fs.Sub(staticFiles, "static")
|
||||||
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
|
static := conf.ChooseFS("static", embeddedStatic)
|
||||||
router.PathPrefix("/assets").Handler(fileServer)
|
|
||||||
router.PathPrefix("/swagger-ui").Handler(fileServer)
|
assetsFileServer := gzipped.FileServer(fsutils.NewExistsHttpFS(
|
||||||
router.PathPrefix("/docs").Handler(
|
fsutils.NewExistsFS(static).WithCache(!config.IsDev()),
|
||||||
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
|
))
|
||||||
)
|
staticFileServer := http.FileServer(http.FS(
|
||||||
|
fsutils.NeuteredFileSystem{FS: static},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.PathPrefix("/contribute.json").Handler(staticFileServer)
|
||||||
|
router.PathPrefix("/assets").Handler(assetsFileServer)
|
||||||
|
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
|
||||||
|
|
||||||
// Listen HTTP
|
// Listen HTTP
|
||||||
listen(router)
|
listen(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listen(handler http.Handler) {
|
func listen(handler http.Handler) {
|
||||||
var s4, s6 *http.Server
|
var s4, s6, sSocket *http.Server
|
||||||
|
|
||||||
// IPv4
|
// IPv4
|
||||||
if config.Server.ListenIpV4 != "" {
|
if config.Server.ListenIpV4 != "" {
|
||||||
@ -226,8 +297,8 @@ func listen(handler http.Handler) {
|
|||||||
s4 = &http.Server{
|
s4 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
Addr: bindString4,
|
Addr: bindString4,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,8 +308,24 @@ func listen(handler http.Handler) {
|
|||||||
s6 = &http.Server{
|
s6 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
Addr: bindString6,
|
Addr: bindString6,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNIX domain socket
|
||||||
|
if config.Server.ListenSocket != "" {
|
||||||
|
// Remove if exists
|
||||||
|
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
|
||||||
|
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket)
|
||||||
|
if err := os.Remove(config.Server.ListenSocket); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sSocket = &http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,6 +346,18 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if sSocket != nil {
|
||||||
|
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
||||||
|
go func() {
|
||||||
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if s4 != nil {
|
if s4 != nil {
|
||||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
||||||
@ -276,6 +375,18 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if sSocket != nil {
|
||||||
|
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
||||||
|
go func() {
|
||||||
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
if err := sSocket.Serve(unixListener); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-make(chan interface{}, 1)
|
<-make(chan interface{}, 1)
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
package middlewares
|
package middlewares
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
)
|
||||||
"strings"
|
|
||||||
|
const (
|
||||||
|
// queryApiKey is the query parameter name for api key.
|
||||||
|
queryApiKey = "api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errEmptyKey = fmt.Errorf("the api_key is empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthenticateMiddleware struct {
|
type AuthenticateMiddleware struct {
|
||||||
@ -45,7 +56,10 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
|||||||
user, err := m.tryGetUserByCookie(r)
|
user, err := m.tryGetUserByCookie(r)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user, err = m.tryGetUserByApiKey(r)
|
user, err = m.tryGetUserByApiKeyHeader(r)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
user, err = m.tryGetUserByApiKeyQuery(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
@ -58,7 +72,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
|||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.Write([]byte(conf.ErrUnauthorized))
|
w.Write([]byte(conf.ErrUnauthorized))
|
||||||
} else {
|
} else {
|
||||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
|
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey))
|
||||||
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -77,7 +91,7 @@ func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
|
func (m *AuthenticateMiddleware) tryGetUserByApiKeyHeader(r *http.Request) (*models.User, error) {
|
||||||
key, err := utils.ExtractBearerAuth(r)
|
key, err := utils.ExtractBearerAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -92,6 +106,20 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*models.User, error) {
|
||||||
|
key := r.URL.Query().Get(queryApiKey)
|
||||||
|
var user *models.User
|
||||||
|
userKey := strings.TrimSpace(key)
|
||||||
|
if userKey == "" {
|
||||||
|
return nil, errEmptyKey
|
||||||
|
}
|
||||||
|
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) {
|
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
username, err := utils.ExtractCookieAuth(r, m.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -3,14 +3,16 @@ package middlewares
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/muety/wakapi/mocks"
|
"github.com/muety/wakapi/mocks"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
|
||||||
testApiKey := "z5uig69cn9ut93n"
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||||
testUser := &models.User{ApiKey: testApiKey}
|
testUser := &models.User{ApiKey: testApiKey}
|
||||||
@ -26,13 +28,13 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
|||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, testUser, result)
|
assert.Equal(t, testUser, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
|
||||||
testApiKey := "z5uig69cn9ut93n"
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||||
|
|
||||||
@ -47,10 +49,55 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
|||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Nil(t, result)
|
assert.Nil(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
|
||||||
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
|
testUser := &models.User{ApiKey: testApiKey}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("api_key", testApiKey)
|
||||||
|
mockRequest := &http.Request{
|
||||||
|
URL: &url.URL{
|
||||||
|
RawQuery: params.Encode(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
|
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||||
|
|
||||||
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
|
result, err := sut.tryGetUserByApiKeyQuery(mockRequest)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, testUser, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Invalid(t *testing.T) {
|
||||||
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("token", testApiKey)
|
||||||
|
mockRequest := &http.Request{
|
||||||
|
URL: &url.URL{
|
||||||
|
RawQuery: params.Encode(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
|
|
||||||
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
|
result, actualErr := sut.tryGetUserByApiKeyQuery(mockRequest)
|
||||||
|
|
||||||
|
assert.Error(t, actualErr)
|
||||||
|
assert.Equal(t, errEmptyKey, actualErr)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: somehow test cookie auth function
|
// TODO: somehow test cookie auth function
|
||||||
|
@ -3,19 +3,30 @@ package relay
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Middleware to conditionally relay heartbeats to Wakatime */
|
const maxFailuresPerDay = 100
|
||||||
|
|
||||||
|
// WakatimeRelayMiddleware is a middleware to conditionally relay heartbeats to Wakatime (and other compatible services)
|
||||||
type WakatimeRelayMiddleware struct {
|
type WakatimeRelayMiddleware struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
hashCache *cache.Cache
|
||||||
|
failureCache *cache.Cache
|
||||||
|
eventBus *hub.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
||||||
@ -23,6 +34,9 @@ func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
|||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
|
hashCache: cache.New(10*time.Minute, 10*time.Minute),
|
||||||
|
failureCache: cache.New(24*time.Hour, 1*time.Hour),
|
||||||
|
eventBus: config.EventBus(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +49,10 @@ func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
|
|||||||
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
defer next(w, r)
|
defer next(w, r)
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
ownInstanceId := config.Get().InstanceId
|
||||||
|
originInstanceId := r.Header.Get("X-Origin-Instance")
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost || originInstanceId == ownInstanceId {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,10 +61,22 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := m.filterByCache(r)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Warn("%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
body, _ := ioutil.ReadAll(r.Body)
|
body, _ := ioutil.ReadAll(r.Body)
|
||||||
r.Body.Close()
|
r.Body.Close()
|
||||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
// prevent cycles
|
||||||
|
downstreamInstanceId := ownInstanceId
|
||||||
|
if originInstanceId != "" {
|
||||||
|
downstreamInstanceId = originInstanceId
|
||||||
|
}
|
||||||
|
|
||||||
headers := http.Header{
|
headers := http.Header{
|
||||||
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
|
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
|
||||||
"Content-Type": r.Header.Values("Content-Type"),
|
"Content-Type": r.Header.Values("Content-Type"),
|
||||||
@ -56,23 +85,27 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
"X-Origin": []string{
|
"X-Origin": []string{
|
||||||
fmt.Sprintf("wakapi v%s", config.Get().Version),
|
fmt.Sprintf("wakapi v%s", config.Get().Version),
|
||||||
},
|
},
|
||||||
|
"X-Origin-Instance": []string{downstreamInstanceId},
|
||||||
"Authorization": []string{
|
"Authorization": []string{
|
||||||
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
|
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url := user.WakaTimeURL(config.WakatimeApiUrl) + config.WakatimeApiHeartbeatsBulkUrl
|
||||||
|
|
||||||
go m.send(
|
go m.send(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
|
url,
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(body),
|
||||||
headers,
|
headers,
|
||||||
|
user,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header) {
|
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
|
||||||
request, err := http.NewRequest(method, url, body)
|
request, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Warn("error constructing relayed request – %v", err)
|
logbuch.Warn("error constructing relayed request - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,11 +117,74 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
|
|||||||
|
|
||||||
response, err := m.httpClient.Do(request)
|
response, err := m.httpClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Warn("error executing relayed request – %v", err)
|
logbuch.Warn("error executing relayed request - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
logbuch.Warn("failed to relay request, got status %d", response.StatusCode)
|
logbuch.Warn("failed to relay request for user %s, got status %d", forUser.ID, response.StatusCode)
|
||||||
|
|
||||||
|
// TODO: use leaky bucket instead of expiring cache?
|
||||||
|
if _, found := m.failureCache.Get(forUser.ID); !found {
|
||||||
|
m.failureCache.SetDefault(forUser.ID, 0)
|
||||||
|
}
|
||||||
|
if n, _ := m.failureCache.IncrementInt(forUser.ID, 1); n == maxFailuresPerDay {
|
||||||
|
m.eventBus.Publish(hub.Message{
|
||||||
|
Name: config.EventWakatimeFailure,
|
||||||
|
Fields: map[string]interface{}{config.FieldUser: forUser, config.FieldPayload: n},
|
||||||
|
})
|
||||||
|
} else if n%10 == 0 {
|
||||||
|
logbuch.Warn("%d / %d failed wakatime heartbeat relaying attempts for user %s within last 24 hours", n, maxFailuresPerDay, forUser.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterByCache takes an HTTP request, tries to parse the body contents as heartbeats, checks against a local cache for whether a heartbeat has already been relayed before according to its hash and in-place filters these from the request's raw json body.
|
||||||
|
// This method operates on the raw body data (interface{}), because serialization of models.Heartbeat is not necessarily identical to what the CLI has actually sent.
|
||||||
|
// Purpose of this mechanism is mainly to prevent cyclic relays / loops.
|
||||||
|
// Caution: this method does in-place changes to the request.
|
||||||
|
func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
|
||||||
|
heartbeats, err := routeutils.ParseHeartbeats(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := ioutil.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
var rawData interface{}
|
||||||
|
if err := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))).Decode(&rawData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newData := make([]interface{}, 0, len(heartbeats))
|
||||||
|
|
||||||
|
for i, hb := range heartbeats {
|
||||||
|
hb = hb.Hashed()
|
||||||
|
|
||||||
|
// we didn't see this particular heartbeat before
|
||||||
|
if _, found := m.hashCache.Get(hb.Hash); !found {
|
||||||
|
m.hashCache.SetDefault(hb.Hash, true)
|
||||||
|
newData = append(newData, rawData.([]interface{})[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newData) == 0 {
|
||||||
|
return errors.New("no new heartbeats to relay")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newData) != len(heartbeats) {
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
|
logbuch.Warn("only relaying %d of %d heartbeats for user %s", len(newData), len(heartbeats), user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
if err := json.NewEncoder(&buf).Encode(newData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Body = ioutil.NopCloser(&buf)
|
||||||
|
|
||||||
|
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' 'unsafe-eval'; 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)
|
||||||
|
}
|
@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SentryMiddleware is a wrapper around sentryhttp to include user information to traces
|
||||||
type SentryMiddleware struct {
|
type SentryMiddleware struct {
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
}
|
}
|
||||||
|
@ -31,13 +31,7 @@ func init() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
condition := "key = ?"
|
if hasRun(name, db) {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,13 +58,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&models.KeyStringValue{
|
setHasRun(name, db)
|
||||||
Key: name,
|
|
||||||
Value: "done",
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -26,13 +26,7 @@ func init() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
condition := "key = ?"
|
if hasRun(name, db) {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,13 +37,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&models.KeyStringValue{
|
setHasRun(name, db)
|
||||||
Key: name,
|
|
||||||
Value: "done",
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -12,13 +10,7 @@ func init() {
|
|||||||
f := migrationFunc{
|
f := migrationFunc{
|
||||||
name: name,
|
name: name,
|
||||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
condition := "key = ?"
|
if hasRun(name, db) {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,13 +18,7 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&models.KeyStringValue{
|
setHasRun(name, db)
|
||||||
Key: name,
|
|
||||||
Value: "done",
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -12,13 +10,7 @@ func init() {
|
|||||||
f := migrationFunc{
|
f := migrationFunc{
|
||||||
name: name,
|
name: name,
|
||||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
condition := "key = ?"
|
if hasRun(name, db) {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,13 +18,7 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&models.KeyStringValue{
|
setHasRun(name, db)
|
||||||
Key: name,
|
|
||||||
Value: "done",
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -13,15 +12,14 @@ func init() {
|
|||||||
f := migrationFunc{
|
f := migrationFunc{
|
||||||
name: name,
|
name: name,
|
||||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
condition := "key = ?"
|
condition := "key = ?"
|
||||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||||
condition = "`key` = ?"
|
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"}
|
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
|
||||||
if err := db.
|
if err := db.
|
||||||
@ -32,13 +30,7 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&models.KeyStringValue{
|
setHasRun(name, db)
|
||||||
Key: name,
|
|
||||||
Value: "done",
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ func init() {
|
|||||||
f := migrationFunc{
|
f := migrationFunc{
|
||||||
name: name,
|
name: name,
|
||||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
if err := db.Migrator().DropTable("gorp_migrations"); err != nil {
|
if err := db.Migrator().DropTable("gorp_migrations"); err == nil {
|
||||||
logbuch.Info("dropped table 'gorp_migrations'")
|
logbuch.Info("dropped table 'gorp_migrations'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
37
migrations/20210806_remove_persisted_project_labels.go
Normal file
37
migrations/20210806_remove_persisted_project_labels.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20210806-remove_persisted_project_labels"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDb, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("failed to retrieve raw sql db instance")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := rawDb.Exec(fmt.Sprintf("delete from summary_items where type = %d", models.SummaryLabel)); err != nil {
|
||||||
|
logbuch.Error("failed to delete project label summary items")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logbuch.Info("successfully deleted project label summary items")
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
52
migrations/20211215_migrate_id_to_bigint.go
Normal file
52
migrations/20211215_migrate_id_to_bigint.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20211215-migrate_id_to_bigint-add_has_data_field"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("this may take a while!")
|
||||||
|
|
||||||
|
if cfg.Db.IsMySQL() {
|
||||||
|
tx := db.Begin()
|
||||||
|
if err := tx.Exec("ALTER TABLE heartbeats MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("ALTER TABLE summary_items MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
} else if cfg.Db.IsPostgres() {
|
||||||
|
// postgres does not have unsigned data types
|
||||||
|
// https://www.postgresql.org/docs/10/datatype-numeric.html
|
||||||
|
tx := db.Begin()
|
||||||
|
if err := tx.Exec("ALTER TABLE heartbeats ALTER COLUMN id TYPE BIGINT").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("ALTER TABLE summary_items ALTER COLUMN id TYPE BIGINT").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
} else {
|
||||||
|
// sqlite doesn't allow for changing column type easily
|
||||||
|
// https://stackoverflow.com/a/2083562/3112139
|
||||||
|
logbuch.Warn("unable to migrate id columns to bigint on %s", cfg.Db.Dialect)
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
48
migrations/20212212_total_summary_heartbeats.go
Normal file
48
migrations/20212212_total_summary_heartbeats.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20212212-total_summary_heartbeats"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("this may take a while!")
|
||||||
|
|
||||||
|
// this turns out to actually be way faster than using joins and instead has the benefit of being cross-dialect compatible
|
||||||
|
|
||||||
|
var summaries []*models.Summary
|
||||||
|
if err := db.Model(&models.Summary{}).
|
||||||
|
Select("id, from_time, to_time, user_id").
|
||||||
|
Scan(&summaries).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
for _, s := range summaries {
|
||||||
|
query := "UPDATE summaries SET num_heartbeats = (SELECT count(id) AS num_heartbeats FROM heartbeats WHERE user_id = @user AND time BETWEEN @from AND @to) WHERE id = @id"
|
||||||
|
tx.Exec(query, sql.Named("from", s.FromTime), sql.Named("to", s.ToTime), sql.Named("id", s.ID), sql.Named("user", s.UserID))
|
||||||
|
}
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
logbuch.Error("failed to retroactively determine total summary heartbeats")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
56
migrations/20220317_align_num_heartbeats.go
Normal file
56
migrations/20220317_align_num_heartbeats.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20220317-align_num_heartbeats"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("this may take a while!")
|
||||||
|
|
||||||
|
// find all summaries whose num_heartbeats is zero even though they have items
|
||||||
|
var faultyIds []uint
|
||||||
|
|
||||||
|
if err := db.Model(&models.Summary{}).
|
||||||
|
Distinct("summaries.id").
|
||||||
|
Joins("INNER JOIN summary_items ON summaries.num_heartbeats = 0 AND summaries.id = summary_items.summary_id").
|
||||||
|
Scan(&faultyIds).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update their heartbeats counter
|
||||||
|
result := db.
|
||||||
|
Table("summaries AS s1").
|
||||||
|
Where("s1.id IN ?", faultyIds).
|
||||||
|
Update(
|
||||||
|
"num_heartbeats",
|
||||||
|
db.
|
||||||
|
Model(&models.Heartbeat{}).
|
||||||
|
Select("COUNT(*)").
|
||||||
|
Where("user_id = ?", gorm.Expr("s1.user_id")).
|
||||||
|
Where("time BETWEEN ? AND ?", gorm.Expr("s1.from_time"), gorm.Expr("s1.to_time")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := result.Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("corrected heartbeats counter of %d summaries", result.RowsAffected)
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
41
migrations/20220318_mysql_timestamp_precision.go
Normal file
41
migrations/20220318_mysql_timestamp_precision.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20220318-mysql_timestamp_precision"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Db.IsMySQL() {
|
||||||
|
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
|
||||||
|
|
||||||
|
db.Exec("SET foreign_key_checks=0;")
|
||||||
|
db.Exec("SET unique_checks=0;")
|
||||||
|
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `time` TIMESTAMP(3) NOT NULL").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `created_at` TIMESTAMP(3) NOT NULL").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
db.Exec("SET foreign_key_checks=1;")
|
||||||
|
db.Exec("SET unique_checks=1;")
|
||||||
|
|
||||||
|
logbuch.Info("migrated timestamp columns to millisecond precision")
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
39
migrations/202203191_drop_diagnostics_user.go
Normal file
39
migrations/202203191_drop_diagnostics_user.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "202203191-drop_diagnostics_user"
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
migrator := db.Migrator()
|
||||||
|
|
||||||
|
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
|
||||||
|
logbuch.Info("running migration '%s'", name)
|
||||||
|
|
||||||
|
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
|
||||||
|
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
|
||||||
|
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
40
migrations/20220403_drop_user_project_idx.go
Normal file
40
migrations/20220403_drop_user_project_idx.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migration to fix https://github.com/muety/wakapi/issues/346
|
||||||
|
// caused by https://github.com/muety/wakapi/blob/2.3.2/migrations/20220319_add_user_project_idx.go in combination with
|
||||||
|
// the wrongly defined index at https://github.com/muety/wakapi/blob/5aae18e2415d9e620f383f98cd8cbdf39cd99f27/models/heartbeat.go#L18
|
||||||
|
// and https://github.com/go-gorm/sqlite/issues/87
|
||||||
|
// -> drop index and let it be auto-created again with properly formatted ddl
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20220403-drop_user_project_idx"
|
||||||
|
const idxName = "idx_user_project"
|
||||||
|
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if !db.Migrator().HasTable(&models.KeyStringValue{}) || hasRun(name, db) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Db.IsSQLite() && db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
|
||||||
|
logbuch.Info("running migration '%s'", name)
|
||||||
|
if err := db.Migrator().DropIndex(&models.Heartbeat{}, idxName); err != nil {
|
||||||
|
logbuch.Warn("failed to drop %s", idxName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRun(name, db)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPreMigration(f)
|
||||||
|
}
|
24
migrations/20220930_drop_heartbeats_entity_idx.go
Normal file
24
migrations/20220930_drop_heartbeats_entity_idx.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20220930-drop_heartbeats_entity_idx"
|
||||||
|
const idxName = "idx_entity"
|
||||||
|
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if !db.Migrator().HasTable(&models.Heartbeat{}) || !db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Migrator().DropIndex(&models.Heartbeat{}, idxName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPreMigration(f)
|
||||||
|
}
|
77
migrations/20221002_fix_summary_id_types.go
Normal file
77
migrations/20221002_fix_summary_id_types.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fix for https://github.com/muety/wakapi/issues/416
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
const name = "20221002-fix_summary_id_types"
|
||||||
|
|
||||||
|
f := migrationFunc{
|
||||||
|
name: name,
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
if cfg.Db.Dialect != config.SQLDialectMysql {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !db.Migrator().HasTable(&models.Summary{}) || !db.Migrator().HasTable(&models.SummaryItem{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentType string
|
||||||
|
if err := db.
|
||||||
|
Table("information_schema.columns").
|
||||||
|
Select("data_type").
|
||||||
|
Where("table_name = ?", "summary_items").
|
||||||
|
Where("column_name = ?", "summary_id").
|
||||||
|
Limit(1).
|
||||||
|
Row().Scan(¤tType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(currentType) != "int" {
|
||||||
|
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_editors") {
|
||||||
|
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_editors"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_languages") {
|
||||||
|
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_languages"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_machines") {
|
||||||
|
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_machines"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems") {
|
||||||
|
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_projects") {
|
||||||
|
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_projects"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Migrator().AlterColumn(&models.Summary{}, "id"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Migrator().AlterColumn(&models.SummaryItem{}, "summary_id"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPreMigration(f)
|
||||||
|
}
|
@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
|
|||||||
for _, m := range preMigrations {
|
for _, m := range preMigrations {
|
||||||
logbuch.Info("potentially running migration '%s'", m.name)
|
logbuch.Info("potentially running migration '%s'", m.name)
|
||||||
if err := m.f(db, cfg); err != nil {
|
if err := m.f(db, cfg); err != nil {
|
||||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,7 +57,7 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
|
|||||||
for _, m := range postMigrations {
|
for _, m := range postMigrations {
|
||||||
logbuch.Info("potentially running migration '%s'", m.name)
|
logbuch.Info("potentially running migration '%s'", m.name)
|
||||||
if err := m.f(db, cfg); err != nil {
|
if err := m.f(db, cfg); err != nil {
|
||||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
30
migrations/shared.go
Normal file
30
migrations/shared.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasRun(name string, db *gorm.DB) bool {
|
||||||
|
condition := "key = ?"
|
||||||
|
if config.Get().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 true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHasRun(name string, db *gorm.DB) {
|
||||||
|
if err := db.Create(&models.KeyStringValue{
|
||||||
|
Key: name,
|
||||||
|
Value: "done",
|
||||||
|
}).Error; err != nil {
|
||||||
|
logbuch.Error("failed to mark migration %s as run - %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AliasRepositoryMock) GetAll() ([]*models.Alias, error) {
|
||||||
|
args := m.Called()
|
||||||
|
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||||
args := m.Called(s)
|
args := m.Called(s)
|
||||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||||
|
@ -29,6 +29,11 @@ func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
|
|||||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AliasServiceMock) GetByUserAndType(s string, u uint8) ([]*models.Alias, error) {
|
||||||
|
args := m.Called(s, u)
|
||||||
|
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||||
args := m.Called(s, s2, u)
|
args := m.Called(s, s2, u)
|
||||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||||
|
16
mocks/duration_service.go
Normal file
16
mocks/duration_service.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DurationServiceMock struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User, f *models.Filters) (models.Durations, error) {
|
||||||
|
args := m.Called(time, time2, user, f)
|
||||||
|
return args.Get(0).(models.Durations), args.Error(1)
|
||||||
|
}
|
@ -20,8 +20,8 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
|
|||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) Count() (int64, error) {
|
func (m *HeartbeatServiceMock) Count(a bool) (int64, error) {
|
||||||
args := m.Called()
|
args := m.Called(a)
|
||||||
return int64(args.Int(0)), args.Error(1)
|
return int64(args.Int(0)), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,17 +40,37 @@ func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, use
|
|||||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *HeartbeatServiceMock) GetAllWithinByFilters(time time.Time, time2 time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
|
||||||
|
args := m.Called(time, time2, user, filters)
|
||||||
|
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
args := m.Called()
|
args := m.Called()
|
||||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *HeartbeatServiceMock) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
|
||||||
|
args := m.Called(user)
|
||||||
|
return args.Get(0).(*models.Heartbeat), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
|
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
|
||||||
args := m.Called(s, user)
|
args := m.Called(s, user)
|
||||||
return args.Get(0).(*models.Heartbeat), args.Error(1)
|
return args.Get(0).(*models.Heartbeat), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *HeartbeatServiceMock) GetEntitySetByUser(u uint8, user *models.User) ([]string, error) {
|
||||||
|
args := m.Called(u, user)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||||
args := m.Called(time)
|
args := m.Called(time)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
|
||||||
|
args := m.Called(u)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
40
mocks/project_label_service.go
Normal file
40
mocks/project_label_service.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLabelServiceMock struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(u)
|
||||||
|
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUserGroupedInverted(s string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(l)
|
||||||
|
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
|
||||||
|
args := p.Called(l)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
|||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
|
||||||
|
args := m.Called()
|
||||||
|
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||||
args := m.Called(user, time, time2)
|
args := m.Called(user, time, time2)
|
||||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||||
|
@ -34,8 +34,28 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
|||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserServiceMock) GetActive() ([]*models.User, error) {
|
func (m *UserServiceMock) GetMany(s []string) ([]*models.User, error) {
|
||||||
|
args := m.Called(s)
|
||||||
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) GetManyMapped(s []string) (map[string]*models.User, error) {
|
||||||
args := m.Called()
|
args := m.Called()
|
||||||
|
return args.Get(0).(map[string]*models.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) GetAllByLeaderboard(b bool) ([]*models.User, error) {
|
||||||
|
//TODO implement me
|
||||||
|
panic("implement me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
|
||||||
|
args := m.Called(b)
|
||||||
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
|
||||||
|
args := m.Called(b)
|
||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,8 +89,8 @@ func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error)
|
|||||||
return args.Get(0).(*models.User), args.Error(1)
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) {
|
func (m *UserServiceMock) SetWakatimeApiCredentials(user *models.User, s1, s2 string) (*models.User, error) {
|
||||||
args := m.Called(user, s)
|
args := m.Called(user, s1, s2)
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
// AliasResolver returns the alias of an entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value
|
||||||
|
type AliasResolver func(t uint8, k string) string
|
||||||
|
|
||||||
|
// AliasReverseResolver returns all original names, which have the given alias as mapping target. I.e., it returns a list of Alias.Value, given an Alias.Key
|
||||||
|
type AliasReverseResolver func(t uint8, k string) []string
|
||||||
|
|
||||||
type Alias struct {
|
type Alias struct {
|
||||||
ID uint `gorm:"primary_key"`
|
ID uint `gorm:"primary_key"`
|
||||||
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
||||||
|
@ -3,14 +3,13 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://shields.io/endpoint
|
// https://shields.io/endpoint
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLabel = "coding time"
|
defaultLabel = "wakapi.dev"
|
||||||
defaultColor = "#2D3748" // not working
|
defaultColor = "2F855A"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BadgeData struct {
|
type BadgeData struct {
|
||||||
@ -20,18 +19,11 @@ type BadgeData struct {
|
|||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
|
func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
|
||||||
var total time.Duration
|
|
||||||
if hasFilter, _, _ := filters.One(); hasFilter {
|
|
||||||
total = summary.TotalTimeByFilters(filters)
|
|
||||||
} else {
|
|
||||||
total = summary.TotalTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &BadgeData{
|
return &BadgeData{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
Label: defaultLabel,
|
Label: defaultLabel,
|
||||||
Message: utils.FmtWakatimeDuration(total),
|
Message: utils.FmtWakatimeDuration(summary.TotalTime()),
|
||||||
Color: defaultColor,
|
Color: defaultColor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://wakatime.com/developers#all_time_since_today
|
// https://wakatime.com/developers#all_time_since_today
|
||||||
@ -27,14 +26,8 @@ type AllTimeRange struct {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
|
||||||
var total time.Duration
|
total := summary.TotalTime()
|
||||||
if key := filters.Project; key != "" {
|
|
||||||
total = summary.TotalTimeByFilters(filters)
|
|
||||||
} else {
|
|
||||||
total = summary.TotalTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AllTimeViewModel{
|
return &AllTimeViewModel{
|
||||||
Data: &AllTimeData{
|
Data: &AllTimeData{
|
||||||
TotalSeconds: float32(total.Seconds()),
|
TotalSeconds: float32(total.Seconds()),
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import "github.com/muety/wakapi/models"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
)
|
||||||
|
|
||||||
type HeartbeatsViewModel struct {
|
type HeartbeatsViewModel struct {
|
||||||
Data []*HeartbeatEntry `json:"data"`
|
Data []*HeartbeatEntry `json:"data"`
|
||||||
@ -10,16 +15,40 @@ type HeartbeatsViewModel struct {
|
|||||||
// that is actually required for the import
|
// that is actually required for the import
|
||||||
|
|
||||||
type HeartbeatEntry struct {
|
type HeartbeatEntry struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Entity string `json:"entity"`
|
Entity string `json:"entity"`
|
||||||
IsWrite bool `json:"is_write"`
|
IsWrite bool `json:"is_write"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Time models.CustomTime `json:"time"`
|
Time float64 `json:"time"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
UserId string `json:"user_id"`
|
UserId string `json:"user_id"`
|
||||||
MachineNameId string `json:"machine_name_id"`
|
MachineNameId string `json:"machine_name_id"`
|
||||||
UserAgentId string `json:"user_agent_id"`
|
UserAgentId string `json:"user_agent_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HeartbeatsToCompat(entries []*models.Heartbeat) []*HeartbeatEntry {
|
||||||
|
out := make([]*HeartbeatEntry, len(entries))
|
||||||
|
for i := 0; i < len(entries); i++ {
|
||||||
|
entry := entries[i]
|
||||||
|
out[i] = &HeartbeatEntry{
|
||||||
|
Id: strconv.FormatUint(entry.ID, 10),
|
||||||
|
Branch: entry.Branch,
|
||||||
|
Category: entry.Category,
|
||||||
|
Entity: entry.Entity,
|
||||||
|
IsWrite: entry.IsWrite,
|
||||||
|
Language: entry.Language,
|
||||||
|
Project: entry.Project,
|
||||||
|
Time: float64(entry.Time.T().Unix()),
|
||||||
|
Type: entry.Type,
|
||||||
|
UserId: entry.UserID,
|
||||||
|
MachineNameId: entry.Machine,
|
||||||
|
UserAgentId: entry.UserAgent,
|
||||||
|
CreatedAt: entry.CreatedAt.T(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,8 @@ package v1
|
|||||||
// https://wakatime.com/api/v1/users/current/machine_names
|
// https://wakatime.com/api/v1/users/current/machine_names
|
||||||
|
|
||||||
type MachineViewModel struct {
|
type MachineViewModel struct {
|
||||||
Data []*MachineEntry `json:"data"`
|
Data []*MachineEntry `json:"data"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineEntry struct {
|
type MachineEntry struct {
|
||||||
|
11
models/compat/wakatime/v1/project.go
Normal file
11
models/compat/wakatime/v1/project.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
type ProjectsViewModel struct {
|
||||||
|
Data []*Project `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
}
|
@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ type StatsData struct {
|
|||||||
Machines []*SummariesEntry `json:"machines"`
|
Machines []*SummariesEntry `json:"machines"`
|
||||||
Projects []*SummariesEntry `json:"projects"`
|
Projects []*SummariesEntry `json:"projects"`
|
||||||
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||||
|
Branches []*SummariesEntry `json:"branches,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||||
@ -41,6 +43,10 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
|
|||||||
DaysIncludingHolidays: numDays,
|
DaysIncludingHolidays: numDays,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
||||||
|
data.DailyAverage = 0
|
||||||
|
}
|
||||||
|
|
||||||
editors := make([]*SummariesEntry, len(summary.Editors))
|
editors := make([]*SummariesEntry, len(summary.Editors))
|
||||||
for i, e := range summary.Editors {
|
for i, e := range summary.Editors {
|
||||||
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
|
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
|
||||||
@ -66,11 +72,21 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
|
|||||||
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
|
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
branches := make([]*SummariesEntry, len(summary.Branches))
|
||||||
|
for i, e := range summary.Branches {
|
||||||
|
branches[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryBranch))
|
||||||
|
}
|
||||||
|
|
||||||
data.Editors = editors
|
data.Editors = editors
|
||||||
data.Languages = languages
|
data.Languages = languages
|
||||||
data.Machines = machines
|
data.Machines = machines
|
||||||
data.Projects = projects
|
data.Projects = projects
|
||||||
data.OperatingSystems = oss
|
data.OperatingSystems = oss
|
||||||
|
data.Branches = branches
|
||||||
|
|
||||||
|
if summary.Branches == nil {
|
||||||
|
data.Branches = nil
|
||||||
|
}
|
||||||
|
|
||||||
return &StatsViewModel{
|
return &StatsViewModel{
|
||||||
Data: data,
|
Data: data,
|
||||||
|
@ -26,6 +26,7 @@ type SummariesData struct {
|
|||||||
Machines []*SummariesEntry `json:"machines"`
|
Machines []*SummariesEntry `json:"machines"`
|
||||||
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||||
Projects []*SummariesEntry `json:"projects"`
|
Projects []*SummariesEntry `json:"projects"`
|
||||||
|
Branches []*SummariesEntry `json:"branches,omitempty"`
|
||||||
GrandTotal *SummariesGrandTotal `json:"grand_total"`
|
GrandTotal *SummariesGrandTotal `json:"grand_total"`
|
||||||
Range *SummariesRange `json:"range"`
|
Range *SummariesRange `json:"range"`
|
||||||
}
|
}
|
||||||
@ -57,7 +58,7 @@ type SummariesRange struct {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
|
||||||
data := make([]*SummariesData, len(summaries))
|
data := make([]*SummariesData, len(summaries))
|
||||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||||
|
|
||||||
@ -92,6 +93,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
Machines: make([]*SummariesEntry, len(s.Machines)),
|
Machines: make([]*SummariesEntry, len(s.Machines)),
|
||||||
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
|
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
|
||||||
Projects: make([]*SummariesEntry, len(s.Projects)),
|
Projects: make([]*SummariesEntry, len(s.Projects)),
|
||||||
|
Branches: make([]*SummariesEntry, len(s.Branches)),
|
||||||
GrandTotal: &SummariesGrandTotal{
|
GrandTotal: &SummariesGrandTotal{
|
||||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||||
Hours: totalHrs,
|
Hours: totalHrs,
|
||||||
@ -109,7 +111,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(5)
|
wg.Add(6)
|
||||||
|
|
||||||
go func(data *SummariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@ -129,7 +131,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Languages {
|
for i, e := range s.Languages {
|
||||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||||
|
|
||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
@ -147,14 +148,23 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
|
go func(data *SummariesData) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i, e := range s.Branches {
|
||||||
|
data.Branches[i] = convertEntry(e, s.TotalTimeBy(models.SummaryBranch))
|
||||||
|
}
|
||||||
|
}(data)
|
||||||
|
|
||||||
|
if s.Branches == nil {
|
||||||
|
data.Branches = nil
|
||||||
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return data
|
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
|
total := e.TotalFixed()
|
||||||
// TODO: fix some day, while migrating persisted summary items
|
|
||||||
total := e.Total * time.Second
|
|
||||||
hrs := int(total.Hours())
|
hrs := int(total.Hours())
|
||||||
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
||||||
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
||||||
|
55
models/compat/wakatime/v1/user.go
Normal file
55
models/compat/wakatime/v1/user.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultWakaUserDisplayName = "Anonymous User"
|
||||||
|
|
||||||
|
// partially compatible with https://wakatime.com/developers#users
|
||||||
|
|
||||||
|
type UserViewModel struct {
|
||||||
|
Data *User `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
IsEmailPublic bool `json:"is_email_public"`
|
||||||
|
IsEmailConfirmed bool `json:"is_email_confirmed"`
|
||||||
|
TimeZone string `json:"timezone"`
|
||||||
|
LastHeartbeatAt models.CustomTime `json:"last_heartbeat_at"`
|
||||||
|
LastProject string `json:"last_project"`
|
||||||
|
LastPluginName string `json:"last_plugin_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
CreatedAt models.CustomTime `json:"created_at"`
|
||||||
|
ModifiedAt models.CustomTime `json:"modified_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromUser(user *models.User) *User {
|
||||||
|
tz, _ := time.Now().Zone()
|
||||||
|
if user.Location != "" {
|
||||||
|
tz = user.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
ID: user.ID,
|
||||||
|
DisplayName: DefaultWakaUserDisplayName,
|
||||||
|
Email: user.Email,
|
||||||
|
TimeZone: tz,
|
||||||
|
Username: user.ID,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
ModifiedAt: user.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) WithLatestHeartbeat(h *models.Heartbeat) *User {
|
||||||
|
u.LastHeartbeatAt = h.Time
|
||||||
|
u.LastProject = h.Project
|
||||||
|
u.LastPluginName = h.Editor
|
||||||
|
return u
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
type UserAgentsViewModel struct {
|
type UserAgentsViewModel struct {
|
||||||
Data []*UserAgentEntry `json:"data"`
|
Data []*UserAgentEntry `json:"data"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserAgentEntry struct {
|
type UserAgentEntry struct {
|
||||||
|
11
models/diagnostics.go
Normal file
11
models/diagnostics.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Diagnostics struct {
|
||||||
|
ID uint `gorm:"primary_key"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Plugin string `json:"plugin"`
|
||||||
|
CliVersion string `json:"cli_version"`
|
||||||
|
Logs string `json:"logs" gorm:"type:text"`
|
||||||
|
StackTrace string `json:"stacktrace" gorm:"type:text"`
|
||||||
|
}
|
70
models/duration.go
Normal file
70
models/duration.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/mitchellh/hashstructure/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Duration struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Time CustomTime `json:"time" hash:"ignore"`
|
||||||
|
Duration time.Duration `json:"duration" hash:"ignore"`
|
||||||
|
Project string `json:"project"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Editor string `json:"editor"`
|
||||||
|
OperatingSystem string `json:"operating_system"`
|
||||||
|
Machine string `json:"machine"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
NumHeartbeats int `json:"-" hash:"ignore"`
|
||||||
|
GroupHash string `json:"-" hash:"ignore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
|
||||||
|
d := &Duration{
|
||||||
|
UserID: h.UserID,
|
||||||
|
Time: h.Time,
|
||||||
|
Duration: 0,
|
||||||
|
Project: h.Project,
|
||||||
|
Language: h.Language,
|
||||||
|
Editor: h.Editor,
|
||||||
|
OperatingSystem: h.OperatingSystem,
|
||||||
|
Machine: h.Machine,
|
||||||
|
Branch: h.Branch,
|
||||||
|
NumHeartbeats: 1,
|
||||||
|
}
|
||||||
|
return d.Hashed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) Hashed() *Duration {
|
||||||
|
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||||
|
}
|
||||||
|
d.GroupHash = fmt.Sprintf("%x", hash)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) GetKey(t uint8) (key string) {
|
||||||
|
switch t {
|
||||||
|
case SummaryProject:
|
||||||
|
key = d.Project
|
||||||
|
case SummaryEditor:
|
||||||
|
key = d.Editor
|
||||||
|
case SummaryLanguage:
|
||||||
|
key = d.Language
|
||||||
|
case SummaryOS:
|
||||||
|
key = d.OperatingSystem
|
||||||
|
case SummaryMachine:
|
||||||
|
key = d.Machine
|
||||||
|
case SummaryBranch:
|
||||||
|
key = d.Branch
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
key = UnknownSummaryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
46
models/durations.go
Normal file
46
models/durations.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
type Durations []*Duration
|
||||||
|
|
||||||
|
func (d Durations) Len() int {
|
||||||
|
return len(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Durations) Less(i, j int) bool {
|
||||||
|
return d[i].Time.T().Before(d[j].Time.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Durations) Swap(i, j int) {
|
||||||
|
d[i], d[j] = d[j], d[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Durations) TotalNumHeartbeats() int {
|
||||||
|
var total int
|
||||||
|
for _, e := range d {
|
||||||
|
total += e.NumHeartbeats
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Durations) Sorted() Durations {
|
||||||
|
sort.Sort(d)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Durations) First() *Duration {
|
||||||
|
// assumes slice to be sorted
|
||||||
|
if d.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (*d)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Durations) Last() *Duration {
|
||||||
|
// assumes slice to be sorted
|
||||||
|
if d.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (*d)[d.Len()-1]
|
||||||
|
}
|
@ -1,45 +1,223 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/mitchellh/hashstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
type Filters struct {
|
type Filters struct {
|
||||||
Project string
|
Project OrFilter
|
||||||
OS string
|
OS OrFilter
|
||||||
Language string
|
Language OrFilter
|
||||||
Editor string
|
Editor OrFilter
|
||||||
Machine string
|
Machine OrFilter
|
||||||
|
Label OrFilter
|
||||||
|
Branch OrFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrFilter []string
|
||||||
|
|
||||||
|
func (f OrFilter) Exists() bool {
|
||||||
|
return len(f) > 0 && f[0] != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f OrFilter) MatchAny(search string) bool {
|
||||||
|
for _, s := range f {
|
||||||
|
if s == search {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterElement struct {
|
type FilterElement struct {
|
||||||
Type uint8
|
entity uint8
|
||||||
Key string
|
filter OrFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||||
switch entity {
|
return NewFilterWithMultiple(entity, []string{key})
|
||||||
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) {
|
func NewFilterWithMultiple(entity uint8, keys []string) *Filters {
|
||||||
if f.Project != "" {
|
filters := &Filters{}
|
||||||
return true, SummaryProject, f.Project
|
return filters.WithMultiple(entity, keys)
|
||||||
} else if f.OS != "" {
|
}
|
||||||
return true, SummaryOS, f.OS
|
|
||||||
} else if f.Language != "" {
|
func (f *Filters) With(entity uint8, key string) *Filters {
|
||||||
return true, SummaryLanguage, f.Language
|
return f.WithMultiple(entity, []string{key})
|
||||||
} else if f.Editor != "" {
|
}
|
||||||
return true, SummaryEditor, f.Editor
|
|
||||||
} else if f.Machine != "" {
|
func (f *Filters) WithMultiple(entity uint8, keys []string) *Filters {
|
||||||
return true, SummaryMachine, f.Machine
|
switch entity {
|
||||||
}
|
case SummaryProject:
|
||||||
return false, 0, ""
|
f.Project = append(f.Project, keys...)
|
||||||
|
case SummaryOS:
|
||||||
|
f.OS = append(f.OS, keys...)
|
||||||
|
case SummaryLanguage:
|
||||||
|
f.Language = append(f.Language, keys...)
|
||||||
|
case SummaryEditor:
|
||||||
|
f.Editor = append(f.Editor, keys...)
|
||||||
|
case SummaryMachine:
|
||||||
|
f.Machine = append(f.Machine, keys...)
|
||||||
|
case SummaryLabel:
|
||||||
|
f.Label = append(f.Label, keys...)
|
||||||
|
case SummaryBranch:
|
||||||
|
f.Branch = append(f.Branch, keys...)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) One() (bool, uint8, OrFilter) {
|
||||||
|
if f.Project != nil && f.Project.Exists() {
|
||||||
|
return true, SummaryProject, f.Project
|
||||||
|
} else if f.OS != nil && f.OS.Exists() {
|
||||||
|
return true, SummaryOS, f.OS
|
||||||
|
} else if f.Language != nil && f.Language.Exists() {
|
||||||
|
return true, SummaryLanguage, f.Language
|
||||||
|
} else if f.Editor != nil && f.Editor.Exists() {
|
||||||
|
return true, SummaryEditor, f.Editor
|
||||||
|
} else if f.Machine != nil && f.Machine.Exists() {
|
||||||
|
return true, SummaryMachine, f.Machine
|
||||||
|
} else if f.Label != nil && f.Label.Exists() {
|
||||||
|
return true, SummaryLabel, f.Label
|
||||||
|
} else if f.Branch != nil && f.Branch.Exists() {
|
||||||
|
return true, SummaryBranch, f.Branch
|
||||||
|
}
|
||||||
|
return false, 0, OrFilter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) OneOrEmpty() FilterElement {
|
||||||
|
if ok, t, of := f.One(); ok {
|
||||||
|
return FilterElement{entity: t, filter: of}
|
||||||
|
}
|
||||||
|
return FilterElement{entity: SummaryUnknown, filter: []string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) IsEmpty() bool {
|
||||||
|
nonEmpty, _, _ := f.One()
|
||||||
|
return !nonEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) Count() int {
|
||||||
|
var count int
|
||||||
|
for i := SummaryProject; i <= SummaryBranch; i++ {
|
||||||
|
count += f.CountByEntity(i)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) CountByEntity(entity uint8) int {
|
||||||
|
return len(*f.ResolveEntity(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) EntityCount() int {
|
||||||
|
var count int
|
||||||
|
for i := SummaryProject; i <= SummaryBranch; i++ {
|
||||||
|
if c := f.CountByEntity(i); c > 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) ResolveEntity(entityId uint8) *OrFilter {
|
||||||
|
switch entityId {
|
||||||
|
case SummaryProject:
|
||||||
|
return &f.Project
|
||||||
|
case SummaryLanguage:
|
||||||
|
return &f.Language
|
||||||
|
case SummaryEditor:
|
||||||
|
return &f.Editor
|
||||||
|
case SummaryOS:
|
||||||
|
return &f.OS
|
||||||
|
case SummaryMachine:
|
||||||
|
return &f.Machine
|
||||||
|
case SummaryLabel:
|
||||||
|
return &f.Label
|
||||||
|
case SummaryBranch:
|
||||||
|
return &f.Branch
|
||||||
|
default:
|
||||||
|
return &OrFilter{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) Hash() string {
|
||||||
|
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) Match(h *Heartbeat) bool {
|
||||||
|
return (f.Project == nil || f.Project.MatchAny(h.Project)) &&
|
||||||
|
(f.OS == nil || f.OS.MatchAny(h.OperatingSystem)) &&
|
||||||
|
(f.Language == nil || f.Language.MatchAny(h.Language)) &&
|
||||||
|
(f.Editor == nil || f.Editor.MatchAny(h.Editor)) &&
|
||||||
|
(f.Machine == nil || f.Machine.MatchAny(h.Machine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAliases adds OR-conditions for every alias of a filter key as additional filter keys
|
||||||
|
func (f *Filters) WithAliases(resolve AliasReverseResolver) *Filters {
|
||||||
|
if f.Project != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.Project)))
|
||||||
|
for _, e := range f.Project {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryProject, e)...)
|
||||||
|
}
|
||||||
|
f.Project = updated
|
||||||
|
}
|
||||||
|
if f.OS != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.OS)))
|
||||||
|
for _, e := range f.OS {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryOS, e)...)
|
||||||
|
}
|
||||||
|
f.OS = updated
|
||||||
|
}
|
||||||
|
if f.Language != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.Language)))
|
||||||
|
for _, e := range f.Language {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryLanguage, e)...)
|
||||||
|
}
|
||||||
|
f.Language = updated
|
||||||
|
}
|
||||||
|
if f.Editor != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.Editor)))
|
||||||
|
for _, e := range f.Editor {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryEditor, e)...)
|
||||||
|
}
|
||||||
|
f.Editor = updated
|
||||||
|
}
|
||||||
|
if f.Machine != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.Machine)))
|
||||||
|
for _, e := range f.Machine {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryMachine, e)...)
|
||||||
|
}
|
||||||
|
f.Machine = updated
|
||||||
|
}
|
||||||
|
if f.Branch != nil {
|
||||||
|
updated := OrFilter(make([]string, 0, len(f.Branch)))
|
||||||
|
for _, e := range f.Branch {
|
||||||
|
updated = append(updated, e)
|
||||||
|
updated = append(updated, resolve(SummaryBranch, e)...)
|
||||||
|
}
|
||||||
|
f.Branch = updated
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Filters) WithProjectLabels(resolve ProjectLabelReverseResolver) *Filters {
|
||||||
|
if f.Label == nil || !f.Label.Exists() {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
for _, l := range f.Label {
|
||||||
|
f.WithMultiple(SummaryProject, resolve(l))
|
||||||
|
}
|
||||||
|
return f
|
||||||
}
|
}
|
||||||
|
160
models/filters_test.go
Normal file
160
models/filters_test.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FiltersTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
TestAliases []*Alias
|
||||||
|
TestProjectLabels []*ProjectLabel
|
||||||
|
GetAliasReverseResolver func(indices []int) AliasReverseResolver
|
||||||
|
GetProjectLabelReverseResolver func(indices []int) ProjectLabelReverseResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) SetupSuite() {
|
||||||
|
suite.TestAliases = []*Alias{
|
||||||
|
{
|
||||||
|
Type: SummaryProject,
|
||||||
|
Key: "wakapi",
|
||||||
|
Value: "wakapi-mobile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SummaryProject,
|
||||||
|
Key: "wakapi",
|
||||||
|
Value: "wakapi-desktop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SummaryLanguage,
|
||||||
|
Key: "Python",
|
||||||
|
Value: "Python 3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.TestProjectLabels = []*ProjectLabel{
|
||||||
|
{
|
||||||
|
ProjectKey: "wakapi",
|
||||||
|
Label: "oss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ProjectKey: "anchr",
|
||||||
|
Label: "oss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ProjectKey: "business-application",
|
||||||
|
Label: "work",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.GetAliasReverseResolver = func(indices []int) AliasReverseResolver {
|
||||||
|
return func(t uint8, k string) []string {
|
||||||
|
aliases := make([]string, 0, len(indices))
|
||||||
|
for _, j := range indices {
|
||||||
|
if a := suite.TestAliases[j]; a.Type == t && a.Key == k {
|
||||||
|
aliases = append(aliases, a.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.GetProjectLabelReverseResolver = func(indices []int) ProjectLabelReverseResolver {
|
||||||
|
return func(k string) []string {
|
||||||
|
labels := make([]string, 0, len(indices))
|
||||||
|
for _, j := range indices {
|
||||||
|
if l := suite.TestProjectLabels[j]; l.Label == k {
|
||||||
|
labels = append(labels, l.ProjectKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiltersTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(FiltersTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) TestFilters_IsEmpty() {
|
||||||
|
assert.False(suite.T(), NewFiltersWith(SummaryProject, "wakapi").IsEmpty())
|
||||||
|
assert.True(suite.T(), (&Filters{}).IsEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) TestFilters_Match() {
|
||||||
|
heartbeats := []*Heartbeat{
|
||||||
|
{Project: "wakapi", Language: "Go"},
|
||||||
|
{Project: "anchr", Language: "Javascript"},
|
||||||
|
}
|
||||||
|
|
||||||
|
sut1 := NewFiltersWith(SummaryProject, "wakapi")
|
||||||
|
assert.True(suite.T(), sut1.Match(heartbeats[0]))
|
||||||
|
assert.False(suite.T(), sut1.Match(heartbeats[1]))
|
||||||
|
|
||||||
|
sut2 := NewFiltersWith(SummaryProject, "Go").With(SummaryLanguage, "JavaScript")
|
||||||
|
assert.False(suite.T(), sut2.Match(heartbeats[0]))
|
||||||
|
assert.False(suite.T(), sut2.Match(heartbeats[1]))
|
||||||
|
|
||||||
|
sut3 := NewFilterWithMultiple(SummaryProject, []string{"wakapi", "anchr"})
|
||||||
|
assert.True(suite.T(), sut3.Match(heartbeats[0]))
|
||||||
|
assert.True(suite.T(), sut3.Match(heartbeats[1]))
|
||||||
|
|
||||||
|
sut4 := &Filters{}
|
||||||
|
assert.True(suite.T(), sut4.Match(heartbeats[0]))
|
||||||
|
assert.True(suite.T(), sut4.Match(heartbeats[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) TestFilters_One() {
|
||||||
|
sut1 := NewFiltersWith(SummaryLanguage, "Java")
|
||||||
|
ok1, type1, filters1 := sut1.One()
|
||||||
|
assert.True(suite.T(), ok1)
|
||||||
|
assert.Equal(suite.T(), SummaryLanguage, type1)
|
||||||
|
assert.Equal(suite.T(), "Java", filters1[0])
|
||||||
|
|
||||||
|
sut2 := &Filters{}
|
||||||
|
ok2, type2, filters2 := sut2.One()
|
||||||
|
assert.False(suite.T(), ok2)
|
||||||
|
assert.Zero(suite.T(), type2)
|
||||||
|
assert.Empty(suite.T(), filters2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) TestFilters_WithAliases() {
|
||||||
|
sut1 := NewFiltersWith(SummaryProject, "wakapi")
|
||||||
|
sut1 = sut1.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
|
||||||
|
assert.Len(suite.T(), sut1.Project, 3)
|
||||||
|
assert.Len(suite.T(), sut1.Language, 0)
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "wakapi")
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "wakapi-desktop")
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "wakapi-mobile")
|
||||||
|
|
||||||
|
sut2 := NewFiltersWith(SummaryProject, "wakapi").With(SummaryLanguage, "Python")
|
||||||
|
sut2 = sut2.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
|
||||||
|
assert.Len(suite.T(), sut2.Project, 3)
|
||||||
|
assert.Len(suite.T(), sut2.Language, 2)
|
||||||
|
assert.Contains(suite.T(), sut2.Language, "Python")
|
||||||
|
assert.Contains(suite.T(), sut2.Language, "Python 3")
|
||||||
|
|
||||||
|
sut3 := NewFiltersWith(SummaryProject, "foo")
|
||||||
|
sut3 = sut3.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
|
||||||
|
assert.Len(suite.T(), sut3.Project, 1)
|
||||||
|
assert.Len(suite.T(), sut3.Language, 0)
|
||||||
|
assert.Contains(suite.T(), sut3.Project, "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FiltersTestSuite) TestFilters_WithProjectLabels() {
|
||||||
|
sut1 := NewFiltersWith(SummaryProject, "mailwhale").With(SummaryLabel, "oss")
|
||||||
|
sut1 = sut1.WithProjectLabels(suite.GetProjectLabelReverseResolver([]int{0, 1, 2}))
|
||||||
|
assert.Len(suite.T(), sut1.Project, 3)
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "wakapi")
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "anchr")
|
||||||
|
assert.Contains(suite.T(), sut1.Project, "mailwhale")
|
||||||
|
assert.Contains(suite.T(), sut1.Label, "oss")
|
||||||
|
|
||||||
|
sut2 := NewFiltersWith(SummaryLabel, "oss")
|
||||||
|
sut2 = sut2.WithProjectLabels(suite.GetProjectLabelReverseResolver([]int{0, 1, 2}))
|
||||||
|
assert.Len(suite.T(), sut2.Project, 2)
|
||||||
|
assert.Contains(suite.T(), sut2.Project, "wakapi")
|
||||||
|
assert.Contains(suite.T(), sut2.Project, "anchr")
|
||||||
|
assert.Contains(suite.T(), sut2.Label, "oss")
|
||||||
|
}
|
@ -9,35 +9,42 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Heartbeat struct {
|
type Heartbeat struct {
|
||||||
ID uint `gorm:"primary_key" hash:"ignore"`
|
ID uint64 `gorm:"primary_key" hash:"ignore"`
|
||||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" 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"`
|
UserID string `json:"-" gorm:"not null; index:idx_time_user; index:idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
|
||||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
Entity string `json:"entity" gorm:"not null"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type" gorm:"size:255"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category" gorm:"size:255"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project" gorm:"index:idx_project; index:idx_user_project"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch" gorm:"index:idx_branch"`
|
||||||
Language string `json:"language" gorm:"index:idx_language"`
|
Language string `json:"language" gorm:"index:idx_language"`
|
||||||
IsWrite bool `json:"is_write"`
|
IsWrite bool `json:"is_write"`
|
||||||
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
Editor string `json:"editor" gorm:"index:idx_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
|
OperatingSystem string `json:"operating_system" gorm:"index:idx_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
|
Machine string `json:"machine" gorm:"index:idx_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"`
|
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
|
||||||
|
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
||||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||||
Origin string `json:"-" hash:"ignore"`
|
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||||
OriginId string `json:"-" hash:"ignore"`
|
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||||
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
|
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp(3)" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Heartbeat) Valid() bool {
|
func (h *Heartbeat) Valid() bool {
|
||||||
return h.User != nil && h.UserID != "" && h.User.ID == 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) Timely(maxAge time.Duration) bool {
|
||||||
|
now := time.Now()
|
||||||
|
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||||
|
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
||||||
for ending, value := range languageMappings {
|
for ending, value := range languageMappings {
|
||||||
if strings.HasSuffix(h.Entity, "."+ending) {
|
if ok, prec := strings.HasSuffix(h.Entity, "."+ending), strings.Count(ending, "."); ok && prec > maxPrec {
|
||||||
h.Language = value
|
h.Language = value
|
||||||
return
|
maxPrec = prec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,6 +61,8 @@ func (h *Heartbeat) GetKey(t uint8) (key string) {
|
|||||||
key = h.OperatingSystem
|
key = h.OperatingSystem
|
||||||
case SummaryMachine:
|
case SummaryMachine:
|
||||||
key = h.Machine
|
key = h.Machine
|
||||||
|
case SummaryBranch:
|
||||||
|
key = h.Branch
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@ -90,8 +99,20 @@ func (h *Heartbeat) String() string {
|
|||||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
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"
|
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetEntityColumn(t uint8) string {
|
||||||
|
return []string{
|
||||||
|
"project",
|
||||||
|
"language",
|
||||||
|
"editor",
|
||||||
|
"operating_system",
|
||||||
|
"machine",
|
||||||
|
"label",
|
||||||
|
"branch",
|
||||||
|
}[t]
|
||||||
|
}
|
||||||
|
@ -28,22 +28,28 @@ func TestHeartbeat_Augment(t *testing.T) {
|
|||||||
testMappings := map[string]string{
|
testMappings := map[string]string{
|
||||||
"py": "Python3",
|
"py": "Python3",
|
||||||
"foo": "Foo Script",
|
"foo": "Foo Script",
|
||||||
|
"php": "PHP 8",
|
||||||
"blade.php": "Blade",
|
"blade.php": "Blade",
|
||||||
}
|
}
|
||||||
|
|
||||||
sut1, sut2 := &Heartbeat{
|
sut1, sut2, sut3 := &Heartbeat{
|
||||||
Entity: "~/dev/file.py",
|
Entity: "~/dev/file.py",
|
||||||
Language: "Python",
|
Language: "Python",
|
||||||
}, &Heartbeat{
|
}, &Heartbeat{
|
||||||
Entity: "~/dev/file.blade.php",
|
Entity: "~/dev/file.blade.php",
|
||||||
Language: "unknown",
|
Language: "unknown",
|
||||||
|
}, &Heartbeat{
|
||||||
|
Entity: "~/dev/file.php",
|
||||||
|
Language: "PHP",
|
||||||
}
|
}
|
||||||
|
|
||||||
sut1.Augment(testMappings)
|
sut1.Augment(testMappings)
|
||||||
sut2.Augment(testMappings)
|
sut2.Augment(testMappings)
|
||||||
|
sut3.Augment(testMappings)
|
||||||
|
|
||||||
assert.Equal(t, "Python3", sut1.Language)
|
assert.Equal(t, "Python3", sut1.Language)
|
||||||
assert.Equal(t, "Blade", sut2.Language)
|
assert.Equal(t, "Blade", sut2.Language)
|
||||||
|
assert.Equal(t, "PHP 8", sut3.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHeartbeat_GetKey(t *testing.T) {
|
func TestHeartbeat_GetKey(t *testing.T) {
|
||||||
|
@ -14,8 +14,9 @@ var (
|
|||||||
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
||||||
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
||||||
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
||||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"}
|
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
|
||||||
IntervalAny = &IntervalKey{"any"}
|
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
|
||||||
|
IntervalAny = &IntervalKey{"any", "all_time"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllIntervals = []*IntervalKey{
|
var AllIntervals = []*IntervalKey{
|
||||||
@ -30,6 +31,7 @@ var AllIntervals = []*IntervalKey{
|
|||||||
IntervalPast7DaysYesterday,
|
IntervalPast7DaysYesterday,
|
||||||
IntervalPast14Days,
|
IntervalPast14Days,
|
||||||
IntervalPast30Days,
|
IntervalPast30Days,
|
||||||
|
IntervalPast6Months,
|
||||||
IntervalPast12Months,
|
IntervalPast12Months,
|
||||||
IntervalAny,
|
IntervalAny,
|
||||||
}
|
}
|
||||||
|
80
models/leaderboard.go
Normal file
80
models/leaderboard.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/duke-git/lancet/v2/maputil"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardItem struct {
|
||||||
|
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||||
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
|
||||||
|
Rank uint `json:"rank" gorm:"->"`
|
||||||
|
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
|
||||||
|
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
|
||||||
|
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
|
||||||
|
Key *string `json:"key" gorm:"size:255"` // pointer because nullable
|
||||||
|
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leaderboard []*LeaderboardItem
|
||||||
|
|
||||||
|
func (l Leaderboard) UserIDs() []string {
|
||||||
|
return slice.Unique[string](slice.Map[*LeaderboardItem, string](l, func(i int, item *LeaderboardItem) string {
|
||||||
|
return item.UserID
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
|
||||||
|
return slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
|
||||||
|
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Leaderboard) TopKeys(by uint8) []string {
|
||||||
|
type keyTotal struct {
|
||||||
|
Key string
|
||||||
|
Total time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
totalsMapped := make(map[string]*keyTotal, len(l))
|
||||||
|
|
||||||
|
for _, item := range l {
|
||||||
|
if item.Key == nil || item.By == nil || *item.By != by {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := totalsMapped[*item.Key]; !ok {
|
||||||
|
totalsMapped[*item.Key] = &keyTotal{Key: *item.Key, Total: 0}
|
||||||
|
}
|
||||||
|
totalsMapped[*item.Key].Total += item.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
|
||||||
|
return *item
|
||||||
|
})
|
||||||
|
if err := slice.SortByField(totals, "Total", "desc"); err != nil {
|
||||||
|
return []string{} // TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
return slice.Map[keyTotal, string](totals, func(i int, item keyTotal) string {
|
||||||
|
return item.Key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
|
||||||
|
return Leaderboard(slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
|
||||||
|
return item.UserID == userId
|
||||||
|
})).TopKeys(by)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Leaderboard) LastUpdate() time.Time {
|
||||||
|
lastUpdate := time.Time{}
|
||||||
|
for _, item := range l {
|
||||||
|
if item.CreatedAt.T().After(lastUpdate) {
|
||||||
|
lastUpdate = item.CreatedAt.T()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastUpdate
|
||||||
|
}
|
@ -5,6 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const HtmlType = "text/html; charset=UTF-8"
|
||||||
|
const PlainType = "text/html; charset=UTF-8"
|
||||||
|
|
||||||
type Mail struct {
|
type Mail struct {
|
||||||
From MailAddress
|
From MailAddress
|
||||||
To MailAddresses
|
To MailAddresses
|
||||||
@ -15,13 +18,13 @@ type Mail struct {
|
|||||||
|
|
||||||
func (m *Mail) WithText(text string) *Mail {
|
func (m *Mail) WithText(text string) *Mail {
|
||||||
m.Body = text
|
m.Body = text
|
||||||
m.Type = "text/plain; charset=UTF-8"
|
m.Type = PlainType
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mail) WithHTML(html string) *Mail {
|
func (m *Mail) WithHTML(html string) *Mail {
|
||||||
m.Body = html
|
m.Body = html
|
||||||
m.Type = "text/html; charset=UTF-8"
|
m.Type = HtmlType
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
@ -4,7 +4,7 @@ import "fmt"
|
|||||||
|
|
||||||
type CounterMetric struct {
|
type CounterMetric struct {
|
||||||
Name string
|
Name string
|
||||||
Value int
|
Value int64
|
||||||
Desc string
|
Desc string
|
||||||
Labels Labels
|
Labels Labels
|
||||||
}
|
}
|
||||||
|
16
models/project_label.go
Normal file
16
models/project_label.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ProjectLabelReverseResolver returns all projects for a given label
|
||||||
|
type ProjectLabelReverseResolver func(l string) []string
|
||||||
|
|
||||||
|
type ProjectLabel struct {
|
||||||
|
ID uint `json:"id" gorm:"primary_key"`
|
||||||
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
|
||||||
|
ProjectKey string `json:"project"`
|
||||||
|
Label string `json:"label" gorm:"type:varchar(64)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ProjectLabel) IsValid() bool {
|
||||||
|
return l.ProjectKey != "" && l.Label != ""
|
||||||
|
}
|
10
models/report.go
Normal file
10
models/report.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
User *User
|
||||||
|
Summary *Summary
|
||||||
|
}
|
@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -30,24 +29,29 @@ type Interval struct {
|
|||||||
End time.Time
|
End time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KeyedInterval struct {
|
||||||
|
Interval
|
||||||
|
Key *IntervalKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
|
||||||
type CustomTime time.Time
|
type CustomTime time.Time
|
||||||
|
|
||||||
func (j *CustomTime) MarshalJSON() ([]byte, error) {
|
func (j *CustomTime) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(j.String())
|
return json.Marshal(j.T())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
s := strings.Trim(string(b), "\"")
|
||||||
i, err := strconv.ParseInt(s, 10, 64)
|
ts, err := strconv.ParseFloat(s, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
t := time.Unix(0, int64(ts*1e9)) // ms to ns
|
||||||
*j = CustomTime(t)
|
*j = CustomTime(t)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
|
||||||
func (j *CustomTime) Scan(value interface{}) error {
|
func (j *CustomTime) Scan(value interface{}) error {
|
||||||
var (
|
var (
|
||||||
t time.Time
|
t time.Time
|
||||||
@ -56,13 +60,12 @@ func (j *CustomTime) Scan(value interface{}) error {
|
|||||||
|
|
||||||
switch value.(type) {
|
switch value.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
// with sqlite, some queries (like GetLastByUser()) return dates as strings,
|
||||||
|
// however, most of the time they are returned as time.Time
|
||||||
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||||
}
|
}
|
||||||
case int64:
|
|
||||||
t = time.Unix(0, value.(int64))
|
|
||||||
break
|
|
||||||
case time.Time:
|
case time.Time:
|
||||||
t = value.(time.Time)
|
t = value.(time.Time)
|
||||||
break
|
break
|
||||||
@ -76,18 +79,17 @@ func (j *CustomTime) Scan(value interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CustomTime) Hash() (uint64, error) {
|
|
||||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j CustomTime) Value() (driver.Value, error) {
|
func (j CustomTime) Value() (driver.Value, error) {
|
||||||
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (j *CustomTime) Hash() (uint64, error) {
|
||||||
|
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (j CustomTime) String() string {
|
func (j CustomTime) String() string {
|
||||||
t := time.Time(j)
|
return j.T().String()
|
||||||
return t.Format("2006-01-02 15:04:05.000")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j CustomTime) T() time.Time {
|
func (j CustomTime) T() time.Time {
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NSummaryTypes uint8 = 99
|
NSummaryTypes uint8 = 99
|
||||||
|
SummaryUnknown uint8 = 98
|
||||||
SummaryProject uint8 = 0
|
SummaryProject uint8 = 0
|
||||||
SummaryLanguage uint8 = 1
|
SummaryLanguage uint8 = 1
|
||||||
SummaryEditor uint8 = 2
|
SummaryEditor uint8 = 2
|
||||||
SummaryOS uint8 = 3
|
SummaryOS uint8 = 3
|
||||||
SummaryMachine uint8 = 4
|
SummaryMachine uint8 = 4
|
||||||
|
SummaryLabel uint8 = 5
|
||||||
|
SummaryBranch uint8 = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
const UnknownSummaryKey = "unknown"
|
const UnknownSummaryKey = "unknown"
|
||||||
|
const DefaultProjectLabel = "default"
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
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"`
|
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"`
|
||||||
@ -27,16 +32,19 @@ type Summary struct {
|
|||||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||||
|
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project filter is applied
|
||||||
|
NumHeartbeats int `json:"-" gorm:"default:0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItems []*SummaryItem
|
type SummaryItems []*SummaryItem
|
||||||
|
|
||||||
type SummaryItem struct {
|
type SummaryItem struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
ID uint64 `json:"-" gorm:"primary_key"`
|
||||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
SummaryID uint `json:"-"`
|
SummaryID uint `json:"-" gorm:"size:32"`
|
||||||
Type uint8 `json:"-" gorm:"index:idx_type"`
|
Type uint8 `json:"-" gorm:"index:idx_type"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key" gorm:"size:255"`
|
||||||
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
|
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,28 +53,23 @@ type SummaryItemContainer struct {
|
|||||||
Items []*SummaryItem
|
Items []*SummaryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryViewModel struct {
|
|
||||||
*Summary
|
|
||||||
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 {
|
type SummaryParams struct {
|
||||||
From time.Time
|
From time.Time
|
||||||
To time.Time
|
To time.Time
|
||||||
User *User
|
User *User
|
||||||
|
Filters *Filters
|
||||||
Recompute bool
|
Recompute bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AliasResolver func(t uint8, k string) string
|
|
||||||
|
|
||||||
func SummaryTypes() []uint8 {
|
func SummaryTypes() []uint8 {
|
||||||
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel, SummaryBranch}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NativeSummaryTypes() []uint8 {
|
||||||
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryBranch}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PersistedSummaryTypes() []uint8 {
|
||||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +79,8 @@ func (s *Summary) Sorted() *Summary {
|
|||||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||||
sort.Sort(sort.Reverse(s.Languages))
|
sort.Sort(sort.Reverse(s.Languages))
|
||||||
sort.Sort(sort.Reverse(s.Editors))
|
sort.Sort(sort.Reverse(s.Editors))
|
||||||
|
sort.Sort(sort.Reverse(s.Labels))
|
||||||
|
sort.Sort(sort.Reverse(s.Branches))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,14 +95,48 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
|||||||
SummaryEditor: &s.Editors,
|
SummaryEditor: &s.Editors,
|
||||||
SummaryOS: &s.OperatingSystems,
|
SummaryOS: &s.OperatingSystems,
|
||||||
SummaryMachine: &s.Machines,
|
SummaryMachine: &s.Machines,
|
||||||
|
SummaryLabel: &s.Labels,
|
||||||
|
SummaryBranch: &s.Branches,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
|
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
|
||||||
return s.MappedItems()[summaryType]
|
switch summaryType {
|
||||||
|
case SummaryProject:
|
||||||
|
return &s.Projects
|
||||||
|
case SummaryLanguage:
|
||||||
|
return &s.Languages
|
||||||
|
case SummaryEditor:
|
||||||
|
return &s.Editors
|
||||||
|
case SummaryOS:
|
||||||
|
return &s.OperatingSystems
|
||||||
|
case SummaryMachine:
|
||||||
|
return &s.Machines
|
||||||
|
case SummaryLabel:
|
||||||
|
return &s.Labels
|
||||||
|
case SummaryBranch:
|
||||||
|
return &s.Branches
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Augments the summary in a way that at least one item is present for every type.
|
func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {
|
||||||
|
if len(types) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range SummaryTypes() {
|
||||||
|
if keep, ok := types[t]; !keep || !ok {
|
||||||
|
*s.ItemsByType(t) = []*SummaryItem{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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,
|
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"
|
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||||
for the missing type.
|
for the missing type.
|
||||||
@ -108,7 +147,7 @@ of time than the other ones.
|
|||||||
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||||
*/
|
*/
|
||||||
func (s *Summary) FillUnknown() {
|
func (s *Summary) FillMissing() {
|
||||||
types := s.Types()
|
types := s.Types()
|
||||||
typeItems := s.MappedItems()
|
typeItems := s.MappedItems()
|
||||||
missingTypes := make([]uint8, 0)
|
missingTypes := make([]uint8, 0)
|
||||||
@ -124,15 +163,46 @@ func (s *Summary) FillUnknown() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timeSum := s.TotalTime()
|
|
||||||
|
|
||||||
// construct dummy item for all missing types
|
// construct dummy item for all missing types
|
||||||
|
presentType, err := s.findFirstPresentType()
|
||||||
|
if err != nil {
|
||||||
|
return // all types are either zero or missing entirely, nothing to fill
|
||||||
|
}
|
||||||
for _, t := range missingTypes {
|
for _, t := range missingTypes {
|
||||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
s.FillBy(presentType, t)
|
||||||
Type: t,
|
}
|
||||||
Key: UnknownSummaryKey,
|
}
|
||||||
Total: timeSum,
|
|
||||||
})
|
// inplace!
|
||||||
|
func (s *Summary) FillBy(fromType uint8, toType uint8) {
|
||||||
|
typeItems := s.MappedItems()
|
||||||
|
totalWanted := s.TotalTimeBy(fromType)
|
||||||
|
totalActual := s.TotalTimeBy(toType)
|
||||||
|
|
||||||
|
key := UnknownSummaryKey
|
||||||
|
if toType == SummaryLabel {
|
||||||
|
key = DefaultProjectLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
existingEntryIdx := -1
|
||||||
|
for i, item := range *typeItems[toType] {
|
||||||
|
if item.Key == key {
|
||||||
|
existingEntryIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := (totalWanted - totalActual) / time.Second // workaround
|
||||||
|
if total > 0 {
|
||||||
|
if existingEntryIdx >= 0 {
|
||||||
|
(*typeItems[toType])[existingEntryIdx].Total = total
|
||||||
|
} else {
|
||||||
|
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
|
||||||
|
Type: toType,
|
||||||
|
Key: key,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,14 +210,12 @@ func (s *Summary) TotalTime() time.Duration {
|
|||||||
var timeSum time.Duration
|
var timeSum time.Duration
|
||||||
|
|
||||||
mappedItems := s.MappedItems()
|
mappedItems := s.MappedItems()
|
||||||
// calculate total duration from any of the present sets of items
|
t, err := s.findFirstPresentType()
|
||||||
for _, t := range s.Types() {
|
if err != nil {
|
||||||
if items := mappedItems[t]; len(*items) > 0 {
|
return 0
|
||||||
for _, item := range *items {
|
}
|
||||||
timeSum += item.Total
|
for _, item := range *mappedItems[t] {
|
||||||
}
|
timeSum += item.Total
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeSum * time.Second
|
return timeSum * time.Second
|
||||||
@ -176,16 +244,41 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur
|
|||||||
return timeSum
|
return timeSum
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
|
func (s *Summary) TotalTimeByFilter(filter FilterElement) time.Duration {
|
||||||
do, typeId, key := filters.One()
|
var total time.Duration
|
||||||
if do {
|
for _, f := range filter.filter {
|
||||||
return s.TotalTimeByKey(typeId, key)
|
total += s.TotalTimeByKey(filter.entity, f)
|
||||||
}
|
}
|
||||||
return 0
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) MaxBy(entityType uint8) *SummaryItem {
|
||||||
|
var max *SummaryItem
|
||||||
|
mappedItems := s.MappedItems()
|
||||||
|
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||||
|
for _, item := range *items {
|
||||||
|
if max == nil || item.Total > max.Total {
|
||||||
|
max = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) MaxByToString(entityType uint8) string {
|
||||||
|
max := s.MaxBy(entityType)
|
||||||
|
if max == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return max.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||||
|
if origin == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
target := make([]*SummaryItem, 0)
|
target := make([]*SummaryItem, 0)
|
||||||
|
|
||||||
findItem := func(key string) *SummaryItem {
|
findItem := func(key string) *SummaryItem {
|
||||||
@ -230,10 +323,47 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
|||||||
s.Languages = processAliases(s.Languages)
|
s.Languages = processAliases(s.Languages)
|
||||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||||
s.Machines = processAliases(s.Machines)
|
s.Machines = processAliases(s.Machines)
|
||||||
|
s.Labels = processAliases(s.Labels)
|
||||||
|
s.Branches = processAliases(s.Branches)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Summary) findFirstPresentType() (uint8, error) {
|
||||||
|
for _, t := range s.Types() {
|
||||||
|
if s.TotalTimeBy(t) != 0 {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 127, errors.New("no type present")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SummaryParams) HasFilters() bool {
|
||||||
|
return s.Filters != nil && !s.Filters.IsEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SummaryParams) IsProjectDetails() bool {
|
||||||
|
if !s.HasFilters() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, entity, filters := s.Filters.One()
|
||||||
|
return entity == SummaryProject && len(filters) == 1 // exactly one
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SummaryParams) GetProjectFilter() string {
|
||||||
|
if !s.IsProjectDetails() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_, _, filters := s.Filters.One()
|
||||||
|
return filters[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SummaryItem) TotalFixed() time.Duration {
|
||||||
|
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||||
|
// TODO: fix some day, while migrating persisted summary items
|
||||||
|
return s.Total * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
func (s SummaryItems) Len() int {
|
func (s SummaryItems) Len() int {
|
||||||
return len(s)
|
return len(s)
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSummary_FillUnknown(t *testing.T) {
|
func TestSummary_FillMissing(t *testing.T) {
|
||||||
testDuration := 10 * time.Minute
|
testDuration := 10 * time.Minute
|
||||||
|
|
||||||
sut := &Summary{
|
sut := &Summary{
|
||||||
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
sut.FillUnknown()
|
sut.FillMissing()
|
||||||
|
|
||||||
itemLists := [][]*SummaryItem{
|
itemLists := [][]*SummaryItem{
|
||||||
sut.Machines,
|
sut.Machines,
|
||||||
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
for _, l := range itemLists {
|
for _, l := range itemLists {
|
||||||
assert.Len(t, l, 1)
|
assert.Len(t, l, 1)
|
||||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
||||||
assert.Equal(t, testDuration, l[0].Total)
|
assert.Equal(t, testDuration, l[0].TotalFixed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Len(t, sut.Labels, 1)
|
||||||
|
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
|
||||||
|
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSummary_TotalTimeBy(t *testing.T) {
|
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||||
@ -94,20 +98,13 @@ func TestSummary_TotalTimeByFilters(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specifying filters about multiple entites is not supported at the moment
|
filters1 := NewFiltersWith(SummaryProject, "wakapi").OneOrEmpty()
|
||||||
// as the current, very rudimentary, time calculation logic wouldn't make sense then.
|
filters2 := NewFiltersWith(SummaryLanguage, "Go").OneOrEmpty()
|
||||||
// Evaluating a filter like (project="wakapi", language="go") can only be realized
|
filters3 := FilterElement{}
|
||||||
// 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"}
|
assert.Equal(t, testDuration1, sut.TotalTimeByFilter(filters1))
|
||||||
filters2 := &Filters{Language: "Go"}
|
assert.Equal(t, testDuration3, sut.TotalTimeByFilter(filters2))
|
||||||
filters3 := &Filters{}
|
assert.Zero(t, sut.TotalTimeByFilter(filters3))
|
||||||
|
|
||||||
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) {
|
func TestSummary_WithResolvedAliases(t *testing.T) {
|
||||||
@ -171,6 +168,66 @@ func TestSummary_WithResolvedAliases(t *testing.T) {
|
|||||||
assert.Empty(t, sut.Machines)
|
assert.Empty(t, sut.Machines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSummary_KeepOnly(t *testing.T) {
|
||||||
|
newSummary := func() *Summary {
|
||||||
|
return &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: 10 * time.Minute / time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SummaryProject,
|
||||||
|
Key: "anchr",
|
||||||
|
Total: 10 * time.Minute / time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Languages: []*SummaryItem{
|
||||||
|
{
|
||||||
|
Type: SummaryLanguage,
|
||||||
|
Key: "Go",
|
||||||
|
Total: 10 * time.Minute / time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Editors: []*SummaryItem{
|
||||||
|
{
|
||||||
|
Type: SummaryEditor,
|
||||||
|
Key: "VSCode",
|
||||||
|
Total: 10 * time.Minute / time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut *Summary
|
||||||
|
|
||||||
|
sut = newSummary().KeepOnly(map[uint8]bool{}) // keep all
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||||
|
assert.Equal(t, 20*time.Minute, sut.TotalTime())
|
||||||
|
|
||||||
|
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||||
|
assert.Zero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||||
|
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
|
||||||
|
assert.Equal(t, 20*time.Minute, sut.TotalTime())
|
||||||
|
|
||||||
|
sut = newSummary().KeepOnly(map[uint8]bool{SummaryEditor: true, SummaryLanguage: true})
|
||||||
|
assert.Zero(t, sut.TotalTimeBy(SummaryProject))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||||
|
assert.Equal(t, 10*time.Minute, sut.TotalTime())
|
||||||
|
|
||||||
|
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
|
||||||
|
sut.FillMissing()
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||||
|
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSummaryItems_Sorted(t *testing.T) {
|
func TestSummaryItems_Sorted(t *testing.T) {
|
||||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||||
|
|
||||||
|
@ -1,28 +1,39 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
mailRegex = regexp.MustCompile(MailPattern)
|
mailRegex = regexp.MustCompile(MailPattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id" gorm:"primary_key"`
|
ID string `json:"id" gorm:"primary_key"`
|
||||||
ApiKey string `json:"api_key" gorm:"unique"`
|
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||||
Email string `json:"email" gorm:"uniqueIndex:idx_user_email"`
|
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||||
Password string `json:"-"`
|
Location string `json:"location"`
|
||||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
Password string `json:"-"`
|
||||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
ShareDataMaxDays int `json:"-" gorm:"default:0"`
|
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
ShareDataMaxDays int `json:"-" gorm:"default:0"`
|
||||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
WakatimeApiKey string `json:"-"`
|
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ResetToken string `json:"-"`
|
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||||
|
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||||
|
ResetToken string `json:"-"`
|
||||||
|
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login struct {
|
type Login struct {
|
||||||
@ -35,6 +46,7 @@ type Signup struct {
|
|||||||
Email string `schema:"email"`
|
Email string `schema:"email"`
|
||||||
Password string `schema:"password"`
|
Password string `schema:"password"`
|
||||||
PasswordRepeat string `schema:"password_repeat"`
|
PasswordRepeat string `schema:"password_repeat"`
|
||||||
|
Location string `schema:"location"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordRequest struct {
|
type SetPasswordRequest struct {
|
||||||
@ -54,7 +66,10 @@ type CredentialsReset struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserDataUpdate struct {
|
type UserDataUpdate struct {
|
||||||
Email string `schema:"email"`
|
Email string `schema:"email"`
|
||||||
|
Location string `schema:"location"`
|
||||||
|
ReportsWeekly bool `schema:"reports_weekly"`
|
||||||
|
PublicLeaderboard bool `schema:"public_leaderboard"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeByUser struct {
|
type TimeByUser struct {
|
||||||
@ -67,6 +82,44 @@ type CountByUser struct {
|
|||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) TZ() *time.Location {
|
||||||
|
if u.Location == "" {
|
||||||
|
u.Location = "Local"
|
||||||
|
}
|
||||||
|
tz, err := time.LoadLocation(u.Location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
return tz
|
||||||
|
}
|
||||||
|
|
||||||
|
// TZOffset returns the time difference between the user's current time zone and UTC
|
||||||
|
// TODO: is this actually working??
|
||||||
|
func (u *User) TZOffset() time.Duration {
|
||||||
|
_, offset := time.Now().In(u.TZ()).Zone()
|
||||||
|
return time.Duration(offset * int(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) AvatarURL(urlTemplate string) string {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{username}", u.ID)
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{email}", u.Email)
|
||||||
|
if strings.Contains(urlTemplate, "{username_hash}") {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{username_hash}", fmt.Sprintf("%x", md5.Sum([]byte(u.ID))))
|
||||||
|
}
|
||||||
|
if strings.Contains(urlTemplate, "{email_hash}") {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{email_hash}", fmt.Sprintf("%x", md5.Sum([]byte(u.Email))))
|
||||||
|
}
|
||||||
|
return urlTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// WakaTimeURL returns the user's effective WakaTime URL, i.e. a custom one (which could also point to another Wakapi instance) or fallback if not specified otherwise.
|
||||||
|
func (u *User) WakaTimeURL(fallback string) string {
|
||||||
|
if u.WakatimeApiUrl != "" {
|
||||||
|
return strings.TrimSuffix(u.WakatimeApiUrl, "/")
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
@ -85,7 +138,7 @@ func (s *Signup) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserDataUpdate) IsValid() bool {
|
func (r *UserDataUpdate) IsValid() bool {
|
||||||
return ValidateEmail(r.Email)
|
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateUsername(username string) bool {
|
func ValidateUsername(username string) bool {
|
||||||
@ -99,3 +152,8 @@ func ValidatePassword(password string) bool {
|
|||||||
func ValidateEmail(email string) bool {
|
func ValidateEmail(email string) bool {
|
||||||
return email == "" || mailRegex.Match([]byte(email))
|
return email == "" || mailRegex.Match([]byte(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateTimezone(tz string) bool {
|
||||||
|
_, err := time.LoadLocation(tz)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
20
models/user_test.go
Normal file
20
models/user_test.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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")
|
||||||
|
_, offset1 := time.Now().Zone()
|
||||||
|
_, offset2 := time.Now().In(pst).Zone()
|
||||||
|
|
||||||
|
assert.Equal(t, time.Local, sut1.TZ())
|
||||||
|
assert.Equal(t, pst, sut2.TZ())
|
||||||
|
|
||||||
|
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||||
|
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
|
||||||
|
}
|
@ -1,10 +1,16 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
|
type Newsbox struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
type HomeViewModel struct {
|
type HomeViewModel struct {
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
TotalHours int
|
TotalHours int
|
||||||
TotalUsers int
|
TotalUsers int
|
||||||
|
Newsbox *Newsbox
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||||
|
76
models/view/leaderboard.go
Normal file
76
models/view/leaderboard.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardViewModel struct {
|
||||||
|
User *models.User
|
||||||
|
By string
|
||||||
|
Key string
|
||||||
|
Items []*models.LeaderboardItem
|
||||||
|
TopKeys []string
|
||||||
|
UserLanguages map[string][]string
|
||||||
|
ApiKey string
|
||||||
|
Success string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
|
||||||
|
s.Success = m
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
||||||
|
s.Error = m
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, principal *models.User) string {
|
||||||
|
if principal != nil && item.UserID == principal.ID {
|
||||||
|
return "self"
|
||||||
|
}
|
||||||
|
if item.Rank == 1 {
|
||||||
|
return "gold"
|
||||||
|
}
|
||||||
|
if item.Rank == 2 {
|
||||||
|
return "silver"
|
||||||
|
}
|
||||||
|
if item.Rank == 3 {
|
||||||
|
return "bronze"
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LeaderboardViewModel) LangIcon(lang string) string {
|
||||||
|
// https://icon-sets.iconify.design/mdi/
|
||||||
|
langs := map[string]string{
|
||||||
|
"c": "c",
|
||||||
|
"c++": "cpp",
|
||||||
|
"cpp": "cpp",
|
||||||
|
"go": "go",
|
||||||
|
"haskell": "haskell",
|
||||||
|
"html": "html5",
|
||||||
|
"java": "java",
|
||||||
|
"javascript": "javascript",
|
||||||
|
"kotlin": "kotlin",
|
||||||
|
"lua": "lua",
|
||||||
|
"php": "php",
|
||||||
|
"python": "python",
|
||||||
|
"r": "r",
|
||||||
|
"ruby": "ruby",
|
||||||
|
"rust": "rust",
|
||||||
|
"swift": "swift",
|
||||||
|
"typescript": "typescript",
|
||||||
|
}
|
||||||
|
if match, ok := langs[strings.ToLower(lang)]; ok {
|
||||||
|
return "mdi:language-" + match
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LeaderboardViewModel) LastUpdate() time.Time {
|
||||||
|
return models.Leaderboard(s.Items).LastUpdate()
|
||||||
|
}
|
@ -1,9 +1,10 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
type LoginViewModel struct {
|
type LoginViewModel struct {
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
TotalUsers int
|
TotalUsers int
|
||||||
|
AllowSignup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordViewModel struct {
|
type SetPasswordViewModel struct {
|
||||||
|
@ -6,6 +6,9 @@ type SettingsViewModel struct {
|
|||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
|
Labels []*SettingsVMCombinedLabel
|
||||||
|
Projects []string
|
||||||
|
ApiKey string
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
@ -16,6 +19,11 @@ type SettingsVMCombinedAlias struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsVMCombinedLabel struct {
|
||||||
|
Key string
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.Success = m
|
||||||
return s
|
return s
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
|
import "github.com/muety/wakapi/models"
|
||||||
|
|
||||||
type SummaryViewModel struct {
|
type SummaryViewModel struct {
|
||||||
Success string
|
*models.Summary
|
||||||
Error string
|
*models.SummaryParams
|
||||||
|
User *models.User
|
||||||
|
AvatarURL string
|
||||||
|
EditorColors map[string]string
|
||||||
|
LanguageColors map[string]string
|
||||||
|
OSColors map[string]string
|
||||||
|
Error string
|
||||||
|
Success string
|
||||||
|
ApiKey string
|
||||||
|
RawQuery string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||||
|
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:icons && npm run build:tailwind",
|
||||||
|
"build:tailwind": "tailwindcss build -i static/assets/css/app.css -o static/assets/css/app.dist.css --minify",
|
||||||
|
"build:icons": "node scripts/bundle_icons.js",
|
||||||
|
"build:all:compress": "npm run build && npm run compress",
|
||||||
|
"watch": "chokidar \"./views/**/*.html\" \"./static/assets/js/**/*.js\" \"./static/assets/css/**/*.css\" -i \"**/vendor/*\" -i \"**/*.dist.*\" -c \"npm run build\"",
|
||||||
|
"watch:compress": "chokidar \"./views/**/*.html\" \"./static/assets/js/**/*.js\" \"./static/assets/css/**/*.css\" -i \"**/vendor/*\" -i \"**/*.dist.*\" -c \"npm run build:all:compress\"",
|
||||||
|
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/json": "^1.1.444",
|
||||||
|
"@iconify/json-tools": "^1.0.10",
|
||||||
|
"chokidar-cli": "^3.0.0",
|
||||||
|
"tailwindcss": "2.2.19"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "3dcc346d-a9a8-4699-8a52-459eb978b382",
|
"_postman_id": "1043ce31-dc5c-4477-a74a-a29a0e1168b0",
|
||||||
"name": "Wakapi",
|
"name": "Wakapi",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
@ -49,6 +49,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send diagnostics",
|
||||||
|
"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 \"platform\": \"unset\",\n \"architecture\": \"unset\",\n \"plugin\": \"\",\n \"cli_version\": \"unset\",\n \"logs\": \"{\\\"caller\\\":\\\"/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:189\\\",\\\"func\\\":\\\"runCmd\\\",\\\"level\\\":\\\"error\\\",\\\"message\\\":\\\"failed to run command: failed to send heartbeat(s) due to api error: failed to send heartbeats via api client: invalid response status from \\\\\\\"https://bin.muetsch.io/n7jnywu/users/current/heartbeats.bulk\\\\\\\". got: 404, want: 201/202. body: \\\\\\\"\\\\\\\"\\\",\\\"now\\\":\\\"2021-08-07T00:33:26+02:00\\\",\\\"version\\\":\\\"unset\\\"}\\n\",\n \"stacktrace\": \"goroutine 1 [running]:\\nruntime/debug.Stack(0x0, 0xc0001f8680, 0x196)\\n\\t/opt/go/src/runtime/debug/stack.go:24 +0x9f\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.runCmd(0xc000103680, 0xc33c60, 0x0)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:194 +0x26c\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.RunCmdWithOfflineSync(0xc000103680, 0xc33c60)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:163 +0x35\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.Run(0xc0000be2c0, 0xc000103680)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:90 +0x62e\\ngithub.com/wakatime/wakatime-cli/cmd.NewRootCMD.func1(0xc0000be2c0, 0xc00028bd40, 0x0, 0x2)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:31 +0x34\\ngithub.com/spf13/cobra.(*Command).execute(0xc0000be2c0, 0xc000020190, 0x2, 0x2, 0xc0000be2c0, 0xc000020190)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:854 +0x2c2\\ngithub.com/spf13/cobra.(*Command).ExecuteC(0xc0000be2c0, 0xc000000180, 0xc0006bff78, 0x407d65)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:958 +0x375\\ngithub.com/spf13/cobra.(*Command).Execute(...)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:895\\ngithub.com/wakatime/wakatime-cli/cmd.Execute()\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:227 +0x2b\\nmain.main()\\n\\t/home/ferdinand/dev/wakatime-cli/main.go:6 +0x25\\n\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/api/plugins/errors",
|
||||||
|
"host": [
|
||||||
|
"{{BASE_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"plugins",
|
||||||
|
"errors"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -201,6 +245,41 @@
|
|||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get heartbeats",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Basic {{TOKEN}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2021-02-10",
|
||||||
|
"host": [
|
||||||
|
"{{BASE_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"compat",
|
||||||
|
"wakatime",
|
||||||
|
"v1",
|
||||||
|
"users",
|
||||||
|
"current",
|
||||||
|
"heartbeats"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "2021-02-10"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Get stats",
|
"name": "Get stats",
|
||||||
"request": {
|
"request": {
|
||||||
@ -298,6 +377,36 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get statusbar",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Basic {{TOKEN}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/statusbar/today",
|
||||||
|
"host": [
|
||||||
|
"{{BASE_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"compat",
|
||||||
|
"wakatime",
|
||||||
|
"v1",
|
||||||
|
"users",
|
||||||
|
"current",
|
||||||
|
"statusbar",
|
||||||
|
"today"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,19 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
|
|||||||
return &AliasRepository{db: db}
|
return &AliasRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
|
||||||
|
var aliases []*models.Alias
|
||||||
|
if err := r.db.Find(&aliases).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{UserID: userId}).
|
Where(&models.Alias{UserID: userId}).
|
||||||
Find(&aliases).Error; err != nil {
|
Find(&aliases).Error; err != nil {
|
||||||
@ -26,6 +37,9 @@ func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
@ -39,6 +53,9 @@ func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias,
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
@ -53,6 +70,9 @@ func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
||||||
alias := &models.Alias{}
|
alias := &models.Alias{}
|
||||||
|
if userId == "" {
|
||||||
|
return nil, errors.New("invalid input")
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
|
18
repositories/diagnostics.go
Normal file
18
repositories/diagnostics.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiagnosticsRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiagnosticsRepository(db *gorm.DB) *DiagnosticsRepository {
|
||||||
|
return &DiagnosticsRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DiagnosticsRepository) Insert(diagnostics *models.Diagnostics) (*models.Diagnostics, error) {
|
||||||
|
return diagnostics, r.db.Create(diagnostics).Error
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@ -8,11 +9,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HeartbeatRepository struct {
|
type HeartbeatRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
config *conf.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||||
return &HeartbeatRepository{db: db}
|
return &HeartbeatRepository{config: conf.Get(), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use with caution!!
|
||||||
|
func (r *HeartbeatRepository) GetAll() ([]*models.Heartbeat, error) {
|
||||||
|
var heartbeats []*models.Heartbeat
|
||||||
|
if err := r.db.Find(&heartbeats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return heartbeats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||||
@ -26,6 +37,18 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HeartbeatRepository) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
|
||||||
|
var heartbeat models.Heartbeat
|
||||||
|
if err := r.db.
|
||||||
|
Model(&models.Heartbeat{}).
|
||||||
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
|
Order("time desc").
|
||||||
|
First(&heartbeat).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &heartbeat, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
|
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
|
||||||
var heartbeat models.Heartbeat
|
var heartbeat models.Heartbeat
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
@ -42,11 +65,12 @@ func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||||
|
// https://stackoverflow.com/a/20765152/3112139
|
||||||
var heartbeats []*models.Heartbeat
|
var heartbeats []*models.Heartbeat
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Heartbeat{UserID: user.ID}).
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
Where("time >= ?", from).
|
Where("time >= ?", from.Local()).
|
||||||
Where("time < ?", to).
|
Where("time < ?", to.Local()).
|
||||||
Order("time asc").
|
Order("time asc").
|
||||||
Find(&heartbeats).Error; err != nil {
|
Find(&heartbeats).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -54,6 +78,26 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
|||||||
return heartbeats, nil
|
return heartbeats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *models.User, filterMap map[string][]string) ([]*models.Heartbeat, error) {
|
||||||
|
// https://stackoverflow.com/a/20765152/3112139
|
||||||
|
var heartbeats []*models.Heartbeat
|
||||||
|
|
||||||
|
q := r.db.
|
||||||
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
|
Where("time >= ?", from.Local()).
|
||||||
|
Where("time < ?", to.Local()).
|
||||||
|
Order("time asc")
|
||||||
|
|
||||||
|
for col, vals := range filterMap {
|
||||||
|
q = q.Where(col+" in ?", vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Find(&heartbeats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return heartbeats, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
var result []*models.TimeByUser
|
var result []*models.TimeByUser
|
||||||
r.db.Model(&models.User{}).
|
r.db.Model(&models.User{}).
|
||||||
@ -74,12 +118,19 @@ func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) Count() (int64, error) {
|
func (r *HeartbeatRepository) Count(approximate bool) (count int64, err error) {
|
||||||
var count int64
|
if r.config.Db.IsMySQL() && approximate {
|
||||||
if err := r.db.
|
err = r.db.Table("information_schema.tables").
|
||||||
Model(&models.Heartbeat{}).
|
Select("table_rows").
|
||||||
Count(&count).Error; err != nil {
|
Where("table_schema = ?", r.config.Db.Name).
|
||||||
return 0, err
|
Where("table_name = 'heartbeats'").
|
||||||
|
Scan(&count).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
err = r.db.
|
||||||
|
Model(&models.Heartbeat{}).
|
||||||
|
Count(&count).Error
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
@ -103,21 +154,46 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
|||||||
userIds[i] = u.ID
|
userIds[i] = u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(userIds) == 0 {
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Model(&models.User{}).
|
Model(&models.Heartbeat{}).
|
||||||
Select("users.id as user, count(heartbeats.id) as count").
|
Select("user_id as user, count(id) as count").
|
||||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
|
||||||
Where("user_id in ?", userIds).
|
Where("user_id in ?", userIds).
|
||||||
Group("user").
|
Group("user").
|
||||||
Find(&counts).Error; err != nil {
|
Find(&counts).Error; err != nil {
|
||||||
return counts, err
|
return counts, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||||
|
var results []string
|
||||||
|
if err := r.db.
|
||||||
|
Model(&models.Heartbeat{}).
|
||||||
|
Distinct(models.GetEntityColumn(entityType)).
|
||||||
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
|
Find(&results).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where("time <= ?", t).
|
Where("time <= ?", t.Local()).
|
||||||
|
Delete(models.Heartbeat{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
|
||||||
|
if err := r.db.
|
||||||
|
Where("user_id = ?", user.ID).
|
||||||
Delete(models.Heartbeat{}).Error; err != nil {
|
Delete(models.Heartbeat{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
@ -15,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
|||||||
return &KeyValueRepository{db: db}
|
return &KeyValueRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *KeyValueRepository) GetAll() ([]*models.KeyStringValue, error) {
|
||||||
|
var keyValues []*models.KeyStringValue
|
||||||
|
if err := r.db.Find(&keyValues).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keyValues, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
||||||
kv := &models.KeyStringValue{}
|
kv := &models.KeyStringValue{}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
|
@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
|
|||||||
return &LanguageMappingRepository{config: config.Get(), db: db}
|
return &LanguageMappingRepository{config: config.Get(), db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *LanguageMappingRepository) GetAll() ([]*models.LanguageMapping, error) {
|
||||||
|
var mappings []*models.LanguageMapping
|
||||||
|
if err := r.db.Find(&mappings).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mappings, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
||||||
mapping := &models.LanguageMapping{}
|
mapping := &models.LanguageMapping{}
|
||||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||||
@ -26,6 +34,9 @@ func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, e
|
|||||||
|
|
||||||
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||||
var mappings []*models.LanguageMapping
|
var mappings []*models.LanguageMapping
|
||||||
|
if userId == "" {
|
||||||
|
return mappings, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.LanguageMapping{UserID: userId}).
|
Where(&models.LanguageMapping{UserID: userId}).
|
||||||
Find(&mappings).Error; err != nil {
|
Find(&mappings).Error; err != nil {
|
||||||
|
81
repositories/leaderboard.go
Normal file
81
repositories/leaderboard.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
|
||||||
|
return &LeaderboardRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) InsertBatch(items []*models.LeaderboardItem) error {
|
||||||
|
if err := r.db.
|
||||||
|
Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.
|
||||||
|
Table("leaderboard_items").
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
||||||
|
// TODO: distinct by (user, key) to filter out potential duplicates ?
|
||||||
|
var items []*models.LeaderboardItem
|
||||||
|
q := r.db.
|
||||||
|
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||||
|
Where("\"interval\" in ?", *key)
|
||||||
|
q = utils.WhereNullable(q, "\"by\"", by)
|
||||||
|
|
||||||
|
if err := q.Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
||||||
|
var items []*models.LeaderboardItem
|
||||||
|
q := r.db.
|
||||||
|
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Where("\"interval\" in ?", *key)
|
||||||
|
q = utils.WhereNullable(q, "\"by\"", by)
|
||||||
|
|
||||||
|
if err := q.Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) DeleteByUser(userId string) error {
|
||||||
|
if err := r.db.
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Delete(models.LeaderboardItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *models.IntervalKey) error {
|
||||||
|
if err := r.db.
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Where("\"interval\" in ?", *key).
|
||||||
|
Delete(models.LeaderboardItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
43
repositories/metrics.go
Normal file
43
repositories/metrics.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetricsRepository struct {
|
||||||
|
config *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeTplMysql = `
|
||||||
|
SELECT SUM(data_length + index_length)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = ?
|
||||||
|
GROUP BY table_schema`
|
||||||
|
|
||||||
|
const sizeTplPostgres = `SELECT pg_database_size('%s');`
|
||||||
|
|
||||||
|
const sizeTplSqlite = `
|
||||||
|
SELECT page_count * page_size as size
|
||||||
|
FROM pragma_page_count(), pragma_page_size();`
|
||||||
|
|
||||||
|
func NewMetricsRepository(db *gorm.DB) *MetricsRepository {
|
||||||
|
return &MetricsRepository{config: config.Get(), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *MetricsRepository) GetDatabaseSize() (size int64, err error) {
|
||||||
|
cfg := srv.config.Db
|
||||||
|
|
||||||
|
query := srv.db.Raw("SELECT 0")
|
||||||
|
if cfg.IsMySQL() {
|
||||||
|
query = srv.db.Raw(sizeTplMysql, cfg.Name)
|
||||||
|
} else if cfg.IsPostgres() {
|
||||||
|
query = srv.db.Raw(sizeTplPostgres, cfg.Name)
|
||||||
|
} else if cfg.IsSQLite() {
|
||||||
|
query = srv.db.Raw(sizeTplSqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = query.Scan(&size).Error
|
||||||
|
return size, err
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user