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

Compare commits

...

138 Commits

Author SHA1 Message Date
e2f046a83d fix: add migration for newly introduced has data field 2021-02-13 13:04:47 +01:00
30510591eb feat: custom time intervals (resolve #115) 2021-02-13 12:59:59 +01:00
daf67b844a refctor: change active users query 2021-02-13 11:23:58 +01:00
6b0b3bddda fix: include overall total number of heartbeats again 2021-02-12 23:16:20 +01:00
ef17d06763 fix: tests [ci skip] 2021-02-12 23:10:25 +01:00
301cab4be4 feat: per-user heartbeats count metrics 2021-02-12 23:06:48 +01:00
703805412b chore: code smell [ci skip] 2021-02-12 19:26:23 +01:00
88eb68b1a9 feat: add prometheus metrics without external standalone exporter 2021-02-12 18:50:13 +01:00
8191a52ce1 chore: make very first user have admin privileges 2021-02-12 18:49:47 +01:00
5b3e88247e chore: introduce user admin flag 2021-02-12 18:13:49 +01:00
59b85863cc chore: accept bearer prefix in auth header 2021-02-12 18:12:46 +01:00
22fbfceca2 fix: support default range for stats endpoint (resolve #125) 2021-02-12 11:25:21 +01:00
4d7fc6bff9 fix: commit missing asset 2021-02-12 10:28:31 +01:00
218c571859 feat: display setup instructions on startup (resolve #120) 2021-02-12 10:10:44 +01:00
e4c413a33c fix: include machine names when importing wakatime data 2021-02-10 22:08:00 +01:00
66b01c2797 docs: update readme toc 2021-02-07 17:30:14 +01:00
0cee7496e0 fix(ui): convert logo text to path (resolve #121) 2021-02-07 17:29:26 +01:00
e571e5266d fix: dockerfile syntax error 2021-02-07 12:58:24 +01:00
b0480356de chore: exclude static assets from request logging 2021-02-07 12:50:02 +01:00
b1c1f14e35 Merge branch 'master' of github.com:muety/wakapi 2021-02-07 12:38:12 +01:00
9e5847b66d fix: json marshalling of custom time (resolve #117) 2021-02-07 12:37:51 +01:00
bb1d6c048d feat: serve swagger ui
fix: forbid to browse file system index
2021-02-07 12:28:42 +01:00
8fc39f23fa feat: add swagger docs (resolve #40) 2021-02-07 11:54:41 +01:00
97a2fadf92 Merge pull request #112 from muety/87-wakatime-data-import
feat: wakatime data import (resolve #87)
2021-02-06 23:47:44 +01:00
6f30272b8c style: card theming 2021-02-06 23:44:11 +01:00
11fbce58d4 fix: consider negative sharing intervals 2021-02-06 23:35:15 +01:00
6d2697ec37 feat: allow unlimited date ranges 2021-02-06 23:23:26 +01:00
2f5973cfa3 chore: add localhost notice to examples 2021-02-06 23:08:22 +01:00
77050f23f2 chore: settings interface for github readme stats 2021-02-06 23:02:35 +01:00
6b1f1c1360 feat: add endpoint compatible to github readme stats (resolve #65) 2021-02-06 23:02:35 +01:00
fca12f522f feat: option to publicly share stats data (resolve #36) 2021-02-06 23:02:35 +01:00
d1dc73b5e6 refactor: make each router handler register middleware on its own 2021-02-06 23:02:35 +01:00
8fed606e9b refactor: make intervals be string lists of aliases 2021-02-06 23:02:35 +01:00
9ff35b85d0 feat: implement stats endpoint (resolve #114) 2021-02-06 23:02:35 +01:00
8ba3fdcaad fix: tests 2021-02-06 23:02:35 +01:00
161e375f74 chore: optimize import date range 2021-02-06 23:02:35 +01:00
da3c80362c docs: update readme 2021-02-06 23:02:35 +01:00
e1906abd38 fix: tests 2021-02-06 23:02:35 +01:00
fd9e2acdf1 feat: wakatime data import (resolve #87) 2021-02-06 23:02:35 +01:00
d9e163bf73 chore: update favicons 2021-02-06 13:23:54 +01:00
3a7f2918f4 style: new logo 2021-02-06 13:04:18 +01:00
d728426b45 chore: add config option to disable user registrations (resolve #113) 2021-02-06 10:59:12 +01:00
22260ceb0d ci: make docker container wait properly for database to come up 2021-02-06 10:43:32 +01:00
38ae41611f fix: 30 days interval 2021-02-06 00:47:44 +01:00
242928aba5 chore: ignore duplicate heartbeat conflicts while inserting 2021-02-06 00:33:20 +01:00
82e9244cdc style: change color palette 2021-02-05 23:42:20 +01:00
aef0c929df fix: wakatime relay 2021-02-05 14:50:00 +01:00
9cb9747e2e fix: missing summary api route 2021-02-03 21:40:01 +01:00
68a17950ef chore: test coverage report 2021-02-03 21:29:12 +01:00
a2368ff76a refactor: significant changes related to routing and general code cleanup 2021-02-03 21:28:02 +01:00
4838300086 refactor: settings routes and actions 2021-02-03 20:53:27 +01:00
a60c725d38 Merge pull request #111 from muety/docker-release
ci(docker): Publish Docker Image on Release
2021-02-03 20:03:15 +01:00
8ceef42ad4 ci(docker): Publish Docker Image on Release 2021-02-03 22:22:37 +11:00
8bed266110 feat: account deletion (#99) 2021-02-02 22:54:22 +01:00
a7afd73e62 chore: require at least one database connection 2021-02-02 22:52:13 +01:00
1dc5be4784 fix: selective summary generation 2021-02-02 22:49:29 +01:00
b6812ddc3a refactor: migrations structure
fix: cascade for alias user foreign key constraint
2021-02-02 21:50:43 +01:00
4f7cc3c57e fix: make logging middleware respect proxy headers 2021-01-31 19:00:42 +01:00
c6139e5366 fix: really fix it now 🤦‍♂️ 2021-01-31 18:56:34 +01:00
28269aa329 fix: start and end parameter parsing for wakatime summary route 2021-01-31 18:41:48 +01:00
b7ae15496d fix: attempt to directly hash struct again 2021-01-31 18:29:50 +01:00
f483488dd5 chore: stop gorm from logging queries 2021-01-31 18:29:24 +01:00
0c3f3b37b0 fix: attempt to quickfix hash collisions 2021-01-31 18:06:20 +01:00
dc1a0c7983 chore: introduce hashes for heartbeats 2021-01-31 17:46:50 +01:00
e4b38d3f51 fix: tests 2021-01-31 16:58:59 +01:00
665ffe8bd1 chore: log request durations (resolve #102) 2021-01-31 16:46:39 +01:00
3e5a51c272 feat: add missing query params to wakatime endpoints (resolve #109) 2021-01-31 16:25:48 +01:00
979549448c chore: remove legacy support for md5 hashed passwords
chore: remove password from encoded cookie content as not used anyway
2021-01-31 14:34:54 +01:00
105f96ff72 chore: get rid of cdn and serve all static assets locally 2021-01-31 14:10:17 +01:00
31013ad986 docs: update readme
docs: mention tinyproxy in advanced setup instructions
2021-01-31 13:59:28 +01:00
db4cb92c26 Merge pull request #107 from YC/legacy-ini
chore: remove legacy config.ini and .env
2021-01-31 13:47:07 +01:00
779108ad88 chore: remove legacy config.ini and .env 2021-01-31 10:51:56 +11:00
61f8a22cff docs: fix readme links and remove duplicated badge 2021-01-30 12:53:51 +01:00
179a107c2a docs: update readme 2021-01-30 12:51:12 +01:00
ef0c76e2ff docs: beautify readme 2021-01-30 12:46:13 +01:00
617d9ad7e4 refactor: include logging framework (resolve #92) 2021-01-30 11:17:37 +01:00
fd239e4f21 chore: add check to validate wakatime api key before accepting it 2021-01-30 10:54:54 +01:00
417d4789ab chore: move route registration into the handler classes themselves (resolve #57) 2021-01-30 10:34:52 +01:00
a6aff07b21 chore: use wakatime colors for editors and os (resolve #100) 2021-01-30 09:51:36 +01:00
b732eea9b7 docs: minor corrections in readme 2021-01-25 08:43:20 +01:00
71d1b2177b fix: missing ca certificates in docker container (resolve #98)
fix: server crash in unsuccessful relaying of heartbeat to wakatime
2021-01-24 21:39:35 +01:00
b2a3579be9 Merge pull request #97 from muety/actions
fix(ci): actions release, build on push/pr
2021-01-24 12:23:05 +01:00
42a6e9d923 fix(ci): actions release, build on push/pr 2021-01-24 22:06:04 +11:00
1f44ccadba docs: update readme with new build instructions 2021-01-24 09:54:54 +01:00
6ea72c6d02 chore: increment patch version number 2021-01-24 09:50:04 +01:00
d93348842a fix: delay defer templateFile.Close() 2021-01-24 10:19:20 +11:00
fb92747129 fix: embed of static, views 2021-01-24 10:13:37 +11:00
4e6e665e19 feat: embed assets into binary
Resolves #26
2021-01-23 10:00:15 +11:00
a3d8c4d464 chore: docs and typos 2021-01-22 00:02:56 +01:00
e9eaa9da53 chore: update version 2021-01-21 23:50:27 +01:00
5adb795f59 chore: include integrity hashes (resolve #93) 2021-01-21 23:50:14 +01:00
a552073d18 feat: ui for configuring wakatime integration 2021-01-21 23:26:50 +01:00
de0401d4bb fix: move caching out of authentication middleware 2021-01-21 23:19:17 +01:00
c39538db13 chore: include machine name in sample data script 2021-01-21 22:17:46 +01:00
189a09d91f feat: relay heartbeats to wakatime (resolve #28) 2021-01-21 22:17:32 +01:00
d57c02af7c feat: add ui for managing aliases (resolve #91) 2021-01-21 00:26:52 +01:00
16b683fcbd fix: permissions bug related to deleting language mappings 2021-01-20 20:49:27 +01:00
acda62488d chore: support for cockroachdb (resolve #90) 2021-01-18 21:37:15 +01:00
1aecfc4ca3 chore: wording 2021-01-17 09:40:14 +01:00
cd97976ed5 chore: show total hours on index page (resolve #88) 2021-01-17 09:32:08 +01:00
3a4504d56a Merge pull request #86 from YC/docker
docker: reduce image layers
2021-01-12 12:27:50 +01:00
a018f70c3f Merge pull request #85 from YC/master
Reduce minimum username length to 1
2021-01-12 12:17:36 +01:00
a03e49e7f0 chore: increment version to 1.18.2 2021-01-12 21:57:15 +11:00
ec81d9fe5d docker: reduce image layers 2021-01-12 21:50:20 +11:00
b7a1e2d795 Reduce minimum username length to 1 2021-01-12 21:06:57 +11:00
98b62b33c8 fix: concurrent access to language mappings (resolve #83) 2021-01-07 10:56:00 +01:00
262bee9022 fix: do not attempt to bind on ipv6 in docker (resolve #84) 2021-01-07 10:55:53 +01:00
9766d8e903 feat: ability to choose number of top entities to display (resolve #81) 2021-01-05 12:41:01 +01:00
39c4777fc8 fix: crash on fail to listen 2021-01-05 11:28:51 +01:00
143c80b7b4 docs: update readme 2021-01-04 11:39:02 +01:00
72e42a9c42 feat: add ipv6 and tls support (resolve #79) 2020-12-12 22:07:00 +01:00
439a87dec9 docs: advanced setup instructions for client-side reverse proxy 2020-12-11 23:05:49 +01:00
e8067bb13e fix: crash when running aggregation job on schedule (fix #78)
chore: move from gocron to its maintained fork
2020-12-11 10:05:17 +01:00
219e969957 Merge branch 'master' of github.com:muety/wakapi into master 2020-12-02 23:16:48 +01:00
e610bb3ee3 fix: html footer rendering
chore: update chartjs
2020-12-02 23:16:12 +01:00
889edd7a33 Merge pull request #77 from notarock/master
Added missing closing parens in language mapping description
2020-12-01 07:48:04 +01:00
4161623c24 Added missing closing parens 2020-11-30 21:42:29 -05:00
67fe6eea56 chore: even more code smell 2020-11-28 20:57:13 +01:00
095fef4868 chore: minor code smell 2020-11-28 20:50:35 +01:00
a0e64ca955 chore: show badges on front page 2020-11-28 20:44:39 +01:00
903defca99 fix: commit missing files
chore: add favicon
2020-11-28 20:31:28 +01:00
16b9aa2282 feat: add front page (resolve #34) 2020-11-28 20:23:40 +01:00
4a78f66778 chore: set samesite attributes and configurable max age for cookies (resolve #75)
fix: sort entities by total time descending (resolve #74)
2020-11-21 22:30:56 +01:00
f4328c452f test: add essential unit tests for core functionality (resolve #6) 2020-11-14 12:30:45 +01:00
e806e5455e chore: attempt to exclude test and mock code from analysis 2020-11-08 13:13:48 +01:00
97e1fb27eb chore: attempt to configure coverage for sonar 2020-11-08 13:07:37 +01:00
ad8168801c test: add first few unit tests 2020-11-08 12:46:12 +01:00
35cdc7b485 refactor: define interface types for all services and repositories 2020-11-08 10:12:49 +01:00
664714de8f fix: filters 2020-11-07 18:39:36 +01:00
7befb82814 chore: remove clean up related parameters 2020-11-07 12:34:17 +01:00
2f12d8efde refactor: simplify summary generation (resolve #68) 2020-11-07 12:01:35 +01:00
8ddd9904a0 refactor: alert handling 2020-11-06 21:19:54 +01:00
78874566a4 chore: introduce constants for db dialects
chore: go fmt
2020-11-06 17:20:26 +01:00
e269b37b0e feat: add ability to regenerate summaries
fix: database cascade settings
chore: debug log mode for gorm queries is back
2020-11-06 17:09:41 +01:00
e6a04cc76d chore: remove cleanup functionality
chore: minor code changes
2020-11-06 14:07:07 +01:00
cb8f68df82 chore: add quick start scripts for spinning up dev database container 2020-11-03 10:32:18 +01:00
b4d2ee7d16 fix: not creating language mappings table due to broken type definition in users model (resolve #69)
chore: introduce foreign key constraints
2020-11-03 10:26:32 +01:00
1224024913 fix: postgres connection (resolve #70) 2020-11-03 10:02:59 +01:00
169 changed files with 10440 additions and 3328 deletions

View File

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

42
.github/workflows/docker.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Publish Docker Image
on:
push:
tags:
- '*.*.*'
- '!*.*.*-*'
jobs:
docker-publish:
runs-on: ubuntu-latest
steps:
# https://stackoverflow.com/questions/58177786
- name: Get version
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to Docker Hub
uses: docker/build-push-action@v2
with:
push: true
tags: |
n1try/wakapi:${{ env.GIT_TAG }}
n1try/wakapi:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache

View File

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

View File

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

6
.gitignore vendored
View File

@ -1,10 +1,10 @@
launch.json
.vscode
.env
wakapi
.idea
build
*.exe
*.db
config.yml
config.ini
config*.yml
!config.default.yml
pkged.go

View File

@ -1,30 +1,36 @@
# Build Stage
FROM golang:1.13 AS build-env
FROM golang:1.15 AS build-env
WORKDIR /src
ADD ./go.mod .
RUN go mod download
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
chmod +x wait-for-it.sh
ADD . .
RUN go build -o wakapi
RUN go generate && go build -o wakapi
# Final Stage
WORKDIR /app
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
cp /src/wait-for-it.sh . && \
cp /src/entrypoint.sh .
# Run Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values using `-e` syntax.
# Available options are:
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD
# WAKAPI_DB_HOST
# WAKAPI_DB_PORT
# WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT
# WAKAPI_BASE_PATH
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM debian
WORKDIR /app
RUN apt update && \
apt install -y ca-certificates
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
@ -32,20 +38,12 @@ ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
ENV WAKAPI_ALLOW_SIGNUP 'true'
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.default.yml /app/config.yml
COPY --from=build-env /src/version.txt /app/
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
ADD static /app/static
ADD data /app/data
ADD migrations /app/migrations
ADD views /app/views
ADD wait-for-it.sh .
COPY --from=build-env /app .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh
ENTRYPOINT ./entrypoint.sh

368
README.md
View File

@ -1,143 +1,331 @@
# 📈 wakapi
<p align="center">
<img src="static/assets/images/logo-gh.svg" width="350">
</p>
![](https://img.shields.io/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
![GitHub issues](https://img.shields.io/github/issues/muety/wakapi)
![GitHub last commit](https://img.shields.io/github/last-commit/muety/wakapi)
[![Say thanks](https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg)](https://saythanks.io/to/n1try)
[![](https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay)](https://liberapay.com/muety/)
![GitHub go.mod Go version](https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc)](https://sonarcloud.io/dashboard?id=muety_wakapi)
---
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
</p>
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
<p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
<h3 align="center">A minimalist, self-hosted WakaTime-compatible backend for coding statistics.</h3>
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!
<div align="center">
<h3>
<a href="https://wakapi.dev">Website</a>
<span> | </span>
<a href="#-features">Features</a>
<span> | </span>
<a href="#-how-to-use">How to use</a>
<span> | </span>
<a href="https://github.com/muety/wakapi/issues">Issues</a>
<span> | </span>
<a href="https://github.com/muety">Contact</a>
</h3>
</div>
## 👀 Hosted Service
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
<p align="center">
<img src="static/assets/images/screenshot.png" width="500px">
</p>
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
## Table of Contents
* [User Survey](#-user-survey)
* [Features](#-features)
* [Roadmap](#-roadmap)
* [How to use](#-how-to-use)
* [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations)
* [Best Practices](#-best-practices)
* [Developer Notes](#-developer-notes)
* [Support](#-support)
* [FAQs](#-faqs)
## ⚙️ Prerequisites
**On the server side:**
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
## 🚀 Features
* ✅ 100 % free and open-source
* ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges
* ✅ REST API
* ✅ Partially compatible with WakaTime
* ✅ WakaTime integration
* ✅ Support for Prometheus exports
* ✅ Lightning fast
* ✅ Self-hosted
## 🚧 Roadmap
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#80](https://github.com/muety/wakapi/issues/80), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
## ⌨️ How to use?
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
If you want to you out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below).
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
### 🐳 Option 2: Use Docker
```bash
# Create a persistent volume
$ docker volume create wakapi-data
# Run the container
$ docker run -d \
-p 3000:3000 \
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
-v wakapi-data:/data \
--name wakapi n1try/wakapi
```
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
### 📦 Option 3: Run a release
```bash
# Download the release and unpack it
$ wget https://github.com/muety/wakapi/releases/download/1.20.2/wakapi_linux_amd64.zip
$ unzip wakapi_linux_amd64.zip
# Optionally adapt config to your needs
$ vi config.yml
# Run it
$ ./wakapi
```
### 🧑‍💻 Option 4: Run from source
#### Prerequisites
* Go >= 1.13 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
* Fedora / RHEL: `dnf install @development-tools`
* Ubuntu / Debian: `apt install build-essential`
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
* _Optional_: A MySQL- or Postgres database
**On your local machine:**
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
#### Compile & Run
```bash
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
## ⌨️ Server Setup
### Run from source
1. Clone the project
1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
1. Build executable: `GO111MODULE=on go build`
1. Run server: `./wakapi`
# Install packaging tool
$ export GO111MODULE=on
$ go get github.com/markbates/pkger/cmd/pkger
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
# Build the executable
$ go generate
$ go build -o 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` to `true` in `config.yml`.
### Run with Docker
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
# Run it
$ ./wakapi
```
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
**Note:** 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`.
## 🔧 Configuration
### 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. **Editing your local `~/.wakatime.cfg`** file as follows
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
```
Optionally, you can set up a [client-side proxy](docs/advanced_setup.md) in addition.
## 🔧 Configuration Options
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key | Environment Variable | Default | Description |
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
| `app.custom_languages` | - | - | Map from file endings to language names |
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of sqlite3, mysql, postgres) |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
## 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
* [SQLite](https://sqlite.org/) (_default, easy setup_)
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. Make your local WakaTime client talk to Wakapi by **editing your local `~/.wakatime.cfg`** file as follows
```
api_url = https://your.server:someport/api/heartbeat`
api_key = the_api_key_printed_to_the_console_after_starting_the_server`
```
You can view your API Key after logging in to the web interface.
## 🔵 Customization
### Aliases
There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects `myapp-frontend` and `myapp-backend` two a common project name `myapp-web` in your statistics, you can add project aliases.
At the moment, this can only be done via raw database queries. For the above example, you would need to add two aliases, like this:
```sql
INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_username', 'myapp-web', 'myapp-frontend');
```
#### Types
* Project ~ type **0**
* Language ~ type **1**
* Editor ~ type **2**
* OS ~ type **3**
* Machine ~ type **4**
### Client-side proxy (`optional`)
See the [advanced setup instructions](docs/advanced_setup.md).
## 🔧 API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
* `POST /api/heartbeat`
* `GET /api/summary`
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
* `GET /api/compat/wakatime/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health`
### Generating Swagger docs
```bash
$ go get -u github.com/swaggo/swag/cmd/swag
$ swag init -o static/docs
```
## ⤴️ Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
## 🤝 Integrations
### Prometheus Export
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
```bash
# 1. Start Wakapi with the feature enabled
$ export WAKAPI_EXPOSE_METRICS=true
$ ./wakapi
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
# 2. Get your API key and hash it
$ echo "<YOUR_API_KEY>" | base64
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
# 3. Add a Prometheus scrape config to your prometheus.yml (see below)
```
## 🏷 Badges
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
#### Scrape config example
```yml
# prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000)
scrape_configs:
- job_name: 'wakapi'
scrape_interval: 1m
metrics_path: '/api/metrics'
bearer_token: '<YOUR_BASE64_HASHED_TOKEN>'
static_configs:
- targets: ['localhost:3000']
```
#### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image)
### WakaTime Integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
### 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.
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## ⚠️ Important Note
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
## 🤓 Developer Notes
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
<details>
<summary><b>What data is sent to Wakapi?</b></summary>
<ul>
<li>File names</li>
<li>Project names</li>
<li>Editor names</li>
<li>You computer's host name</li>
<li>Timestamps for every action you take in your editor</li>
<li>...</li>
</ul>
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
</details>
<details>
<summary><b>What happens if I'm offline?</b></summary>
All data is cached locally on your machine and sent in batches once you're online again.
</details>
<details>
<summary><b>How did Wakapi come about?</b></summary>
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing)">9 $ a month</a> back then. Luckily, most parts of WakaTime are open source!
</details>
<details>
<summary><b>How does Wakapi compare to WakaTime?</b></summary>
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi, include:
<ul>
<li>Leaderboards</li>
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
<li>Personal Goals</li>
<li>Team- / Organization Support</li>
<li>Integrations (with GitLab, etc.)</li>
<li>Richer API</li>
</ul>
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
</details>
<details>
<summary><b>How are durations calculated?</b></summary>
Inferring a measure for your coding time from heartbeats works a bit different than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat, that occurs after a longer pause, with 2 extra minutes.
Here is an example (circles are heartbeats):
```
|---o---o--------------o---o---|
| |10s| 3m |10s| |
```
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen find a solution, but not actually typing code.
<ul>
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
<li><b>WakaTime</b> (with 2 min timeout): 20 sec
<li><b>Wakapi:</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
</ul>
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
</details>
## 🙏 Thanks
I highly appreciate the efforts of [@alanhamlett](https://github.com/alanhamlett) and the WakaTime team and am thankful for their software being open source.
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -1,13 +1,16 @@
env: development
server:
listen_ipv4: 127.0.0.1
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
port: 3000
base_path: /
app:
cleanup: false # only edit, if you know what you're doing
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
custom_languages:
vue: Vue
jsx: JSX
@ -19,8 +22,12 @@ db:
password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
dialect: sqlite3 # mysql, postgres, sqlite3
max_conn: 2
max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
security:
password_salt: # CHANGE !
insecure_cookies: false
insecure_cookies: false # You need to set this to 'true' when on localhost
cookie_max_age: 172800
allow_signup: true
expose_metrics: false

View File

@ -4,8 +4,10 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/markbates/pkger"
"github.com/muety/wakapi/models"
migrate "github.com/rubenv/sql-migrate"
"gorm.io/driver/mysql"
@ -13,33 +15,58 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
)
const (
defaultConfigPath = "config.yml"
defaultConfigPathLegacy = "config.ini"
defaultEnvConfigPathLegacy = ".env"
defaultConfigPath = "config.yml"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3"
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
ErrUnauthorized = "401 unauthorized"
ErrInternalServerError = "500 internal server error"
)
var (
cfg *Config
cFlag *string
const (
WakatimeApiUrl = "https://wakatime.com/api/v1"
WakatimeApiUserUrl = "/users/current"
WakatimeApiAllTimeUrl = "/users/current/all_time_since_today"
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
)
var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct {
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"`
LanguageColors map[string]string `yaml:"-"`
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
}
@ -49,14 +76,19 @@ type dbConfig struct {
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `default:"sqlite3" env:"WAKAPI_DB_TYPE"`
Dialect string `yaml:"-"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type Config struct {
@ -68,26 +100,44 @@ type Config struct {
Server serverConfig
}
func init() {
cFlag = flag.String("c", defaultConfigPath, "config file location")
flag.Parse()
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
}
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
return c.createCookie(name, "", path, -1)
}
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: path,
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
}
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.Alias{})
db.AutoMigrate(&models.Heartbeat{})
db.AutoMigrate(&models.Summary{})
db.AutoMigrate(&models.SummaryItem{})
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.Heartbeat{})
db.AutoMigrate(&models.SummaryItem{})
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.LanguageMapping{})
return nil
}
@ -96,8 +146,8 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/common/fixtures",
migrations := &migrate.HttpFileSystemMigrationSource{
FileSystem: pkger.Dir("/migrations"),
}
migrate.SetIgnoreUnknown(true)
@ -107,24 +157,23 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return err
}
log.Printf("applied %d fixtures\n", n)
logbuch.Info("applied %d fixtures", n)
return nil
}
}
func (c *dbConfig) GetDialector() gorm.Dialector {
switch c.Dialect {
case "mysql":
case SQLDialectMysql:
return mysql.New(mysql.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
})
case "postgres":
case SQLDialectPostgres:
return postgres.New(postgres.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
DSN: postgresConnectionString(c),
})
case "sqlite3":
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
}
return nil
@ -143,12 +192,18 @@ func mysqlConnectionString(config *dbConfig) string {
}
func postgresConnectionString(config *dbConfig) string {
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
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,
)
}
@ -156,45 +211,64 @@ func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.Colors["languages"], true)
}
func (c *appConfig) GetEditorColors() map[string]string {
return cloneStringMap(c.Colors["editors"], true)
}
func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true)
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func readVersion() string {
file, err := os.Open("version.txt")
file, err := pkger.Open("/version.txt")
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
return string(bytes)
return strings.TrimSpace(string(bytes))
}
func readLanguageColors() map[string]string {
func readColors() map[string]map[string]string {
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
// Source:
// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// https://wakatime.com/colors/operating_systems
// - https://wakatime.com/colors/editors
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
// $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
// $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
var colors = make(map[string]map[string]string)
data, err := ioutil.ReadFile("data/colors.json")
file, err := pkger.Open("/data/colors.json")
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
if err := json.Unmarshal(bytes, &colors); err != nil {
logbuch.Fatal(err.Error())
}
return colors
@ -202,12 +276,18 @@ func readLanguageColors() map[string]string {
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
}
return *cFlag
}
func resolveDbDialect(dbType string) string {
if dbType == "cockroach" {
return "postgres"
}
return dbType
}
func Set(config *Config) {
cfg = config
}
@ -219,15 +299,15 @@ func Get() *Config {
func Load() *Config {
config := &Config{}
maybeMigrateLegacyConfig()
flag.Parse()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
log.Fatalf("failed to read config: %v\n", err)
logbuch.Fatal("failed to read config: %v", err)
}
config.Version = readVersion()
config.App.LanguageColors = readLanguageColors()
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
@ -243,6 +323,14 @@ func Load() *Config {
}
}
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
Set(config)
return Get()
}

66
config/config_test.go Normal file
View File

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

View File

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

View File

@ -2,6 +2,7 @@ package config
const (
IndexTemplate = "index.tpl.html"
LoginTemplate = "login.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html"

14
config/utils.go Normal file
View File

@ -0,0 +1,14 @@
package config
import "strings"
func cloneStringMap(m map[string]string, keysToLower bool) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
if keysToLower {
k = strings.ToLower(k)
}
m2[k] = v
}
return m2
}

615
coverage/coverage.out Normal file
View File

@ -0,0 +1,615 @@
mode: set
github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1
github.com/muety/wakapi/models/heartbeat.go:36.65,37.28 1 1
github.com/muety/wakapi/models/heartbeat.go:40.2,41.45 2 1
github.com/muety/wakapi/models/heartbeat.go:44.2,45.44 2 1
github.com/muety/wakapi/models/heartbeat.go:48.2,48.42 1 1
github.com/muety/wakapi/models/heartbeat.go:37.28,39.3 1 1
github.com/muety/wakapi/models/heartbeat.go:41.45,43.3 1 0
github.com/muety/wakapi/models/heartbeat.go:45.44,47.3 1 0
github.com/muety/wakapi/models/heartbeat.go:51.50,52.11 1 1
github.com/muety/wakapi/models/heartbeat.go:65.2,65.15 1 1
github.com/muety/wakapi/models/heartbeat.go:69.2,69.12 1 1
github.com/muety/wakapi/models/heartbeat.go:53.22,54.18 1 1
github.com/muety/wakapi/models/heartbeat.go:55.21,56.17 1 1
github.com/muety/wakapi/models/heartbeat.go:57.23,58.19 1 1
github.com/muety/wakapi/models/heartbeat.go:59.17,60.26 1 1
github.com/muety/wakapi/models/heartbeat.go:61.22,62.18 1 1
github.com/muety/wakapi/models/heartbeat.go:65.15,67.3 1 1
github.com/muety/wakapi/models/heartbeat.go:72.37,88.2 1 0
github.com/muety/wakapi/models/heartbeat.go:96.41,98.16 2 0
github.com/muety/wakapi/models/heartbeat.go:101.2,102.10 2 0
github.com/muety/wakapi/models/heartbeat.go:98.16,100.3 1 0
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/summary.go:68.29,70.2 1 1
github.com/muety/wakapi/models/summary.go:72.37,79.2 6 1
github.com/muety/wakapi/models/summary.go:81.35,83.2 1 1
github.com/muety/wakapi/models/summary.go:85.57,93.2 1 1
github.com/muety/wakapi/models/summary.go:95.64,97.2 1 0
github.com/muety/wakapi/models/summary.go:110.33,115.26 4 1
github.com/muety/wakapi/models/summary.go:122.2,122.37 1 1
github.com/muety/wakapi/models/summary.go:126.2,129.33 2 1
github.com/muety/wakapi/models/summary.go:115.26,116.30 1 1
github.com/muety/wakapi/models/summary.go:116.30,118.4 1 1
github.com/muety/wakapi/models/summary.go:122.37,124.3 1 0
github.com/muety/wakapi/models/summary.go:129.33,135.3 1 1
github.com/muety/wakapi/models/summary.go:138.45,143.30 3 1
github.com/muety/wakapi/models/summary.go:152.2,152.30 1 1
github.com/muety/wakapi/models/summary.go:143.30,144.47 1 1
github.com/muety/wakapi/models/summary.go:144.47,145.32 1 1
github.com/muety/wakapi/models/summary.go:148.4,148.9 1 1
github.com/muety/wakapi/models/summary.go:145.32,147.5 1 1
github.com/muety/wakapi/models/summary.go:155.73,157.55 2 1
github.com/muety/wakapi/models/summary.go:162.2,162.16 1 1
github.com/muety/wakapi/models/summary.go:157.55,158.31 1 1
github.com/muety/wakapi/models/summary.go:158.31,160.4 1 1
github.com/muety/wakapi/models/summary.go:165.88,167.55 2 1
github.com/muety/wakapi/models/summary.go:175.2,175.16 1 1
github.com/muety/wakapi/models/summary.go:167.55,168.31 1 1
github.com/muety/wakapi/models/summary.go:168.31,169.23 1 1
github.com/muety/wakapi/models/summary.go:172.4,172.46 1 1
github.com/muety/wakapi/models/summary.go:169.23,170.13 1 1
github.com/muety/wakapi/models/summary.go:178.70,180.8 2 1
github.com/muety/wakapi/models/summary.go:183.2,183.10 1 1
github.com/muety/wakapi/models/summary.go:180.8,182.3 1 1
github.com/muety/wakapi/models/summary.go:186.71,187.63 1 1
github.com/muety/wakapi/models/summary.go:227.2,233.10 6 1
github.com/muety/wakapi/models/summary.go:187.63,190.45 2 1
github.com/muety/wakapi/models/summary.go:199.3,199.31 1 1
github.com/muety/wakapi/models/summary.go:206.3,206.31 1 1
github.com/muety/wakapi/models/summary.go:223.3,223.16 1 1
github.com/muety/wakapi/models/summary.go:190.45,191.32 1 1
github.com/muety/wakapi/models/summary.go:196.4,196.14 1 1
github.com/muety/wakapi/models/summary.go:191.32,192.24 1 1
github.com/muety/wakapi/models/summary.go:192.24,194.6 1 1
github.com/muety/wakapi/models/summary.go:199.31,201.60 1 1
github.com/muety/wakapi/models/summary.go:201.60,203.5 1 1
github.com/muety/wakapi/models/summary.go:206.31,208.60 1 1
github.com/muety/wakapi/models/summary.go:208.60,209.55 1 1
github.com/muety/wakapi/models/summary.go:209.55,211.6 1 1
github.com/muety/wakapi/models/summary.go:211.11,219.6 1 1
github.com/muety/wakapi/models/summary.go:236.33,238.2 1 1
github.com/muety/wakapi/models/summary.go:240.43,242.2 1 1
github.com/muety/wakapi/models/summary.go:244.38,246.2 1 1
github.com/muety/wakapi/models/user.go:46.43,49.2 1 0
github.com/muety/wakapi/models/user.go:51.33,55.2 1 0
github.com/muety/wakapi/models/user.go:57.45,59.2 1 0
github.com/muety/wakapi/models/user.go:61.45,63.2 1 0
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
github.com/muety/wakapi/models/filters.go:32.47,33.21 1 1
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 1
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 1
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 1
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 1
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 1
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 1
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 1
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
github.com/muety/wakapi/models/interval.go:39.47,40.23 1 0
github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0
github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0
github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/shared.go:35.52,37.2 1 0
github.com/muety/wakapi/models/shared.go:39.52,42.16 3 0
github.com/muety/wakapi/models/shared.go:45.2,47.12 3 0
github.com/muety/wakapi/models/shared.go:42.16,44.3 1 0
github.com/muety/wakapi/models/shared.go:51.52,57.22 2 0
github.com/muety/wakapi/models/shared.go:73.2,76.12 3 0
github.com/muety/wakapi/models/shared.go:58.14,60.17 2 0
github.com/muety/wakapi/models/shared.go:63.13,65.8 2 0
github.com/muety/wakapi/models/shared.go:66.17,68.8 2 0
github.com/muety/wakapi/models/shared.go:69.10,70.64 1 0
github.com/muety/wakapi/models/shared.go:60.17,62.4 1 0
github.com/muety/wakapi/models/shared.go:79.45,81.2 1 0
github.com/muety/wakapi/models/shared.go:83.51,86.2 2 0
github.com/muety/wakapi/models/shared.go:88.37,91.2 2 0
github.com/muety/wakapi/models/shared.go:93.35,95.2 1 0
github.com/muety/wakapi/models/shared.go:97.34,99.2 1 0
github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/utils/common.go:10.48,12.2 1 0
github.com/muety/wakapi/utils/common.go:14.40,16.2 1 0
github.com/muety/wakapi/utils/common.go:18.45,20.2 1 0
github.com/muety/wakapi/utils/common.go:22.24,24.2 1 0
github.com/muety/wakapi/utils/common.go:26.56,29.45 3 1
github.com/muety/wakapi/utils/common.go:32.2,32.40 1 1
github.com/muety/wakapi/utils/common.go:29.45,31.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0
github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
github.com/muety/wakapi/utils/auth.go:37.65,39.85 2 0
github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/utils/auth.go:39.85,41.3 1 0
github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0
github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0
github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0
github.com/muety/wakapi/utils/filesystem.go:16.16,18.3 1 0
github.com/muety/wakapi/utils/filesystem.go:21.15,23.47 2 0
github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0
github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0
github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.66,11.40 1 0
github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0
github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0
github.com/muety/wakapi/utils/summary.go:12.27,14.4 1 0
github.com/muety/wakapi/utils/summary.go:19.67,22.2 2 0
github.com/muety/wakapi/utils/summary.go:24.74,26.16 2 0
github.com/muety/wakapi/utils/summary.go:29.2,29.32 1 0
github.com/muety/wakapi/utils/summary.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/summary.go:32.84,35.18 2 0
github.com/muety/wakapi/utils/summary.go:70.2,70.22 1 0
github.com/muety/wakapi/utils/summary.go:36.28,37.24 1 0
github.com/muety/wakapi/utils/summary.go:38.32,40.22 2 0
github.com/muety/wakapi/utils/summary.go:41.31,42.23 1 0
github.com/muety/wakapi/utils/summary.go:43.31,45.21 2 0
github.com/muety/wakapi/utils/summary.go:46.32,47.24 1 0
github.com/muety/wakapi/utils/summary.go:48.32,50.22 2 0
github.com/muety/wakapi/utils/summary.go:51.31,52.23 1 0
github.com/muety/wakapi/utils/summary.go:53.32,54.42 1 0
github.com/muety/wakapi/utils/summary.go:55.41,57.40 2 0
github.com/muety/wakapi/utils/summary.go:58.33,59.43 1 0
github.com/muety/wakapi/utils/summary.go:60.33,61.43 1 0
github.com/muety/wakapi/utils/summary.go:62.35,63.43 1 0
github.com/muety/wakapi/utils/summary.go:64.26,65.21 1 0
github.com/muety/wakapi/utils/summary.go:66.10,67.39 1 0
github.com/muety/wakapi/utils/summary.go:73.73,80.56 5 0
github.com/muety/wakapi/utils/summary.go:96.2,103.8 2 0
github.com/muety/wakapi/utils/summary.go:80.56,82.3 1 0
github.com/muety/wakapi/utils/summary.go:82.8,82.54 1 0
github.com/muety/wakapi/utils/summary.go:82.54,84.3 1 0
github.com/muety/wakapi/utils/summary.go:84.8,86.17 2 0
github.com/muety/wakapi/utils/summary.go:90.3,91.17 2 0
github.com/muety/wakapi/utils/summary.go:86.17,88.4 1 0
github.com/muety/wakapi/utils/summary.go:91.17,93.4 1 0
github.com/muety/wakapi/config/config.go:103.70,105.2 1 0
github.com/muety/wakapi/config/config.go:107.65,109.2 1 0
github.com/muety/wakapi/config/config.go:111.82,121.2 1 0
github.com/muety/wakapi/config/config.go:123.31,125.2 1 0
github.com/muety/wakapi/config/config.go:127.32,129.2 1 0
github.com/muety/wakapi/config/config.go:131.74,132.19 1 0
github.com/muety/wakapi/config/config.go:133.10,134.34 1 0
github.com/muety/wakapi/config/config.go:134.34,143.4 8 0
github.com/muety/wakapi/config/config.go:147.73,148.33 1 0
github.com/muety/wakapi/config/config.go:148.33,156.17 5 0
github.com/muety/wakapi/config/config.go:160.3,161.13 2 0
github.com/muety/wakapi/config/config.go:156.17,158.4 1 0
github.com/muety/wakapi/config/config.go:165.50,166.19 1 0
github.com/muety/wakapi/config/config.go:179.2,179.12 1 0
github.com/muety/wakapi/config/config.go:167.23,171.5 1 0
github.com/muety/wakapi/config/config.go:172.26,175.5 1 0
github.com/muety/wakapi/config/config.go:176.24,177.48 1 0
github.com/muety/wakapi/config/config.go:182.53,192.2 1 1
github.com/muety/wakapi/config/config.go:194.56,196.16 2 1
github.com/muety/wakapi/config/config.go:200.2,207.3 1 1
github.com/muety/wakapi/config/config.go:196.16,198.3 1 0
github.com/muety/wakapi/config/config.go:210.54,212.2 1 1
github.com/muety/wakapi/config/config.go:214.60,216.2 1 0
github.com/muety/wakapi/config/config.go:218.59,220.2 1 0
github.com/muety/wakapi/config/config.go:222.57,224.2 1 0
github.com/muety/wakapi/config/config.go:226.53,228.2 1 0
github.com/muety/wakapi/config/config.go:230.29,232.2 1 1
github.com/muety/wakapi/config/config.go:234.27,236.16 2 0
github.com/muety/wakapi/config/config.go:239.2,242.16 3 0
github.com/muety/wakapi/config/config.go:246.2,246.41 1 0
github.com/muety/wakapi/config/config.go:236.16,238.3 1 0
github.com/muety/wakapi/config/config.go:242.16,244.3 1 0
github.com/muety/wakapi/config/config.go:249.48,261.16 3 0
github.com/muety/wakapi/config/config.go:264.2,266.16 3 0
github.com/muety/wakapi/config/config.go:270.2,270.55 1 0
github.com/muety/wakapi/config/config.go:274.2,274.15 1 0
github.com/muety/wakapi/config/config.go:261.16,263.3 1 0
github.com/muety/wakapi/config/config.go:266.16,268.3 1 0
github.com/muety/wakapi/config/config.go:270.55,272.3 1 0
github.com/muety/wakapi/config/config.go:277.38,278.43 1 0
github.com/muety/wakapi/config/config.go:281.2,281.15 1 0
github.com/muety/wakapi/config/config.go:278.43,280.3 1 0
github.com/muety/wakapi/config/config.go:284.45,285.27 1 0
github.com/muety/wakapi/config/config.go:288.2,288.15 1 0
github.com/muety/wakapi/config/config.go:285.27,287.3 1 0
github.com/muety/wakapi/config/config.go:291.26,293.2 1 0
github.com/muety/wakapi/config/config.go:295.20,297.2 1 0
github.com/muety/wakapi/config/config.go:299.21,304.96 3 0
github.com/muety/wakapi/config/config.go:308.2,316.52 5 0
github.com/muety/wakapi/config/config.go:320.2,320.47 1 0
github.com/muety/wakapi/config/config.go:326.2,326.70 1 0
github.com/muety/wakapi/config/config.go:330.2,330.28 1 0
github.com/muety/wakapi/config/config.go:334.2,335.14 2 0
github.com/muety/wakapi/config/config.go:304.96,306.3 1 0
github.com/muety/wakapi/config/config.go:316.52,318.3 1 0
github.com/muety/wakapi/config/config.go:320.47,321.14 1 0
github.com/muety/wakapi/config/config.go:321.14,323.4 1 0
github.com/muety/wakapi/config/config.go:326.70,328.3 1 0
github.com/muety/wakapi/config/config.go:330.28,332.3 1 0
github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0
github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0
github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0
github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0
github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:20.91,26.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:28.90,31.2 2 0
github.com/muety/wakapi/middlewares/authenticate.go:33.90,36.2 2 0
github.com/muety/wakapi/middlewares/authenticate.go:38.71,39.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:39.71,41.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.107,48.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:52.2,52.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:68.2,69.29 2 0
github.com/muety/wakapi/middlewares/authenticate.go:48.16,50.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:52.31,53.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.3,58.29 1 0
github.com/muety/wakapi/middlewares/authenticate.go:65.3,65.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:53.31,56.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:58.29,61.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:61.9,64.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:72.70,73.39 1 0
github.com/muety/wakapi/middlewares/authenticate.go:78.2,78.14 1 0
github.com/muety/wakapi/middlewares/authenticate.go:73.39,74.60 1 0
github.com/muety/wakapi/middlewares/authenticate.go:74.60,76.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:81.92,83.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:87.2,90.16 4 1
github.com/muety/wakapi/middlewares/authenticate.go:93.2,93.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:83.16,85.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:90.16,92.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:96.92,98.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:102.2,103.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:98.16,100.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:103.16,105.3 1 0
github.com/muety/wakapi/middlewares/filetype.go:13.83,14.43 1 0
github.com/muety/wakapi/middlewares/filetype.go:14.43,19.3 1 0
github.com/muety/wakapi/middlewares/filetype.go:22.84,24.34 2 0
github.com/muety/wakapi/middlewares/filetype.go:31.2,31.27 1 0
github.com/muety/wakapi/middlewares/filetype.go:24.34,25.50 1 0
github.com/muety/wakapi/middlewares/filetype.go:25.50,29.4 3 0
github.com/muety/wakapi/middlewares/logging.go:19.105,20.43 1 0
github.com/muety/wakapi/middlewares/logging.go:20.43,26.3 1 0
github.com/muety/wakapi/middlewares/logging.go:29.80,38.44 7 0
github.com/muety/wakapi/middlewares/logging.go:44.2,53.3 1 0
github.com/muety/wakapi/middlewares/logging.go:38.44,39.38 1 0
github.com/muety/wakapi/middlewares/logging.go:39.38,41.4 1 0
github.com/muety/wakapi/middlewares/logging.go:56.41,58.14 2 0
github.com/muety/wakapi/middlewares/logging.go:61.2,61.14 1 0
github.com/muety/wakapi/middlewares/logging.go:64.2,64.11 1 0
github.com/muety/wakapi/middlewares/logging.go:58.14,60.3 1 0
github.com/muety/wakapi/middlewares/logging.go:61.14,63.3 1 0
github.com/muety/wakapi/middlewares/logging.go:95.52,97.2 1 0
github.com/muety/wakapi/middlewares/logging.go:109.45,110.20 1 0
github.com/muety/wakapi/middlewares/logging.go:110.20,114.3 3 0
github.com/muety/wakapi/middlewares/logging.go:116.54,119.18 3 0
github.com/muety/wakapi/middlewares/logging.go:126.2,127.15 2 0
github.com/muety/wakapi/middlewares/logging.go:119.18,122.17 2 0
github.com/muety/wakapi/middlewares/logging.go:122.17,124.4 1 0
github.com/muety/wakapi/middlewares/logging.go:129.42,130.20 1 0
github.com/muety/wakapi/middlewares/logging.go:130.20,132.3 1 0
github.com/muety/wakapi/middlewares/logging.go:134.36,136.2 1 0
github.com/muety/wakapi/middlewares/logging.go:137.42,139.2 1 0
github.com/muety/wakapi/middlewares/logging.go:140.40,142.2 1 0
github.com/muety/wakapi/middlewares/logging.go:143.52,145.2 1 0
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.80,31.2 1 0
github.com/muety/wakapi/services/heartbeat.go:33.53,35.2 1 0
github.com/muety/wakapi/services/heartbeat.go:37.76,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.96,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.111,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:50.2,50.43 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:53.116,55.2 1 0
github.com/muety/wakapi/services/heartbeat.go:57.78,59.2 1 0
github.com/muety/wakapi/services/heartbeat.go:61.62,63.2 1 0
github.com/muety/wakapi/services/heartbeat.go:65.116,67.16 2 0
github.com/muety/wakapi/services/heartbeat.go:71.2,71.28 1 0
github.com/muety/wakapi/services/heartbeat.go:75.2,75.24 1 0
github.com/muety/wakapi/services/heartbeat.go:67.16,69.3 1 0
github.com/muety/wakapi/services/heartbeat.go:71.28,73.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.78,27.16 2 0
github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0
github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0
github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0
github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/alias.go:17.77,22.2 1 1
github.com/muety/wakapi/services/alias.go:26.60,27.43 1 1
github.com/muety/wakapi/services/alias.go:30.2,30.14 1 1
github.com/muety/wakapi/services/alias.go:27.43,29.3 1 1
github.com/muety/wakapi/services/alias.go:33.62,35.16 2 1
github.com/muety/wakapi/services/alias.go:38.2,38.12 1 1
github.com/muety/wakapi/services/alias.go:35.16,37.3 1 1
github.com/muety/wakapi/services/alias.go:41.76,43.16 2 0
github.com/muety/wakapi/services/alias.go:46.2,46.21 1 0
github.com/muety/wakapi/services/alias.go:43.16,45.3 1 0
github.com/muety/wakapi/services/alias.go:49.113,51.16 2 0
github.com/muety/wakapi/services/alias.go:54.2,54.21 1 0
github.com/muety/wakapi/services/alias.go:51.16,53.3 1 0
github.com/muety/wakapi/services/alias.go:57.108,58.32 1 1
github.com/muety/wakapi/services/alias.go:64.2,65.46 2 1
github.com/muety/wakapi/services/alias.go:70.2,70.19 1 1
github.com/muety/wakapi/services/alias.go:58.32,59.52 1 1
github.com/muety/wakapi/services/alias.go:59.52,61.4 1 1
github.com/muety/wakapi/services/alias.go:65.46,66.48 1 1
github.com/muety/wakapi/services/alias.go:66.48,68.4 1 1
github.com/muety/wakapi/services/alias.go:73.77,75.16 2 0
github.com/muety/wakapi/services/alias.go:78.2,79.20 2 0
github.com/muety/wakapi/services/alias.go:75.16,77.3 1 0
github.com/muety/wakapi/services/alias.go:82.60,83.24 1 0
github.com/muety/wakapi/services/alias.go:86.2,88.12 3 0
github.com/muety/wakapi/services/alias.go:83.24,85.3 1 0
github.com/muety/wakapi/services/alias.go:91.69,94.28 3 0
github.com/muety/wakapi/services/alias.go:102.2,104.31 2 0
github.com/muety/wakapi/services/alias.go:108.2,108.12 1 0
github.com/muety/wakapi/services/alias.go:94.28,95.21 1 0
github.com/muety/wakapi/services/alias.go:98.3,99.16 2 0
github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0
github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0
github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0
github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
github.com/muety/wakapi/services/summary.go:53.2,53.65 1 1
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
github.com/muety/wakapi/services/summary.go:53.65,55.3 1 0
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.61,62.2 2 0
github.com/muety/wakapi/services/user.go:64.48,66.2 1 0
github.com/muety/wakapi/services/user.go:68.102,76.93 2 0
github.com/muety/wakapi/services/user.go:82.2,82.38 1 0
github.com/muety/wakapi/services/user.go:76.93,78.3 1 0
github.com/muety/wakapi/services/user.go:78.8,80.3 1 0
github.com/muety/wakapi/services/user.go:85.73,88.2 2 0
github.com/muety/wakapi/services/user.go:90.78,94.2 3 0
github.com/muety/wakapi/services/user.go:96.99,99.2 2 0
github.com/muety/wakapi/services/user.go:101.106,104.96 3 0
github.com/muety/wakapi/services/user.go:109.2,109.68 1 0
github.com/muety/wakapi/services/user.go:104.96,106.3 1 0
github.com/muety/wakapi/services/user.go:106.8,108.3 1 0
github.com/muety/wakapi/services/user.go:112.57,115.2 2 0
github.com/muety/wakapi/services/user.go:117.38,119.2 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
github.com/muety/wakapi/services/misc.go:81.24,82.144 1 0
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
github.com/muety/wakapi/services/misc.go:82.144,84.4 1 0
github.com/muety/wakapi/services/misc.go:84.9,90.4 2 0
github.com/muety/wakapi/services/misc.go:91.48,94.4 2 0
github.com/muety/wakapi/services/misc.go:98.86,101.30 3 0
github.com/muety/wakapi/services/misc.go:106.2,109.17 1 0
github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0

File diff suppressed because it is too large Load Diff

47
docs/advanced_setup.md Normal file
View File

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

8
entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
./wakapi
else
echo "Waiting for database to come up"
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
fi

22
go.mod
View File

@ -3,25 +3,31 @@ module github.com/muety/wakapi
go 1.13
require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emvi/logbuch v1.1.1
github.com/go-co-op/gocron v0.3.3
github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/configor v1.2.0
github.com/joho/godotenv v1.3.0
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/markbates/pkger v0.17.1
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/mitchellh/hashstructure/v2 v2.0.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.6.1
github.com/swaggo/swag v1.7.0
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0
gopkg.in/yaml.v2 v2.2.8
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/tools v0.1.0 // indirect
gorm.io/driver/mysql v1.0.3
gorm.io/driver/postgres v1.0.5
gorm.io/driver/sqlite v1.1.3
gorm.io/gorm v1.20.5
gorm.io/gorm v1.20.11
)

111
go.sum
View File

@ -3,11 +3,18 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@ -42,7 +49,9 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -56,6 +65,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@ -63,13 +74,33 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@ -79,6 +110,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
@ -92,7 +125,6 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -204,8 +236,6 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -216,6 +246,8 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -243,6 +275,14 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -268,6 +308,8 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@ -348,7 +390,9 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
@ -358,6 +402,7 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@ -382,18 +427,26 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
@ -405,6 +458,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
@ -433,9 +487,12 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -452,6 +509,13 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -461,6 +525,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -484,10 +550,21 @@ golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -505,13 +582,21 @@ golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -543,17 +628,23 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
@ -562,8 +653,8 @@ gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

277
main.go
View File

@ -1,12 +1,20 @@
package main
//go:generate $GOPATH/bin/pkger
import (
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
"github.com/markbates/pkger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/utils"
"gorm.io/gorm/logger"
"log"
"net/http"
"os"
"strconv"
"time"
@ -28,59 +36,90 @@ var (
)
var (
aliasRepository *repositories.AliasRepository
heartbeatRepository *repositories.HeartbeatRepository
userRepository *repositories.UserRepository
languageMappingRepository *repositories.LanguageMappingRepository
summaryRepository *repositories.SummaryRepository
keyValueRepository *repositories.KeyValueRepository
aliasRepository repositories.IAliasRepository
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
languageMappingService *services.LanguageMappingService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
aliasService services.IAliasService
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
summaryService services.ISummaryService
aggregationService services.IAggregationService
keyValueService services.IKeyValueService
miscService services.IMiscService
)
// TODO: Refactor entire project to be structured after business domains
// @title Wakapi API
// @version 1.0
// @description REST API to interact with [Wakapi](https://wakapi.dev)
// @description
// @description ## Authentication
// @description Set header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`
// @description **Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`
// @contact.name Ferdinand Mütsch
// @contact.url https://github.com/muety
// @contact.email ferdinand@muetsch.io
// @license.name GPL-3.0
// @license.url https://github.com/muety/wakapi/blob/master/LICENSE
// @securitydefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @BasePath /api
func main() {
config = conf.Load()
// Enable line numbers in logging
// Set log level
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
logbuch.SetLevel(logbuch.LevelDebug)
} else {
logbuch.SetLevel(logbuch.LevelInfo)
}
// Show data loss warning
if config.App.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
}
// Set up GORM
gormLogger := logger.New(
log.New(os.Stdout, "", log.LstdFlags),
logger.Config{
SlowThreshold: time.Minute,
Colorful: false,
LogLevel: logger.Silent,
},
)
// Connect to database
var err error
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
}
if config.IsDev() {
db = db.Debug()
}
sqlDb, _ := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
log.Println(err)
log.Fatal("could not connect to database")
logbuch.Error(err.Error())
logbuch.Fatal("could not connect to database")
}
defer sqlDb.Close()
// Migrate database schema
common.RunCustomPreMigrations(db, config)
migrations.RunPreMigrations(db, config)
runDatabaseMigrations()
common.RunCustomPostMigrations(db, config)
migrations.RunCustomPostMigrations(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
@ -98,107 +137,143 @@ func main() {
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Aggregate heartbeats to summaries and persist them
// Schedule background tasks
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
if config.App.CleanUp {
go heartbeatService.ScheduleCleanUp()
}
routes.Init()
// TODO: move endpoint registration to the respective routes files
// API Handlers
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
// Handlers
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, languageMappingService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// Setup Routers
router := mux.NewRouter()
publicRouter := router.PathPrefix("/").Subrouter()
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
// Middlewares
// Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService,
[]string{"/api/health", "/api/compat/shields/v1"},
).Handler
loggingMiddleware := middlewares.NewLoggingMiddleware(
log.New(os.Stdout, "", log.LstdFlags),
[]string{"/assets"},
)
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware)
settingsRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
// Route registrations
homeHandler.RegisterRoutes(rootRouter)
loginHandler.RegisterRoutes(rootRouter)
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCreateLanguageMapping)
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Wakatime compat V1 API Routes
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
// Shields.io compat API Routes
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
// API route registrations
summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: pkger.Dir("/static")})
router.PathPrefix("/assets").Handler(fileServer)
router.PathPrefix("/swagger-ui").Handler(fileServer)
router.PathPrefix("/docs").Handler(
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
)
// Listen HTTP
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s := &http.Server{
Handler: router,
Addr: portString,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
listen(router)
}
func listen(handler http.Handler) {
var s4, s6 *http.Server
// IPv4
if config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{
Handler: handler,
Addr: bindString4,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
log.Printf("Listening on %+s\n", portString)
s.ListenAndServe()
// IPv6
if config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{
Handler: handler,
Addr: bindString6,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
if config.UseTLS() {
if s4 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
if s6 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
} else {
if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
if s6 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
}
<-make(chan interface{}, 1)
}
func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
log.Fatal(err)
}
}
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
logbuch.Fatal(err.Error())
}
}

View File

@ -2,37 +2,39 @@ package middlewares
import (
"context"
"errors"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"strings"
"time"
"github.com/patrickmn/go-cache"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
type AuthenticateMiddleware struct {
config *conf.Config
userSrvc *services.UserService
cache *cache.Cache
whitelistPaths []string
config *conf.Config
userSrvc services.IUserService
optionalForPaths []string
redirectTarget string // optional
}
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
}
}
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMiddleware {
m.optionalForPaths = paths
return m
}
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
m.redirectTarget = path
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
@ -40,13 +42,6 @@ func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
}
func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
for _, p := range m.whitelistPaths {
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
next(w, r)
return
}
}
var user *models.User
user, err := m.tryGetUserByCookie(r)
@ -54,22 +49,35 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
user, err = m.tryGetUserByApiKey(r)
}
if err != nil {
if strings.HasPrefix(r.URL.Path, "/api") {
if err != nil || user == nil {
if m.isOptional(r.URL.Path) {
next(w, r)
return
}
if m.redirectTarget == "" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
} else {
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
}
return
}
m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx))
}
func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
for _, p := range m.optionalForPaths {
if strings.HasPrefix(requestPath, p) || requestPath == p {
return true
}
}
return false
}
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
key, err := utils.ExtractBearerAuth(r)
if err != nil {
@ -78,51 +86,26 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
var user *models.User
userKey := strings.TrimSpace(key)
cachedUser, ok := m.cache.Get(userKey)
if !ok {
user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, err
}
} else {
user = cachedUser.(*models.User)
user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, err
}
return user, nil
}
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
login, err := utils.ExtractCookieAuth(r, m.config)
username, err := utils.ExtractCookieAuth(r, m.config)
if err != nil {
return nil, err
}
cachedUser, ok := m.cache.Get(login.Username)
if ok {
return cachedUser.(*models.User), nil
}
user, err := m.userSrvc.GetUserById(login.Username)
user, err := m.userSrvc.GetUserById(*username)
if err != nil {
return nil, err
}
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) {
return nil, errors.New("invalid password")
}
// no need to check password here, as securecookie decoding will fail anyway,
// if cookie is not properly signed
return user, nil
}
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
if utils.IsMd5(user.Password) {
if utils.CompareMd5(user.Password, login.Password, "") {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login)
return true
}
return false
}
return utils.CompareBcrypt(user.Password, login.Password, salt)
}

View File

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

View File

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

32
middlewares/filetype.go Normal file
View File

@ -0,0 +1,32 @@
package middlewares
import (
"net/http"
"strings"
)
type SuffixFilterMiddleware struct {
handler http.Handler
filterTypes []string
}
func NewFileTypeFilterMiddleware(filter []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &SuffixFilterMiddleware{
handler: h,
filterTypes: filter,
}
}
}
func (f *SuffixFilterMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.ToLower(r.URL.Path)
for _, t := range f.filterTypes {
if strings.HasSuffix(path, strings.ToLower(t)) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("403 forbidden"))
return
}
}
f.handler.ServeHTTP(w, r)
}

View File

@ -1,17 +1,145 @@
package middlewares
// Borrowed from https://gist.github.com/elithrar/887d162dfd0c539b700ab4049c76e22b
import (
"github.com/gorilla/handlers"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
type LoggingMiddleware struct{}
func NewLoggingMiddleware() *LoggingMiddleware {
return &LoggingMiddleware{}
type LoggingMiddleware struct {
handler http.Handler
output *log.Logger
excludePrefixes []string
}
func (m *LoggingMiddleware) Handler(h http.Handler) http.Handler {
return handlers.LoggingHandler(os.Stdout, h)
func NewLoggingMiddleware(output *log.Logger, excludePrefixes []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &LoggingMiddleware{
handler: h,
output: output,
excludePrefixes: excludePrefixes,
}
}
}
func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ww := wrapWriter(w)
start := time.Now()
lg.handler.ServeHTTP(ww, r)
end := time.Now()
duration := end.Sub(start)
path := strings.ToLower(r.URL.Path)
for _, prefix := range lg.excludePrefixes {
if strings.HasPrefix(path, prefix) {
return
}
}
lg.output.Printf(
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n",
time.Now().Format(time.RFC3339Nano),
ww.Status(),
r.Method,
r.URL.String(),
duration,
ww.BytesWritten(),
readUserIP(r),
)
}
func readUserIP(r *http.Request) string {
ip := r.Header.Get("X-Real-Ip")
if ip == "" {
ip = r.Header.Get("X-Forwarded-For")
}
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
// The below writer-wrapping code has been lifted from
// https://github.com/zenazn/goji/blob/master/web/middleware/logger.go - because
// it does exactly what is needed, and it's unlikely to change in any
// significant way that makes copying worse-off than importing. MIT licensed
// and (c) Carl Jackson.
// writerProxy is a proxy around an http.ResponseWriter that allows you to hook
// into various parts of the response process.
type writerProxy interface {
http.ResponseWriter
// Status returns the HTTP status of the request, or 0 if one has not
// yet been sent.
Status() int
// BytesWritten returns the total number of bytes sent to the client.
BytesWritten() int
// Tee causes the response body to be written to the given io.Writer in
// addition to proxying the writes through. Only one io.Writer can be
// tee'd to at once: setting a second one will overwrite the first.
// Writes will be sent to the proxy before being written to this
// io.Writer. It is illegal for the tee'd writer to be modified
// concurrently with writes.
Tee(io.Writer)
// Unwrap returns the original proxied target.
Unwrap() http.ResponseWriter
}
// wrapWriter wraps an http.ResponseWriter, returning a proxy that allows you to
// hook into various parts of the response process.
func wrapWriter(w http.ResponseWriter) writerProxy {
return &basicWriter{ResponseWriter: w}
}
// basicWriter wraps a http.ResponseWriter that implements the minimal
// http.ResponseWriter interface.
type basicWriter struct {
http.ResponseWriter
wroteHeader bool
code int
bytes int
tee io.Writer
}
func (b *basicWriter) WriteHeader(code int) {
if !b.wroteHeader {
b.code = code
b.wroteHeader = true
b.ResponseWriter.WriteHeader(code)
}
}
func (b *basicWriter) Write(buf []byte) (int, error) {
b.WriteHeader(http.StatusOK)
n, err := b.ResponseWriter.Write(buf)
if b.tee != nil {
_, err2 := b.tee.Write(buf[:n])
// Prefer errors generated by the proxied writer.
if err == nil {
err = err2
}
}
b.bytes += n
return n, err
}
func (b *basicWriter) maybeWriteHeader() {
if !b.wroteHeader {
b.WriteHeader(http.StatusOK)
}
}
func (b *basicWriter) Status() int {
return b.code
}
func (b *basicWriter) BytesWritten() int {
return b.bytes
}
func (b *basicWriter) Tee(w io.Writer) {
b.tee = w
}
func (b *basicWriter) Unwrap() http.ResponseWriter {
return b.ResponseWriter
}

View File

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

View File

@ -0,0 +1,32 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20201103-rename_language_mappings_table",
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
oldTableName, newTableName := "custom_rules", "language_mappings"
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
if migrator.HasTable(oldTableName) {
logbuch.Info("renaming '%s' table to '%s'", oldTableName, newTableName)
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
return err
}
logbuch.Info("renaming '%s' index to '%s'", oldIndexName, newIndexName)
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
}
return nil
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,79 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20201106-migration_cascade_constraints"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
// drop all already existing foreign key constraints
// afterwards let them be re-created by auto migrate with the newly introduced cascade settings,
migrator := db.Migrator()
if cfg.Db.Dialect == config.SQLDialectSqlite {
// https://stackoverflow.com/a/1884893/3112139
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
// things like deleting all summaries won't work in those cases unless an entirely new db is created
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
logbuch.Info("key-value table not yet existing")
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
// SELECT * FROM INFORMATION_SCHEMA.table_constraints;
constraints := map[string]interface{}{
"fk_summaries_editors": &models.SummaryItem{},
"fk_summaries_languages": &models.SummaryItem{},
"fk_summaries_machines": &models.SummaryItem{},
"fk_summaries_operating_systems": &models.SummaryItem{},
"fk_summaries_projects": &models.SummaryItem{},
"fk_summary_items_summary": &models.SummaryItem{},
"fk_summaries_user": &models.Summary{},
"fk_language_mappings_user": &models.LanguageMapping{},
"fk_heartbeats_user": &models.Heartbeat{},
"fk_aliases_user": &models.Alias{},
}
for name, table := range constraints {
if migrator.HasConstraint(table, name) {
logbuch.Info("dropping constraint '%s'", name)
if err := migrator.DropConstraint(table, name); err != nil {
return err
}
}
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,58 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20210202-fix_cascade_for_alias_user_constraint"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if cfg.Db.Dialect == config.SQLDialectSqlite {
// see 20201106_migration_cascade_constraints
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
logbuch.Info("key-value table not yet existing")
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
if migrator.HasConstraint(&models.Alias{}, "fk_aliases_user") {
logbuch.Info("dropping constraint 'fk_aliases_user'")
if err := migrator.DropConstraint(&models.Alias{}, "fk_aliases_user"); err != nil {
return err
}
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,51 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20210206_drop_badges_column_add_sharing_flags",
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database, nothing to migrate
return nil
}
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
} else {
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,20 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20210213_add_has_data_field",
f: func(db *gorm.DB, cfg *config.Config) error {
if err := db.Exec("UPDATE users SET has_data = TRUE WHERE 1").Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

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

View File

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

View File

@ -1,33 +0,0 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
var customPreMigrations []migrationFunc
func init() {
customPreMigrations = []migrationFunc{
{
f: func(db *gorm.DB, cfg *config.Config) error {
if db.Migrator().HasTable("custom_rules") {
return db.Migrator().RenameTable("custom_rules", &models.LanguageMapping{})
}
return nil
},
name: "rename language mappings table",
},
}
}
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPreMigrations {
log.Printf("running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}
}
}

View File

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

67
migrations/migrations.go Normal file
View File

@ -0,0 +1,67 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"sort"
"strings"
)
type migrationFunc struct {
f func(db *gorm.DB, cfg *config.Config) error
name string
}
type migrationFuncs []migrationFunc
var (
preMigrations migrationFuncs
postMigrations migrationFuncs
)
func registerPreMigration(f migrationFunc) {
preMigrations = append(preMigrations, f)
}
func registerPostMigration(f migrationFunc) {
postMigrations = append(postMigrations, f)
}
// NOTE: Currently, migrations themselves keep track
// of whether they have run, yet or not, because some
// simply run on every start.
func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(preMigrations)
for _, m := range preMigrations {
logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err)
}
}
}
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(postMigrations)
for _, m := range postMigrations {
logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err)
}
}
}
func (m migrationFuncs) Len() int {
return len(m)
}
func (m migrationFuncs) Less(i, j int) bool {
return strings.Compare(m[i].name, m[j].name) < 0
}
func (m migrationFuncs) Swap(i, j int) {
m[i], m[j] = m[j], m[i]
}

45
mocks/alias_repository.go Normal file
View File

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

50
mocks/alias_service.go Normal file
View File

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

View File

@ -0,0 +1,56 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type HeartbeatServiceMock struct {
mock.Mock
}
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
args := m.Called(heartbeat)
return args.Error(0)
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats)
return args.Error(0)
}
func (m *HeartbeatServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
args := m.Called(user)
return args.Get(0).(int64), args.Error(0)
}
func (m *HeartbeatServiceMock) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
args := m.Called(users)
return args.Get(0).([]*models.CountByUser), args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
args := m.Called(s, user)
return args.Get(0).(*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)
}

View File

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

74
mocks/user_service.go Normal file
View File

@ -0,0 +1,74 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type UserServiceMock struct {
mock.Mock
}
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetActive() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
args := m.Called(signup, isAdmin)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) Delete(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) {
args := m.Called(user, s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
package v1
import "github.com/muety/wakapi/models"
type HeartbeatsViewModel struct {
Data []*HeartbeatEntry `json:"data"`
}
// Incomplete, for now, only the subset of fields is implemented
// that is actually required for the import
type HeartbeatEntry struct {
Id string `json:"id"`
Branch string `json:"branch"`
Category string `json:"category"`
Entity string `json:"entity"`
IsWrite bool `json:"is_write"`
Language string `json:"language"`
Project string `json:"project"`
Time models.CustomTime `json:"time"`
Type string `json:"type"`
UserId string `json:"user_id"`
MachineNameId string `json:"machine_name_id"`
UserAgentId string `json:"user_agent_id"`
}

View File

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

View File

@ -0,0 +1,78 @@
package v1
import (
"github.com/muety/wakapi/models"
"time"
)
// https://wakatime.com/api/v1/users/current/stats/last_7_days
// https://pastr.de/p/f2fxg6ragj7z5e7fhsow9rb6
type StatsViewModel struct {
Data *StatsData `json:"data"`
}
type StatsData struct {
Username string `json:"username"`
UserId string `json:"user_id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
TotalSeconds float64 `json:"total_seconds"`
DailyAverage float64 `json:"daily_average"`
DaysIncludingHolidays int `json:"days_including_holidays"`
Editors []*SummariesEntry `json:"editors"`
Languages []*SummariesEntry `json:"languages"`
Machines []*SummariesEntry `json:"machines"`
Projects []*SummariesEntry `json:"projects"`
OperatingSystems []*SummariesEntry `json:"operating_systems"`
}
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
totalTime := summary.TotalTime()
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
data := &StatsData{
Username: summary.UserID,
UserId: summary.UserID,
Start: summary.FromTime.T(),
End: summary.ToTime.T(),
TotalSeconds: totalTime.Seconds(),
DailyAverage: totalTime.Seconds() / float64(numDays),
DaysIncludingHolidays: numDays,
}
editors := make([]*SummariesEntry, len(summary.Editors))
for i, e := range summary.Editors {
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
}
languages := make([]*SummariesEntry, len(summary.Languages))
for i, e := range summary.Languages {
languages[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryLanguage))
}
machines := make([]*SummariesEntry, len(summary.Machines))
for i, e := range summary.Machines {
machines[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryMachine))
}
projects := make([]*SummariesEntry, len(summary.Projects))
for i, e := range summary.Projects {
projects[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryProject))
}
oss := make([]*SummariesEntry, len(summary.OperatingSystems))
for i, e := range summary.OperatingSystems {
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
}
data.Editors = editors
data.Languages = languages
data.Machines = machines
data.Projects = projects
data.OperatingSystems = oss
return &StatsViewModel{
Data: data,
}
}

View File

@ -13,24 +13,24 @@ import (
// https://pastr.de/v/736450
type SummariesViewModel struct {
Data []*summariesData `json:"data"`
Data []*SummariesData `json:"data"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
}
type summariesData struct {
Categories []*summariesEntry `json:"categories"`
Dependencies []*summariesEntry `json:"dependencies"`
Editors []*summariesEntry `json:"editors"`
Languages []*summariesEntry `json:"languages"`
Machines []*summariesEntry `json:"machines"`
OperatingSystems []*summariesEntry `json:"operating_systems"`
Projects []*summariesEntry `json:"projects"`
GrandTotal *summariesGrandTotal `json:"grand_total"`
Range *summariesRange `json:"range"`
type SummariesData struct {
Categories []*SummariesEntry `json:"categories"`
Dependencies []*SummariesEntry `json:"dependencies"`
Editors []*SummariesEntry `json:"editors"`
Languages []*SummariesEntry `json:"languages"`
Machines []*SummariesEntry `json:"machines"`
OperatingSystems []*SummariesEntry `json:"operating_systems"`
Projects []*SummariesEntry `json:"projects"`
GrandTotal *SummariesGrandTotal `json:"grand_total"`
Range *SummariesRange `json:"range"`
}
type summariesEntry struct {
type SummariesEntry struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
@ -41,7 +41,7 @@ type summariesEntry struct {
TotalSeconds float64 `json:"total_seconds"`
}
type summariesGrandTotal struct {
type SummariesGrandTotal struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
@ -49,7 +49,7 @@ type summariesGrandTotal struct {
TotalSeconds float64 `json:"total_seconds"`
}
type summariesRange struct {
type SummariesRange struct {
Date string `json:"date"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
@ -58,7 +58,7 @@ type summariesRange struct {
}
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
data := make([]*summariesData, len(summaries))
data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
for i, s := range summaries {
@ -79,27 +79,27 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
}
}
func newDataFrom(s *models.Summary) *summariesData {
func newDataFrom(s *models.Summary) *SummariesData {
zone, _ := time.Now().Zone()
total := s.TotalTime()
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
data := &summariesData{
Categories: make([]*summariesEntry, 0),
Dependencies: make([]*summariesEntry, 0),
Editors: make([]*summariesEntry, len(s.Editors)),
Languages: make([]*summariesEntry, len(s.Languages)),
Machines: make([]*summariesEntry, len(s.Machines)),
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
Projects: make([]*summariesEntry, len(s.Projects)),
GrandTotal: &summariesGrandTotal{
data := &SummariesData{
Categories: make([]*SummariesEntry, 0),
Dependencies: make([]*SummariesEntry, 0),
Editors: make([]*SummariesEntry, len(s.Editors)),
Languages: make([]*SummariesEntry, len(s.Languages)),
Machines: make([]*SummariesEntry, len(s.Machines)),
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
Projects: make([]*SummariesEntry, len(s.Projects)),
GrandTotal: &SummariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs,
Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
},
Range: &summariesRange{
Range: &SummariesRange{
Date: time.Now().Format(time.RFC3339),
End: s.ToTime.T(),
Start: s.FromTime.T(),
@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *summariesData {
var wg sync.WaitGroup
wg.Add(5)
go func(data *summariesData) {
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Projects {
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
}
}(data)
go func(data *summariesData) {
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Editors {
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
}
}(data)
go func(data *summariesData) {
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *summariesData {
}
}(data)
go func(data *summariesData) {
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.OperatingSystems {
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
}
}(data)
go func(data *summariesData) {
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Machines {
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *summariesData {
return data
}
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second
@ -163,7 +163,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
percentage = 0
}
return &summariesEntry{
return &SummariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs,
Minutes: mins,

View File

@ -0,0 +1,12 @@
package v1
type UserAgentsViewModel struct {
Data []*UserAgentEntry `json:"data"`
}
type UserAgentEntry struct {
Id string `json:"id"`
Editor string `json:"editor"`
Os string `json:"os"`
Value string `json:"value"`
}

View File

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

View File

@ -1,30 +1,36 @@
package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"regexp"
"time"
)
type Heartbeat struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project"`
Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp
ID uint `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project"`
Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`
OriginId string `json:"-" hash:"ignore"`
languageRegex *regexp.Regexp `hash:"ignore"`
}
func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
}
func (h *Heartbeat) Augment(languageMappings map[string]string) {
@ -41,3 +47,57 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
}
h.Language, _ = languageMappings[ending]
}
func (h *Heartbeat) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = h.Project
case SummaryEditor:
key = h.Editor
case SummaryLanguage:
key = h.Language
case SummaryOS:
key = h.OperatingSystem
case SummaryMachine:
key = h.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}
func (h *Heartbeat) String() string {
return fmt.Sprintf(
"Heartbeat {user=%s, entity=%s, type=%s, category=%s, project=%s, branch=%s, language=%s, iswrite=%v, editor=%s, os=%s, machine=%s, time=%d}",
h.UserID,
h.Entity,
h.Type,
h.Category,
h.Project,
h.Branch,
h.Language,
h.IsWrite,
h.Editor,
h.OperatingSystem,
h.Machine,
(time.Time(h.Time)).UnixNano(),
)
}
// Hash is used to prevent duplicate heartbeats
// Using a UNIQUE INDEX over all relevant columns would be more straightforward,
// whereas manually computing this kind of hash is quite cumbersome. However,
// such a unique index would, according to https://stackoverflow.com/q/65980064/3112139,
// essentially double the space required for heartbeats, so we decided to go this way.
func (h *Heartbeat) Hashed() *Heartbeat {
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err)
}
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
return h
}

53
models/heartbeat_test.go Normal file
View File

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

38
models/heartbeats.go Normal file
View File

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

46
models/interval.go Normal file
View File

@ -0,0 +1,46 @@
package models
// Support Wakapi and WakaTime range / interval identifiers
// See https://wakatime.com/developers/#summaries
var (
IntervalToday = &IntervalKey{"today", "Today"}
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
IntervalThisWeek = &IntervalKey{"week", "This Week"}
IntervalLastWeek = &IntervalKey{"Last Week"}
IntervalThisMonth = &IntervalKey{"month", "This Month"}
IntervalLastMonth = &IntervalKey{"Last Month"}
IntervalThisYear = &IntervalKey{"year"}
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"}
IntervalAny = &IntervalKey{"any"}
)
var AllIntervals = []*IntervalKey{
IntervalToday,
IntervalYesterday,
IntervalThisWeek,
IntervalLastWeek,
IntervalThisMonth,
IntervalLastMonth,
IntervalThisYear,
IntervalPast7Days,
IntervalPast7DaysYesterday,
IntervalPast14Days,
IntervalPast30Days,
IntervalPast12Months,
IntervalAny,
}
type IntervalKey []string
func (k *IntervalKey) HasAlias(s string) bool {
for _, e := range *k {
if e == s {
return true
}
}
return false
}

View File

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

View File

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

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

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

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

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

View File

@ -2,6 +2,7 @@ package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
@ -24,8 +25,17 @@ type KeyStringValue struct {
Value string `gorm:"type:text"`
}
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time
func (j *CustomTime) MarshalJSON() ([]byte, error) {
return json.Marshal(j.String())
}
func (j *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
i, err := strconv.ParseInt(s, 10, 64)
@ -66,6 +76,10 @@ func (j *CustomTime) Scan(value interface{}) error {
return nil
}
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) Value() (driver.Value, error) {
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil
@ -79,3 +93,7 @@ func (j CustomTime) String() string {
func (j CustomTime) T() time.Time {
return time.Time(j)
}
func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0
}

View File

@ -1,6 +1,7 @@
package models
import (
"sort"
"time"
)
@ -13,44 +14,30 @@ const (
SummaryMachine uint8 = 4
)
const (
IntervalToday string = "today"
IntervalYesterday string = "day"
IntervalThisWeek string = "week"
IntervalThisMonth string = "month"
IntervalThisYear string = "year"
IntervalPast7Days string = "7_days"
IntervalPast30Days string = "30_days"
IntervalPast12Months string = "12_months"
IntervalAny string = "any"
)
func Intervals() []string {
return []string{
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
}
}
const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"`
OperatingSystems []*SummaryItem `json:"operating_systems"`
Machines []*SummaryItem `json:"machines"`
ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-"`
Type uint8 `json:"-"`
Key string `json:"key"`
Total time.Duration `json:"total"`
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
}
type SummaryItemContainer struct {
@ -60,10 +47,14 @@ type SummaryItemContainer struct {
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 {
@ -73,16 +64,27 @@ type SummaryParams struct {
Recompute bool
}
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Projects))
sort.Sort(sort.Reverse(s.Machines))
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
return s
}
func (s *Summary) Types() []uint8 {
return SummaryTypes()
}
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
return map[uint8]*[]*SummaryItem{
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
return map[uint8]*SummaryItems{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
@ -91,6 +93,10 @@ func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
}
}
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
}
/* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
@ -170,9 +176,72 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur
return timeSum
}
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
for _, f := range filter.All() {
timeSum += s.TotalTimeByKey(f.Type, f.Key)
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
do, typeId, key := filters.One()
if do {
return s.TotalTimeByKey(typeId, key)
}
return timeSum
return 0
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
s.Projects = processAliases(s.Projects)
s.Editors = processAliases(s.Editors)
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
return s
}
func (s SummaryItems) Len() int {
return len(s)
}
func (s SummaryItems) Less(i, j int) bool {
return s[i].Total < s[j].Total
}
func (s SummaryItems) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

202
models/summary_test.go Normal file
View File

@ -0,0 +1,202 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration / time.Second,
},
},
}
sut.FillUnknown()
itemLists := [][]*SummaryItem{
sut.Machines,
sut.OperatingSystems,
sut.Languages,
sut.Editors,
}
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
}
}
func TestSummary_TotalTimeBy(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
}
func TestSummary_TotalTimeByFilters(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
// Specifying filters about multiple entites is not supported at the moment
// as the current, very rudimentary, time calculation logic wouldn't make sense then.
// Evaluating a filter like (project="wakapi", language="go") can only be realized
// before computing the summary in the first place, because afterwards we can't know
// what time coded in "Go" was in the "Wakapi" project
// See https://github.com/muety/wakapi/issues/108
filters1 := &Filters{Project: "wakapi"}
filters2 := &Filters{Language: "Go"}
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
}
func TestSummary_WithResolvedAliases(t *testing.T) {
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
var resolver AliasResolver = func(t uint8, k string) string {
switch t {
case SummaryProject:
switch k {
case "wakapi-mobile":
return "wakapi"
}
case SummaryLanguage:
switch k {
case "Java 8":
return "Java"
}
}
return k
}
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "wakapi-mobile",
Total: testDuration2 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration3 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Java 8",
Total: testDuration4 / time.Second,
},
},
}
sut = sut.WithResolvedAliases(resolver)
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
assert.Len(t, sut.Projects, 2)
assert.Len(t, sut.Languages, 1)
assert.Empty(t, sut.Editors)
assert.Empty(t, sut.OperatingSystems)
assert.Empty(t, sut.Machines)
}
func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2,
},
{
Type: SummaryProject,
Key: "anchr-mobile",
Total: testDuration3,
},
},
}
sut = sut.Sorted()
assert.Equal(t, testDuration3, sut.Projects[0].Total)
assert.Equal(t, testDuration1, sut.Projects[1].Total)
assert.Equal(t, testDuration2, sut.Projects[2].Total)
}

View File

@ -1,12 +1,20 @@
package models
type User struct {
ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
ShareDataMaxDays int `json:"-" gorm:"default:0"`
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`
}
type Login struct {
@ -26,6 +34,16 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"`
}
type TimeByUser struct {
User string
Time CustomTime
}
type CountByUser struct {
User string
Count int64
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat
@ -38,7 +56,7 @@ func (s *Signup) IsValid() bool {
}
func validateUsername(username string) bool {
return len(username) >= 3 && username != "current"
return len(username) >= 1 && username != "current"
}
func validatePassword(password string) bool {

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@ -3,6 +3,7 @@ package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
@ -15,12 +16,31 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db.Create(&heartbeats).Error; err != nil {
if err := r.db.
Clauses(clause.OnConflict{
DoNothing: true,
}).
Create(&heartbeats).Error; err != nil {
return err
}
return nil
}
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
var heartbeat models.Heartbeat
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{
UserID: user.ID,
Origin: origin,
}).
Order("time desc").
First(&heartbeat).Error; err != nil {
return nil, err
}
return &heartbeat, nil
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.
@ -34,18 +54,65 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
return heartbeats, nil
}
// Will return *models.Heartbeat object with only user_id and time fields filled
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, min(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Table("heartbeats").
Select("user_id, min(time) as time").
Where("user_id IN (?)", userIds).
Group("user_id").
Scan(&heartbeats).Error; err != nil {
return nil, err
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
}
return heartbeats, nil
return count, nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
var counts []*models.CountByUser
userIds := make([]string, len(users))
for i, u := range users {
userIds[i] = u.ID
}
if err := r.db.
Model(&models.User{}).
Select("users.id as user, count(heartbeats.id) as count").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Where("user_id in ?", userIds).
Group("user").
Find(&counts).Error; err != nil {
return counts, err
}
return counts, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {

View File

@ -2,8 +2,10 @@ package repositories
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type KeyValueRepository struct {
@ -27,16 +29,19 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
result := r.db.
Clauses(clause.OnConflict{
UpdateAll: true,
}).
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
FirstOrCreate(kv)
Create(kv)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing updated")
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
}
return nil

View File

@ -1,6 +1,7 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
@ -34,6 +35,9 @@ func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.Language
}
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
if !mapping.IsValid() {
return nil, errors.New("invalid mapping")
}
result := r.db.Create(mapping)
if err := result.Error; err != nil {
return nil, err

View File

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

View File

@ -39,15 +39,21 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
return summaries, nil
}
// Will return *models.Index objects with only user_id and to_time filled
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Table("summaries").
Select("user_id, max(to_time) as to_time").
Group("user_id").
Scan(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(to_time) as time").
Joins("left join summaries on users.id = summaries.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *SummaryRepository) DeleteByUser(userId string) error {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type UserRepository struct {
@ -22,6 +23,17 @@ func (r *UserRepository) GetById(userId string) (*models.User, error) {
return u, nil
}
func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Model(&models.User{}).
Where("id in ?", userIds).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
@ -33,13 +45,53 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Where(&models.User{}).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Where("last_logged_in_at >= ?", t).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user")
var userIds []string
if err := r.db.
Select("user as id").
Table("(?) as q", subQuery1).
Where("time >= ?", t).
Scan(&userIds).Error; err != nil {
return nil, err
}
return r.GetByIds(userIds)
}
func (r *UserRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.User{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
if err := result.Error; err != nil {
@ -54,7 +106,21 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
}
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
result := r.db.Model(&models.User{}).Updates(user)
updateMap := map[string]interface{}{
"api_key": user.ApiKey,
"password": user.Password,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
"share_languages": user.ShareLanguages,
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData,
}
result := r.db.Model(user).Updates(updateMap)
if err := result.Error; err != nil {
return nil, err
}
@ -78,3 +144,7 @@ func (r *UserRepository) UpdateField(user *models.User, key string, value interf
return user, nil
}
func (r *UserRepository) Delete(user *models.User) error {
return r.db.Delete(user).Error
}

39
routes/api/health.go Normal file
View File

@ -0,0 +1,39 @@
package api
import (
"fmt"
"github.com/gorilla/mux"
"gorm.io/gorm"
"net/http"
)
type HealthApiHandler struct {
db *gorm.DB
}
func NewHealthApiHandler(db *gorm.DB) *HealthApiHandler {
return &HealthApiHandler{db: db}
}
func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/health").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Check the application's health status
// @ID get-health
// @Tags misc
// @Produce plain
// @Success 200 {string} string
// @Router /health [get]
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if sqlDb, err := h.db.DB(); err == nil {
if err := sqlDb.Ping(); err == nil {
dbStatus = 1
}
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(fmt.Sprintf("app=1\ndb=%d", dbStatus)))
}

View File

@ -1,26 +1,30 @@
package routes
package api
import (
"encoding/json"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"net/http"
"os"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"github.com/muety/wakapi/models"
)
type HeartbeatHandler struct {
type HeartbeatApiHandler struct {
config *conf.Config
heartbeatSrvc *services.HeartbeatService
languageMappingSrvc *services.LanguageMappingService
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{
func NewHeartbeatApiHandler(userService services.IUserService, heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatApiHandler {
return &HeartbeatApiHandler{
config: conf.Get(),
userSrvc: userService,
heartbeatSrvc: heartbeatService,
languageMappingSrvc: languageMappingService,
}
@ -30,7 +34,24 @@ type heartbeatResponseVm struct {
Responses [][]interface{} `json:"responses"`
}
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
r.Methods(http.MethodPost).HandlerFunc(h.Post)
}
// @Summary Push a new heartbeat
// @ID post-heartbeat
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /heartbeat [post]
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
@ -43,13 +64,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
return
}
/*languageMappings, err := h.languageMappingSrvc.ResolveByUser(user.ID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}*/
for _, hb := range heartbeats {
hb.OperatingSystem = opSys
hb.Editor = editor
@ -59,17 +73,30 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid heartbeat object."))
w.Write([]byte("invalid heartbeat object"))
return
}
hb.Hashed()
}
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.WriteString(err.Error())
w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to batch-insert heartbeats %v", err)
return
}
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to update user %v", err)
return
}
}
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
}

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

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

55
routes/api/summary.go Normal file
View File

@ -0,0 +1,55 @@
package api
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type SummaryApiHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummaryApiHandler(userService services.IUserService, summaryService services.ISummaryService) *SummaryApiHandler {
return &SummaryApiHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
}
}
func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Retrieve a summary
// @ID get-summary
// @Tags summary
// @Produce json
// @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
// @Param from query string false "Start date (e.g. '2021-02-07')"
// @Param to query string false "End date (e.g. '2021-02-08')"
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
// @Security ApiKeyAuth
// @Success 200 {object} models.Summary
// @Router /summary [get]
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
utils.RespondJSON(w, http.StatusOK, summary)
}

View File

@ -2,7 +2,7 @@ package v1
import (
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
@ -10,6 +10,7 @@ import (
"net/http"
"regexp"
"strings"
"time"
)
const (
@ -18,21 +19,36 @@ const (
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
aliasSrvc *services.AliasService
config *config2.Config
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: config2.Get(),
config: conf.Get(),
}
}
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
// no auth middleware here, handler itself resolves the user
r := router.PathPrefix("/compat/shields/v1/{user}").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Get badge data
// @Description Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.
// @ID get-badge
// @Tags badges
// @Produce json
// @Param user path string true "User ID to fetch data for"
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
// @Param filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
// @Success 200 {object} v1.BadgeData
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
@ -41,13 +57,6 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
@ -55,7 +64,25 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var interval = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
interval = groups[1]
if i, err := utils.ParseInterval(groups[1]); err == nil {
interval = i
}
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
}
var filters *models.Filters
@ -85,7 +112,7 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval)
if err != nil {
return nil, err, http.StatusBadRequest
@ -97,9 +124,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user,
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,7 +2,8 @@ package v1
import (
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -13,18 +14,37 @@ import (
)
type AllTimeHandler struct {
summarySrvc *services.SummaryService
config *config2.Config
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
func NewAllTimeHandler(userService services.IUserService, summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: config2.Get(),
config: conf.Get(),
}
}
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/all_time_since_today").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Retrieve summary for all time
// @Description Mimics https://wakatime.com/developers#all_time_since_today
// @ID get-all-time
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)
@ -55,9 +75,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false,
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -0,0 +1,126 @@
package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type StatsHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewStatsHandler(userService services.IUserService, summaryService services.ISummaryService) *StatsHandler {
return &StatsHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: conf.Get(),
}
}
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
)
r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
// Also works without range, see https://github.com/anuraghazra/github-readme-stats/issues/865#issuecomment-776186592
r.Path("/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if u := r.Context().Value(models.UserKey); u != nil {
authorizedUser = u.(*models.User)
}
if authorizedUser != nil && vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
requestedUser, err := h.userSrvc.GetUserById(vars["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("user not found"))
return
}
rangeParam := vars["range"]
if rangeParam == "" {
rangeParam = (*models.IntervalPast7Days)[0]
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
return
}
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
}
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
stats := v1.NewStatsFrom(summary, &models.Filters{})
// post filter stats according to user's given sharing permissions
if !requestedUser.ShareEditors {
stats.Data.Editors = nil
}
if !requestedUser.ShareLanguages {
stats.Data.Languages = nil
}
if !requestedUser.ShareProjects {
stats.Data.Projects = nil
}
if !requestedUser.ShareOSs {
stats.Data.OperatingSystems = nil
}
if !requestedUser.ShareMachines {
stats.Data.Machines = nil
}
utils.RespondJSON(w, http.StatusOK, stats)
}
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
overallParams := &models.SummaryParams{
From: start,
To: end,
User: user,
Recompute: false,
}
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -3,7 +3,8 @@ package v1
import (
"errors"
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -14,24 +15,45 @@ import (
)
type SummariesHandler struct {
summarySrvc *services.SummaryService
config *config2.Config
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
func NewSummariesHandler(userService services.IUserService, summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: config2.Get(),
config: conf.Get(),
}
}
/*
TODO: support parameters: project, branches, timeout, writes_only, timezone
https://wakatime.com/developers#summaries
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
*/
func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/summaries").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
// See https://wakatime.com/developers#summaries.
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108.
// @Summary Retrieve WakaTime-compatible summaries
// @Description Mimics https://wakatime.com/developers#summaries.
// @ID get-wakatime-summaries
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Param range query string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
// @Param start query string false "Start date (e.g. '2021-02-07')"
// @Param end query string false "End date (e.g. '2021-02-08')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
@ -48,28 +70,41 @@ func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
filters := &models.Filters{}
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
filters.Project = projectQuery
}
vm := v1.NewSummariesFrom(summaries, filters)
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
var start, end time.Time
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
start = utils.StartOfToday()
end = time.Now()
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam {
// also accept start param to be a range param
start, end = parsedFrom, parsedTo
} else {
// eventually, consider start and end params a date
var err error
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
start, err = time.Parse(time.RFC3339, strings.Replace(startParam, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
end, err = time.Parse(time.RFC3339, strings.Replace(endParam, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
@ -86,9 +121,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
)
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}

7
routes/handler.go Normal file
View File

@ -0,0 +1,7 @@
package routes
import "github.com/gorilla/mux"
type Handler interface {
RegisterRoutes(router *mux.Router)
}

View File

@ -1,27 +0,0 @@
package routes
import (
"fmt"
"gorm.io/gorm"
"net/http"
)
type HealthHandler struct {
db *gorm.DB
}
func NewHealthHandler(db *gorm.DB) *HealthHandler {
return &HealthHandler{db: db}
}
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if sqlDb, err := h.db.DB(); err == nil {
if err := sqlDb.Ping(); err == nil {
dbStatus = 1
}
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(fmt.Sprintf("app=1\ndb=%d", dbStatus)))
}

70
routes/home.go Normal file
View File

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

46
routes/imprint.go Normal file
View File

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

178
routes/login.go Normal file
View File

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

View File

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

View File

@ -2,7 +2,9 @@ package routes
import (
"fmt"
"github.com/markbates/pkger"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
@ -11,19 +13,28 @@ import (
"strings"
)
func init() {
func Init() {
loadTemplates()
}
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template
func loadTemplates() {
tplPath := "views"
const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"capitalize": utils.Capitalize,
"json": utils.Json,
"date": utils.FormatDateHuman,
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"capitalize": utils.Capitalize,
"toRunes": utils.ToRunes,
"entityTypes": models.SummaryTypes,
"typeName": typeName,
"getBasePath": func() string {
return config.Get().Server.BasePath
},
@ -31,7 +42,7 @@ func loadTemplates() {
return config.Get().Version
},
"getDbType": func() string {
return strings.ToLower(config.Get().Db.Dialect)
return strings.ToLower(config.Get().Db.Type)
},
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
@ -39,7 +50,12 @@ func loadTemplates() {
})
templates = make(map[string]*template.Template)
files, err := ioutil.ReadDir(tplPath)
dir, err := pkger.Open(tplPath)
if err != nil {
panic(err)
}
defer dir.Close()
files, err := dir.Readdir(0)
if err != nil {
panic(err)
}
@ -50,7 +66,18 @@ func loadTemplates() {
continue
}
tpl, err := tpls.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
templateFile, err := pkger.Open(fmt.Sprintf("%s/%s", tplPath, tplName))
if err != nil {
panic(err)
}
templateData, err := ioutil.ReadAll(templateFile)
if err != nil {
panic(err)
}
templateFile.Close()
tpl, err := tpls.New(tplName).Parse(string(templateData))
if err != nil {
panic(err)
}
@ -59,32 +86,25 @@ func loadTemplates() {
}
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
if tplName == "" {
tplName = config.IndexTemplate
func typeName(t uint8) string {
if t == models.SummaryProject {
return "project"
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error, Success: success})
if t == models.SummaryLanguage {
return "language"
}
if t == models.SummaryEditor {
return "editor"
}
if t == models.SummaryOS {
return "operating system"
}
if t == models.SummaryMachine {
return "machine"
}
return "unknown"
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -1,51 +1,147 @@
package routes
import (
"encoding/base64"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"strconv"
"time"
)
type SettingsHandler struct {
config *conf.Config
userSrvc *services.UserService
languageMappingSrvc *services.LanguageMappingService
userSrvc services.IUserService
summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
keyValueSrvc services.IKeyValueService
httpClient *http.Client
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
func NewSettingsHandler(
userService services.IUserService,
heartbeatService services.IHeartbeatService,
summaryService services.ISummaryService,
aliasService services.IAliasService,
aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService,
keyValueService services.IKeyValueService,
) *SettingsHandler {
return &SettingsHandler{
config: conf.Get(),
summarySrvc: summaryService,
aliasSrvc: aliasService,
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
}
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
data := map[string]interface{}{
"User": user,
"LanguageMappings": mappings,
"Success": r.FormValue("success"),
"Error": r.FormValue("error"),
}
templates[conf.SettingsTemplate].Execute(w, data)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing form values"))
return
}
action := r.PostForm.Get("action")
r.PostForm.Del("action")
actionFunc := h.dispatchAction(action)
if actionFunc == nil {
logbuch.Warn("failed to dispatch action '%s'", action)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("unknown action requests"))
return
}
status, successMsg, errorMsg := actionFunc(w, r)
// action responded itself
if status == -1 {
return
}
if errorMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
return
}
if successMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) dispatchAction(action string) action {
switch action {
case "change_password":
return h.actionChangePassword
case "reset_apikey":
return h.actionResetApiKey
case "delete_alias":
return h.actionDeleteAlias
case "add_alias":
return h.actionAddAlias
case "delete_mapping":
return h.actionDeleteLanguageMapping
case "add_mapping":
return h.actionAddLanguageMapping
case "update_sharing":
return h.actionUpdateSharing
case "toggle_wakatime":
return h.actionSetWakatimeApiKey
case "import_wakatime":
return h.actionImportWaktime
case "regenerate_summaries":
return h.actionRegenerateSummaries
case "delete_account":
return h.actionDeleteUser
}
return nil
}
func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -54,60 +150,135 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
return
return http.StatusBadRequest, "", "missing parameters"
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
return
return http.StatusBadRequest, "", "missing parameters"
}
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid credentials")), http.StatusFound)
return
return http.StatusUnauthorized, "", "invalid credentials"
}
if !credentials.IsValid() {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid parameters")), http.StatusFound)
return
return http.StatusBadRequest, "", "invalid parameters"
}
user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
login := &models.Login{
Username: user.ID,
Password: user.Password,
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.Security.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("password was updated successfully")), http.StatusFound)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
return http.StatusOK, "password was updated successfully", ""
}
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
return http.StatusOK, msg, ""
}
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
var err error
user := r.Context().Value(models.UserKey).(*models.User)
defer h.userSrvc.FlushCache()
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal sever error"
}
return http.StatusOK, "settings updated", ""
}
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
aliasKey := r.PostFormValue("key")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
if err != nil {
aliasType = 99 // nothing will be found later on
}
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
return http.StatusNotFound, "", "aliases not found"
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
return http.StatusInternalServerError, "", "could not delete aliases"
}
return http.StatusOK, "aliases deleted successfully", ""
}
func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
aliasKey := r.PostFormValue("key")
aliasValue := r.PostFormValue("value")
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
if err != nil {
aliasType = 99 // Alias.IsValid() will return false later on
}
alias := &models.Alias{
UserID: user.ID,
Key: aliasKey,
Value: aliasValue,
Type: uint8(aliasType),
}
if _, err := h.aliasSrvc.Create(alias); err != nil {
// TODO: distinguish between bad request, conflict and server error
return http.StatusBadRequest, "", "invalid input"
}
return http.StatusOK, "alias added successfully", ""
}
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -115,25 +286,23 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
user := r.Context().Value(models.UserKey).(*models.User)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
return
return http.StatusInternalServerError, "", "could not delete mapping"
}
mapping := &models.LanguageMapping{
ID: uint(id),
UserID: user.ID,
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
return http.StatusNotFound, "", "mapping not found"
} else if mapping.UserID != user.ID {
return http.StatusForbidden, "", "not allowed to delete mapping"
}
err = h.languageMappingSrvc.Delete(mapping)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
return
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping deleted successfully")), http.StatusFound)
return http.StatusOK, "mapping deleted successfully", ""
}
func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -152,39 +321,209 @@ func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *ht
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("mapping already exists")), http.StatusFound)
return
return http.StatusConflict, "", "mapping already exists"
}
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping added successfully")), http.StatusFound)
return http.StatusOK, "mapping added successfully", ""
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
return
apiKey := r.PostFormValue("api_key")
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
return http.StatusOK, "Wakatime API Key updated successfully", ""
}
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if user.WakatimeApiKey == "" {
return http.StatusForbidden, "", "not connected to wakatime"
}
kvKey := fmt.Sprintf("%s_%s", conf.KeyLastImportImport, user.ID)
if !h.config.IsDev() {
lastImportKv := h.keyValueSrvc.MustGetString(kvKey)
lastImport, _ := time.Parse(time.RFC822, lastImportKv.Value)
if time.Now().Sub(lastImport) < time.Duration(h.config.App.ImportBackoffMin)*time.Minute {
return http.StatusTooManyRequests,
"",
fmt.Sprintf("Too many data imports. You are only allowed to request an import every %d minutes.", h.config.App.ImportBackoffMin)
}
}
go func(user *models.User) {
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
countBefore, err := h.heartbeatSrvc.CountByUser(user)
if err != nil {
println(err)
}
var stream <-chan *models.Heartbeat
if latest, err := h.heartbeatSrvc.GetLatestByOriginAndUser(imports.OriginWakatime, user); latest == nil || err != nil {
stream = importer.ImportAll(user)
} else {
// if an import has happened before, only import heartbeats newer than the latest of the last import
stream = importer.Import(user, latest.Time.T(), time.Now())
}
count := 0
batch := make([]*models.Heartbeat, 0)
for hb := range stream {
count++
batch = append(batch, hb)
if len(batch) == h.config.App.ImportBatchSize {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err)
}
batch = make([]*models.Heartbeat, 0)
}
}
countAfter, _ := h.heartbeatSrvc.CountByUser(user)
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
h.regenerateSummaries(user)
}(user)
h.keyValueSrvc.PutString(&models.KeyStringValue{
Key: kvKey,
Value: time.Now().Format(time.RFC822),
})
return http.StatusAccepted, "ImportAll started. This may take a few minutes.", ""
}
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
return
if err := h.regenerateSummaries(user); err != nil {
return http.StatusInternalServerError, "", "failed to regenerate summaries"
}
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
return http.StatusOK, "summaries are being regenerated this may take a few seconds", ""
}
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
go func(user *models.User) {
logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute)
if err := h.userSrvc.Delete(user); err != nil {
logbuch.Error("failed to delete user '%s' %v", user.ID, err)
} else {
logbuch.Info("successfully deleted user '%s'", user.ID)
}
}(user)
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
return -1, "", ""
}
func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
headers := http.Header{
"Accept": []string{"application/json"},
"Authorization": []string{
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(apiKey))),
},
}
request, err := http.NewRequest(
http.MethodGet,
conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
nil,
)
if err != nil {
return false
}
request.Header = headers
response, err := h.httpClient.Do(request)
if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
return false
}
return true
}
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
logbuch.Info("clearing summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
logbuch.Error("failed to clear summaries: %v", err)
return err
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
return err
}
return nil
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := r.Context().Value(models.UserKey).(*models.User)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
if _, ok := aliasMap[k]; !ok {
aliasMap[k] = []*models.Alias{a}
} else {
aliasMap[k] = append(aliasMap[k], a)
}
}
combinedAliases := make([]*view.SettingsVMCombinedAlias, 0)
for _, l := range aliasMap {
ca := &view.SettingsVMCombinedAlias{
Key: l[0].Key,
Type: l[0].Type,
Values: make([]string, len(l)),
}
for i, a := range l {
ca.Values[i] = a.Value
}
combinedAliases = append(combinedAliases, ca)
}
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -1,34 +1,37 @@
package routes
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type SummaryHandler struct {
summarySrvc *services.SummaryService
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
}
}
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
utils.RespondJSON(w, http.StatusOK, summary)
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
@ -36,45 +39,43 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
rawQuery := r.URL.RawQuery
q := r.URL.Query()
if q.Get("interval") == "" && q.Get("from") == "" {
q.Set("interval", "today")
r.URL.RawQuery = q.Encode()
}
summary, err, status := h.loadUserSummary(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
w.WriteHeader(status)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil {
respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
return
}
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary),
User: user,
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
}
templates[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -0,0 +1,27 @@
package utils
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
}
var retrieveSummary services.SummaryRetriever = ss.Retrieve
if summaryParams.Recompute {
retrieveSummary = ss.Summarize
}
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

3
scripts/docker_mysql.sh Normal file
View File

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

View File

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

View File

@ -11,6 +11,7 @@ from typing import List, Union
import requests
from tqdm import tqdm
MACHINE = "devmachine"
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0'
LANGUAGES = {
'Go': 'go',
@ -75,7 +76,8 @@ def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
for h in tqdm(data):
r = requests.post(url, json=[h.__dict__], headers={
'User-Agent': UA,
'Authorization': f'Basic {encoded_key}'
'Authorization': f'Basic {encoded_key}',
'X-Machine-Name': MACHINE,
})
if r.status_code != 201:
print(r.text)

View File

@ -1,12 +1,12 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"log"
"runtime"
"time"
"github.com/jasonlvhit/gocron"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
@ -16,12 +16,12 @@ const (
type AggregationService struct {
config *config.Config
userService *UserService
summaryService *SummaryService
heartbeatService *HeartbeatService
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
}
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
return &AggregationService{
config: config.Get(),
userService: userService,
@ -38,10 +38,19 @@ type AggregationJob struct {
// Schedule a job to (re-)generate summaries every day shortly after midnight
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
logbuch.Fatal("failed to run AggregationJob: %v", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
s.StartBlocking()
}
func (srv *AggregationService) Run(userIds map[string]bool) error {
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
defer close(jobs)
defer close(summaries)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
@ -51,19 +60,22 @@ func (srv *AggregationService) Schedule() {
go srv.persistWorker(summaries)
}
// Run once initially
srv.trigger(jobs)
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.trigger, jobs)
<-gocron.Start()
return srv.trigger(jobs, userIds)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
logbuch.Error("failed to generate summary (%v, %v, %s) %v", job.From, job.To, job.UserID, err)
} else {
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary
}
}
@ -72,71 +84,82 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil {
log.Printf("Failed to save summary (%v, %v, %s) %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
logbuch.Error("failed to save summary (%v, %v, %s) %v", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
log.Println("Generating summaries.")
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
logbuch.Info("generating summaries")
users, err := srv.userService.GetAll()
if err != nil {
log.Println(err)
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
logbuch.Error(err.Error())
return err
}
latestSummaries, err := srv.summaryService.GetLatestByUser()
if err != nil {
log.Println(err)
return err
}
userSummaryTimes := make(map[string]time.Time)
for _, s := range latestSummaries {
userSummaryTimes[s.UserID] = s.ToTime.T()
}
missingUserIDs := make([]string, 0)
for _, u := range users {
if _, ok := userSummaryTimes[u.ID]; !ok {
missingUserIDs = append(missingUserIDs, u.ID)
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, 0)
for _, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users = append(users, u)
}
}
} else {
users = allUsers
}
firstHeartbeats, err := srv.heartbeatService.GetFirstUserHeartbeats(missingUserIDs)
// Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil {
log.Println(err)
logbuch.Error(err.Error())
return err
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
logbuch.Error(err.Error())
return err
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
// Build actual lookup table from it
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
for _, e := range firstUserHeartbeatTimes {
firstUserHeartbeatLookup[e.User] = e.Time
}
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
generateUserJobs(e.User, e.Time.T(), jobs)
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
// Case 2: User has no aggregated summaries, yet, but has heartbeats
// -> Spawn jobs to create summaries from their first heartbeat to now
generateUserJobs(e.User, t.T(), jobs)
}
// Case 3: User doesn't have heartbeats at all
// -> Nothing to do
}
return nil
}
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
var from, to time.Time
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var to time.Time
// Go to next day of either user's first heartbeat or latest aggregation
from.Add(-1 * time.Second)
from = time.Date(
from.Year(),
from.Month(),
from.Day()+aggregateIntervalDays,
0, 0, 0, 0,
from.Location(),
)
// Iteratively aggregate per-day summaries until end of yesterday is reached
end := getStartOfToday().Add(-1 * time.Second)
if lastAggregation.Hour() == 0 {
from = lastAggregation
} else {
from = time.Date(
lastAggregation.Year(),
lastAggregation.Month(),
lastAggregation.Day()+aggregateIntervalDays,
0, 0, 0, 0,
lastAggregation.Location(),
)
}
for from.Before(end) && to.Before(end) {
to = time.Date(
from.Year(),

View File

@ -1,19 +1,20 @@
package services
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"sync"
"github.com/muety/wakapi/models"
)
type AliasService struct {
config *config.Config
repository *repositories.AliasRepository
repository repositories.IAliasRepository
}
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
return &AliasService{
config: config.Get(),
repository: aliasRepo,
@ -22,7 +23,14 @@ func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
var userAliases sync.Map
func (srv *AliasService) LoadUserAliases(userId string) error {
func (srv *AliasService) IsInitialized(userId string) bool {
if _, ok := userAliases.Load(userId); ok {
return true
}
return false
}
func (srv *AliasService) InitializeUser(userId string) error {
aliases, err := srv.repository.GetByUser(userId)
if err == nil {
userAliases.Store(userId, aliases)
@ -30,9 +38,25 @@ func (srv *AliasService) LoadUserAliases(userId string) error {
return err
}
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
return aliases, nil
}
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType)
if err != nil {
return nil, err
}
return aliases, nil
}
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
if !srv.IsInitialized(userId) {
if err := srv.LoadUserAliases(userId); err != nil {
if err := srv.InitializeUser(userId); err != nil {
return "", err
}
}
@ -46,9 +70,46 @@ func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, val
return value, nil
}
func (srv *AliasService) IsInitialized(userId string) bool {
if _, ok := userAliases.Load(userId); ok {
return true
func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
result, err := srv.repository.Insert(alias)
if err != nil {
return nil, err
}
go srv.reinitUser(alias.UserID)
return result, nil
}
func (srv *AliasService) Delete(alias *models.Alias) error {
if alias.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(alias.ID)
go srv.reinitUser(alias.UserID)
return err
}
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
ids := make([]uint, len(aliases))
affectedUsers := make(map[string]bool)
for i, a := range aliases {
if a.UserID == "" {
return errors.New("no user id specified")
}
affectedUsers[a.UserID] = true
ids[i] = a.ID
}
err := srv.repository.DeleteBatch(ids)
for k := range affectedUsers {
go srv.reinitUser(k)
}
return err
}
func (srv *AliasService) reinitUser(userId string) {
if err := srv.InitializeUser(userId); err != nil {
logbuch.Error("error initializing user aliases %v", err)
}
return false
}

63
services/alias_test.go Normal file
View File

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

View File

@ -1,27 +1,20 @@
package services
import (
"github.com/jasonlvhit/gocron"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"log"
"time"
"github.com/muety/wakapi/models"
)
const (
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
)
type HeartbeatService struct {
config *config.Config
repository *repositories.HeartbeatRepository
languageMappingSrvc *LanguageMappingService
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
config: config.Get(),
repository: heartbeatRepo,
@ -29,10 +22,26 @@ func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, langua
}
}
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return srv.repository.InsertBatch(heartbeats)
}
func (srv *HeartbeatService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user)
}
func (srv *HeartbeatService) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
return srv.repository.CountByUsers(users)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil {
@ -41,31 +50,18 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID)
}
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
return srv.repository.GetFirstByUsers(userIds)
func (srv *HeartbeatService) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
return srv.repository.GetLatestByOriginAndUser(origin, user)
}
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers()
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
return srv.repository.DeleteBefore(t)
}
func (srv *HeartbeatService) CleanUp() error {
refTime := utils.StartOfToday().Add(-cleanUpInterval)
if err := srv.DeleteBefore(refTime); err != nil {
log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err)
return err
}
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
return nil
}
func (srv *HeartbeatService) ScheduleCleanUp() {
srv.CleanUp()
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
<-gocron.Start()
}
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
if err != nil {

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