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

Compare commits

...

62 Commits

Author SHA1 Message Date
e55adf6287 fix: mysql character encoding (resolve #131) 2021-02-17 21:04:22 +01:00
56800be8e8 chore: change info message in migration (resolve #128) 2021-02-16 17:18:56 +01:00
c149766ecc fix: drop_badges_column migration for sqlite 2021-02-16 22:07:41 +11:00
759e8e4dfd chore: change logging middleware to use different output 2021-02-14 16:41:02 +01:00
708863fd33 fix: broken migration on postgres (resolve #127) 2021-02-14 16:02:05 +01:00
e2f046a83d fix: add migration for newly introduced has data field 2021-02-13 13:04:47 +01:00
30510591eb feat: custom time intervals (resolve #115) 2021-02-13 12:59:59 +01:00
daf67b844a refctor: change active users query 2021-02-13 11:23:58 +01:00
6b0b3bddda fix: include overall total number of heartbeats again 2021-02-12 23:16:20 +01:00
ef17d06763 fix: tests [ci skip] 2021-02-12 23:10:25 +01:00
301cab4be4 feat: per-user heartbeats count metrics 2021-02-12 23:06:48 +01:00
703805412b chore: code smell [ci skip] 2021-02-12 19:26:23 +01:00
88eb68b1a9 feat: add prometheus metrics without external standalone exporter 2021-02-12 18:50:13 +01:00
8191a52ce1 chore: make very first user have admin privileges 2021-02-12 18:49:47 +01:00
5b3e88247e chore: introduce user admin flag 2021-02-12 18:13:49 +01:00
59b85863cc chore: accept bearer prefix in auth header 2021-02-12 18:12:46 +01:00
22fbfceca2 fix: support default range for stats endpoint (resolve #125) 2021-02-12 11:25:21 +01:00
4d7fc6bff9 fix: commit missing asset 2021-02-12 10:28:31 +01:00
218c571859 feat: display setup instructions on startup (resolve #120) 2021-02-12 10:10:44 +01:00
e4c413a33c fix: include machine names when importing wakatime data 2021-02-10 22:08:00 +01:00
66b01c2797 docs: update readme toc 2021-02-07 17:30:14 +01:00
0cee7496e0 fix(ui): convert logo text to path (resolve #121) 2021-02-07 17:29:26 +01:00
e571e5266d fix: dockerfile syntax error 2021-02-07 12:58:24 +01:00
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
123 changed files with 6300 additions and 1487 deletions

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

@ -2,10 +2,11 @@ name: Build Wakapi on Linux
on:
push:
branches:
pull_request:
release:
types:
- created
- published
jobs:
build-and-release:

View File

@ -2,10 +2,11 @@ name: Build Wakapi on Windows
on:
push:
branches:
pull_request:
release:
types:
- created
- published
jobs:
build-and-release:

View File

@ -6,6 +6,9 @@ WORKDIR /src
ADD ./go.mod .
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 generate && go build -o wakapi
@ -13,7 +16,8 @@ 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/wait-for-it.sh . && \
cp /src/entrypoint.sh .
# Run Stage
@ -36,9 +40,10 @@ 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 /app .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh
ENTRYPOINT ./entrypoint.sh

View File

@ -1,4 +1,6 @@
<h1 align="center">📊 Wakapi</h1>
<p align="center">
<img src="static/assets/images/logo-gh.svg" width="350">
</p>
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
@ -31,16 +33,17 @@
</div>
<p align="center">
<img src="https://anchr.io/i/bxQ69.png" width="500px">
<img src="static/assets/images/screenshot.png" width="500px">
</p>
## 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)
* [Prometheus Export](#-prometheus-export)
* [Integrations](#-integrations)
* [Best Practices](#-best-practices)
* [Developer Notes](#-developer-notes)
* [Support](#-support)
@ -56,12 +59,16 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Badges
* ✅ REST API
* ✅ Partially compatible with WakaTime
* ✅ WakaTime relay to use both
* ✅ Support for [Prometheus](https://github.com/muety/wakapi#%EF%B8%8F-prometheus-export) exports
* ✅ 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 out hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
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).
@ -157,13 +164,16 @@ You can specify configuration options either via a config file (default: `config
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
@ -179,23 +189,55 @@ Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of differ
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)
```
#### 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).

View File

@ -10,7 +10,7 @@ server:
app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
custom_languages:
vue: Vue
jsx: JSX
@ -22,10 +22,13 @@ 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
charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
security:
password_salt: # CHANGE !
insecure_cookies: false
cookie_max_age: 172800
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

@ -21,7 +21,7 @@ import (
)
const (
defaultConfigPath = "config.yml"
defaultConfigPath = "config.yml"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
@ -29,25 +29,40 @@ const (
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
ErrUnauthorized = "401 unauthorized"
ErrInternalServerError = "500 internal server error"
)
const (
WakatimeApiUrl = "https://wakatime.com/api/v1"
WakatimeApiHeartbeatsEndpoint = "/users/current/heartbeats.bulk"
WakatimeApiUserEndpoint = "/users/current"
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 {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]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"`
@ -62,6 +77,7 @@ type dbConfig struct {
Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
@ -166,12 +182,13 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
func mysqlConnectionString(config *dbConfig) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
config.Charset,
"Local",
)
}
@ -312,6 +329,10 @@ func Load() *Config {
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()
}

View File

@ -1,4 +1,92 @@
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
@ -22,172 +110,38 @@ 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/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
github.com/muety/wakapi/models/user.go:35.43,38.2 1 0
github.com/muety/wakapi/models/user.go:40.33,44.2 1 0
github.com/muety/wakapi/models/user.go:46.45,48.2 1 0
github.com/muety/wakapi/models/user.go:50.45,52.2 1 0
github.com/muety/wakapi/models/heartbeat.go:30.34,32.2 1 1
github.com/muety/wakapi/models/heartbeat.go:34.65,35.28 1 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.45 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,43.44 2 1
github.com/muety/wakapi/models/heartbeat.go:46.2,46.42 1 1
github.com/muety/wakapi/models/heartbeat.go:35.28,37.3 1 1
github.com/muety/wakapi/models/heartbeat.go:39.45,41.3 1 0
github.com/muety/wakapi/models/heartbeat.go:43.44,45.3 1 0
github.com/muety/wakapi/models/heartbeat.go:49.50,50.11 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.15 1 1
github.com/muety/wakapi/models/heartbeat.go:67.2,67.12 1 1
github.com/muety/wakapi/models/heartbeat.go:51.22,52.18 1 1
github.com/muety/wakapi/models/heartbeat.go:53.21,54.17 1 1
github.com/muety/wakapi/models/heartbeat.go:55.23,56.19 1 1
github.com/muety/wakapi/models/heartbeat.go:57.17,58.26 1 1
github.com/muety/wakapi/models/heartbeat.go:59.22,60.18 1 1
github.com/muety/wakapi/models/heartbeat.go:63.15,65.3 1 1
github.com/muety/wakapi/models/heartbeat.go:76.41,78.16 2 0
github.com/muety/wakapi/models/heartbeat.go:81.2,82.10 2 0
github.com/muety/wakapi/models/heartbeat.go:78.16,80.3 1 0
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/interval.go:39.47,40.23 1 0
github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0
github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0
github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/summary.go:41.27,45.2 1 0
github.com/muety/wakapi/models/summary.go:97.29,99.2 1 1
github.com/muety/wakapi/models/summary.go:101.37,108.2 6 1
github.com/muety/wakapi/models/summary.go:110.35,112.2 1 1
github.com/muety/wakapi/models/summary.go:114.57,122.2 1 1
github.com/muety/wakapi/models/summary.go:135.33,140.26 4 1
github.com/muety/wakapi/models/summary.go:147.2,147.37 1 1
github.com/muety/wakapi/models/summary.go:151.2,154.33 2 1
github.com/muety/wakapi/models/summary.go:140.26,141.30 1 1
github.com/muety/wakapi/models/summary.go:141.30,143.4 1 1
github.com/muety/wakapi/models/summary.go:147.37,149.3 1 0
github.com/muety/wakapi/models/summary.go:154.33,160.3 1 1
github.com/muety/wakapi/models/summary.go:163.45,168.30 3 1
github.com/muety/wakapi/models/summary.go:177.2,177.30 1 1
github.com/muety/wakapi/models/summary.go:168.30,169.47 1 1
github.com/muety/wakapi/models/summary.go:169.47,170.32 1 1
github.com/muety/wakapi/models/summary.go:173.4,173.9 1 1
github.com/muety/wakapi/models/summary.go:170.32,172.5 1 1
github.com/muety/wakapi/models/summary.go:180.73,182.55 2 1
github.com/muety/wakapi/models/summary.go:187.2,187.16 1 1
github.com/muety/wakapi/models/summary.go:182.55,183.31 1 1
github.com/muety/wakapi/models/summary.go:183.31,185.4 1 1
github.com/muety/wakapi/models/summary.go:190.88,192.55 2 1
github.com/muety/wakapi/models/summary.go:200.2,200.16 1 1
github.com/muety/wakapi/models/summary.go:192.55,193.31 1 1
github.com/muety/wakapi/models/summary.go:193.31,194.23 1 1
github.com/muety/wakapi/models/summary.go:197.4,197.46 1 1
github.com/muety/wakapi/models/summary.go:194.23,195.13 1 1
github.com/muety/wakapi/models/summary.go:203.70,205.8 2 1
github.com/muety/wakapi/models/summary.go:208.2,208.10 1 1
github.com/muety/wakapi/models/summary.go:205.8,207.3 1 1
github.com/muety/wakapi/models/summary.go:211.71,212.63 1 1
github.com/muety/wakapi/models/summary.go:252.2,258.10 6 1
github.com/muety/wakapi/models/summary.go:212.63,215.45 2 1
github.com/muety/wakapi/models/summary.go:224.3,224.31 1 1
github.com/muety/wakapi/models/summary.go:231.3,231.31 1 1
github.com/muety/wakapi/models/summary.go:248.3,248.16 1 1
github.com/muety/wakapi/models/summary.go:215.45,216.32 1 1
github.com/muety/wakapi/models/summary.go:221.4,221.14 1 1
github.com/muety/wakapi/models/summary.go:216.32,217.24 1 1
github.com/muety/wakapi/models/summary.go:217.24,219.6 1 1
github.com/muety/wakapi/models/summary.go:224.31,226.60 1 1
github.com/muety/wakapi/models/summary.go:226.60,228.5 1 1
github.com/muety/wakapi/models/summary.go:231.31,233.60 1 1
github.com/muety/wakapi/models/summary.go:233.60,234.55 1 1
github.com/muety/wakapi/models/summary.go:234.55,236.6 1 1
github.com/muety/wakapi/models/summary.go:236.11,244.6 1 1
github.com/muety/wakapi/models/summary.go:261.33,263.2 1 1
github.com/muety/wakapi/models/summary.go:265.43,267.2 1 1
github.com/muety/wakapi/models/summary.go:269.38,271.2 1 1
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
github.com/muety/wakapi/utils/summary.go:49.2,49.22 1 0
github.com/muety/wakapi/utils/summary.go:14.58,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.66,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.64,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.39,23.21 2 0
github.com/muety/wakapi/utils/summary.go:24.66,25.24 1 0
github.com/muety/wakapi/utils/summary.go:26.40,28.22 2 0
github.com/muety/wakapi/utils/summary.go:29.31,30.23 1 0
github.com/muety/wakapi/utils/summary.go:31.66,32.42 1 0
github.com/muety/wakapi/utils/summary.go:33.49,35.40 2 0
github.com/muety/wakapi/utils/summary.go:36.41,37.43 1 0
github.com/muety/wakapi/utils/summary.go:38.68,40.42 2 0
github.com/muety/wakapi/utils/summary.go:41.35,42.43 1 0
github.com/muety/wakapi/utils/summary.go:43.26,44.21 1 0
github.com/muety/wakapi/utils/summary.go:45.10,46.39 1 0
github.com/muety/wakapi/utils/summary.go:52.73,59.56 5 0
github.com/muety/wakapi/utils/summary.go:73.2,80.8 2 0
github.com/muety/wakapi/utils/summary.go:59.56,61.3 1 0
github.com/muety/wakapi/utils/summary.go:61.8,63.17 2 0
github.com/muety/wakapi/utils/summary.go:67.3,68.17 2 0
github.com/muety/wakapi/utils/summary.go:63.17,65.4 1 0
github.com/muety/wakapi/utils/summary.go:68.17,70.4 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0
github.com/muety/wakapi/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.54 2 0
github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/utils/auth.go:39.54,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/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:9.48,11.2 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.24,23.2 1 0
github.com/muety/wakapi/utils/common.go:25.56,28.45 3 1
github.com/muety/wakapi/utils/common.go:31.2,31.40 1 1
github.com/muety/wakapi/utils/common.go:28.45,30.3 1 1
github.com/muety/wakapi/utils/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
@ -208,131 +162,299 @@ 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/config/config.go:88.70,90.2 1 0
github.com/muety/wakapi/config/config.go:92.65,94.2 1 0
github.com/muety/wakapi/config/config.go:96.82,106.2 1 0
github.com/muety/wakapi/config/config.go:108.31,110.2 1 0
github.com/muety/wakapi/config/config.go:112.32,114.2 1 0
github.com/muety/wakapi/config/config.go:116.74,117.19 1 0
github.com/muety/wakapi/config/config.go:118.10,119.34 1 0
github.com/muety/wakapi/config/config.go:119.34,128.4 8 0
github.com/muety/wakapi/config/config.go:132.73,133.33 1 0
github.com/muety/wakapi/config/config.go:133.33,141.17 5 0
github.com/muety/wakapi/config/config.go:145.3,146.13 2 0
github.com/muety/wakapi/config/config.go:141.17,143.4 1 0
github.com/muety/wakapi/config/config.go:150.50,151.19 1 0
github.com/muety/wakapi/config/config.go:164.2,164.12 1 0
github.com/muety/wakapi/config/config.go:152.23,156.5 1 0
github.com/muety/wakapi/config/config.go:157.26,160.5 1 0
github.com/muety/wakapi/config/config.go:161.24,162.48 1 0
github.com/muety/wakapi/config/config.go:167.53,177.2 1 1
github.com/muety/wakapi/config/config.go:179.56,181.16 2 1
github.com/muety/wakapi/config/config.go:185.2,192.3 1 1
github.com/muety/wakapi/config/config.go:181.16,183.3 1 0
github.com/muety/wakapi/config/config.go:195.54,197.2 1 1
github.com/muety/wakapi/config/config.go:199.60,201.2 1 0
github.com/muety/wakapi/config/config.go:203.59,205.2 1 0
github.com/muety/wakapi/config/config.go:207.57,209.2 1 0
github.com/muety/wakapi/config/config.go:211.53,213.2 1 0
github.com/muety/wakapi/config/config.go:215.29,217.2 1 1
github.com/muety/wakapi/config/config.go:219.27,221.16 2 0
github.com/muety/wakapi/config/config.go:224.2,227.16 3 0
github.com/muety/wakapi/config/config.go:231.2,231.41 1 0
github.com/muety/wakapi/config/config.go:221.16,223.3 1 0
github.com/muety/wakapi/config/config.go:227.16,229.3 1 0
github.com/muety/wakapi/config/config.go:234.48,246.16 3 0
github.com/muety/wakapi/config/config.go:249.2,251.16 3 0
github.com/muety/wakapi/config/config.go:255.2,255.55 1 0
github.com/muety/wakapi/config/config.go:259.2,259.15 1 0
github.com/muety/wakapi/config/config.go:246.16,248.3 1 0
github.com/muety/wakapi/config/config.go:251.16,253.3 1 0
github.com/muety/wakapi/config/config.go:255.55,257.3 1 0
github.com/muety/wakapi/config/config.go:262.38,263.43 1 0
github.com/muety/wakapi/config/config.go:266.2,266.15 1 0
github.com/muety/wakapi/config/config.go:263.43,265.3 1 0
github.com/muety/wakapi/config/config.go:269.45,270.27 1 0
github.com/muety/wakapi/config/config.go:273.2,273.15 1 0
github.com/muety/wakapi/config/config.go:270.27,272.3 1 0
github.com/muety/wakapi/config/config.go:276.26,278.2 1 0
github.com/muety/wakapi/config/config.go:280.20,282.2 1 0
github.com/muety/wakapi/config/config.go:284.21,289.96 3 0
github.com/muety/wakapi/config/config.go:293.2,301.52 5 0
github.com/muety/wakapi/config/config.go:305.2,305.47 1 0
github.com/muety/wakapi/config/config.go:311.2,311.70 1 0
github.com/muety/wakapi/config/config.go:315.2,316.14 2 0
github.com/muety/wakapi/config/config.go:289.96,291.3 1 0
github.com/muety/wakapi/config/config.go:301.52,303.3 1 0
github.com/muety/wakapi/config/config.go:305.47,306.14 1 0
github.com/muety/wakapi/config/config.go:306.14,308.4 1 0
github.com/muety/wakapi/config/config.go:311.70,313.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:20.116,26.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:28.71,29.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:29.71,31.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:34.107,35.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.2,45.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:49.2,49.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:59.2,60.29 2 0
github.com/muety/wakapi/middlewares/authenticate.go:35.37,36.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:36.58,39.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:45.16,47.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:49.16,50.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:56.3,56.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.44,52.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:52.9,55.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:63.92,65.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:69.2,72.16 4 1
github.com/muety/wakapi/middlewares/authenticate.go:75.2,75.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:65.16,67.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:72.16,74.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:78.92,80.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:84.2,85.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:92.2,92.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:80.16,82.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:85.16,87.3 1 0
github.com/muety/wakapi/middlewares/logging.go:17.79,18.43 1 0
github.com/muety/wakapi/middlewares/logging.go:18.43,23.3 1 0
github.com/muety/wakapi/middlewares/logging.go:26.80,44.2 6 0
github.com/muety/wakapi/middlewares/logging.go:74.52,76.2 1 0
github.com/muety/wakapi/middlewares/logging.go:88.45,89.20 1 0
github.com/muety/wakapi/middlewares/logging.go:89.20,93.3 3 0
github.com/muety/wakapi/middlewares/logging.go:95.54,98.18 3 0
github.com/muety/wakapi/middlewares/logging.go:105.2,106.15 2 0
github.com/muety/wakapi/middlewares/logging.go:98.18,101.17 2 0
github.com/muety/wakapi/middlewares/logging.go:101.17,103.4 1 0
github.com/muety/wakapi/middlewares/logging.go:108.42,109.20 1 0
github.com/muety/wakapi/middlewares/logging.go:109.20,111.3 1 0
github.com/muety/wakapi/middlewares/logging.go:113.36,115.2 1 0
github.com/muety/wakapi/middlewares/logging.go:116.42,118.2 1 0
github.com/muety/wakapi/middlewares/logging.go:119.40,121.2 1 0
github.com/muety/wakapi/middlewares/logging.go:122.52,124.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
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
@ -422,22 +544,33 @@ 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/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
github.com/muety/wakapi/services/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
@ -457,102 +590,26 @@ github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/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/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.88,66.93 2 0
github.com/muety/wakapi/services/user.go:72.2,72.38 1 0
github.com/muety/wakapi/services/user.go:66.93,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/user.go:75.73,78.2 2 0
github.com/muety/wakapi/services/user.go:80.78,84.2 3 0
github.com/muety/wakapi/services/user.go:86.79,89.2 2 0
github.com/muety/wakapi/services/user.go:91.99,94.2 2 0
github.com/muety/wakapi/services/user.go:96.106,99.96 3 0
github.com/muety/wakapi/services/user.go:104.2,104.68 1 0
github.com/muety/wakapi/services/user.go:99.96,101.3 1 0
github.com/muety/wakapi/services/user.go:101.8,103.3 1 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
github.com/muety/wakapi/services/misc.go:81.24,82.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

View File

@ -49,6 +49,7 @@
"DataWeave": "#003a52",
"DM": "#447265",
"Dockerfile": "#384d54",
"Docker": "#384d54",
"Dogescript": "#cca760",
"Dylan": "#6c616e",
"E": "#ccce35",

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

11
go.mod
View File

@ -3,26 +3,29 @@ 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/jinzhu/configor v1.2.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/mitchellh/hashstructure/v2 v2.0.1
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/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/yaml.v2 v2.2.8 // indirect
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

82
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=
@ -65,6 +74,7 @@ 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=
@ -74,6 +84,23 @@ github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgO
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=
@ -219,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=
@ -246,6 +275,12 @@ 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=
@ -273,7 +308,6 @@ 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 v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0=
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=
@ -356,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=
@ -366,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=
@ -398,12 +435,18 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
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=
@ -448,6 +491,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA
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=
@ -464,7 +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=
@ -474,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=
@ -497,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=
@ -518,14 +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=
@ -563,12 +634,17 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/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=

114
main.go
View File

@ -7,8 +7,10 @@ import (
"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"
@ -18,7 +20,6 @@ import (
"github.com/gorilla/mux"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
@ -56,6 +57,26 @@ var (
// 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()
@ -96,9 +117,9 @@ func main() {
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)
@ -122,72 +143,63 @@ func main() {
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
// TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
// API Handlers
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
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)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// 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()
summaryApiRouter := apiRouter.PathPrefix("/summary").Subrouter()
heartbeatApiRouter := apiRouter.PathPrefix("/heartbeat").Subrouter()
healthApiRouter := apiRouter.PathPrefix("/health").Subrouter()
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1/users/{user}").Subrouter()
shieldsV1Router := compatRouter.PathPrefix("/shields/v1/{user}").Subrouter()
// Middlewares
// Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware(
// Use logbuch here once https://github.com/emvi/logbuch/issues/4 is realized
log.New(os.Stdout, "", log.LstdFlags),
)
corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService,
[]string{"/api/health", "/api/compat/shields/v1"},
).Handler
wakatimeRelayMiddleware := customMiddleware.NewWakatimeRelayMiddleware().Handler
loggingMiddleware := middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"})
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware)
settingsRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware)
heartbeatApiRouter.Use(wakatimeRelayMiddleware)
// Route registrations
homeHandler.RegisterRoutes(publicRouter)
loginHandler.RegisterRoutes(publicRouter)
imprintHandler.RegisterRoutes(publicRouter)
summaryHandler.RegisterRoutes(summaryRouter)
settingsHandler.RegisterRoutes(settingsRouter)
homeHandler.RegisterRoutes(rootRouter)
loginHandler.RegisterRoutes(rootRouter)
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
// API Route registrations
summaryHandler.RegisterAPIRoutes(summaryApiRouter)
healthHandler.RegisterAPIRoutes(healthApiRouter)
heartbeatHandler.RegisterAPIRoutes(heartbeatApiRouter)
wakatimeV1AllHandler.RegisterAPIRoutes(wakatimeV1Router)
wakatimeV1SummariesHandler.RegisterAPIRoutes(wakatimeV1Router)
shieldV1BadgeHandler.RegisterAPIRoutes(shieldsV1Router)
// 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(pkger.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
listen(router)

View File

@ -2,7 +2,6 @@ package middlewares
import (
"context"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
@ -12,19 +11,30 @@ import (
)
type AuthenticateMiddleware struct {
config *conf.Config
userSrvc services.IUserService
whitelistPaths []string
config *conf.Config
userSrvc services.IUserService
optionalForPaths []string
redirectTarget string // optional
}
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
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)
@ -32,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)
@ -46,12 +49,18 @@ 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 {
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
}
return
}
@ -60,6 +69,15 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
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 {

View File

@ -24,7 +24,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)
@ -45,7 +45,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)

View File

@ -63,7 +63,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
go m.send(
http.MethodPost,
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsEndpoint,
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
bytes.NewReader(body),
headers,
)

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

@ -4,21 +4,25 @@ package middlewares
import (
"io"
"log"
"net/http"
"strings"
"time"
)
type logFunc func(string, ...interface{})
type LoggingMiddleware struct {
handler http.Handler
output *log.Logger
handler http.Handler
logFunc logFunc
excludePrefixes []string
}
func NewLoggingMiddleware(output *log.Logger) func(http.Handler) http.Handler {
func NewLoggingMiddleware(logFunc logFunc, excludePrefixes []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &LoggingMiddleware{
handler: h,
output: output,
handler: h,
logFunc: logFunc,
excludePrefixes: excludePrefixes,
}
}
}
@ -31,9 +35,15 @@ func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
end := time.Now()
duration := end.Sub(start)
lg.output.Printf(
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n",
time.Now().Format(time.RFC3339Nano),
path := strings.ToLower(r.URL.Path)
for _, prefix := range lg.excludePrefixes {
if strings.HasPrefix(path, prefix) {
return
}
}
lg.logFunc(
"[request] status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s",
ww.Status(),
r.Method,
r.URL.String(),

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,55 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20210206_drop_badges_column_add_sharing_flags",
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database or already migrated, nothing to migrate
return nil
}
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if cfg.Db.Dialect == config.SQLDialectSqlite {
logbuch.Info("not attempting to drop column 'badges_enabled' on sqlite")
return nil
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
}
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
return nil
},
}
registerPostMigration(f)
}

View File

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

View File

@ -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/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
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 {
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)
}
}
}

View File

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

View File

@ -1,25 +0,0 @@
package common
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
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 {
logbuch.Fatal(result.Error.Error())
}
if result.RowsAffected > 0 {
logbuch.Info("migrated %+v rows for custom language %+s", 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]
}

View File

@ -10,11 +10,31 @@ 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)
@ -25,6 +45,11 @@ func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
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

@ -24,8 +24,18 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
args := m.Called(signup)
func (m *UserServiceMock) 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)
}
@ -34,6 +44,11 @@ func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
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)
@ -53,3 +68,7 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

@ -2,8 +2,8 @@ package models
type Alias struct {
ID uint `gorm:"primary_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
User *User `json:"-" gorm:"not null"`
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"`

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

@ -19,11 +19,13 @@ type Heartbeat struct {
Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
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"`
}

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

@ -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"
@ -31,6 +32,10 @@ type Interval struct {
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)

View File

@ -14,44 +14,14 @@ 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"
// https://wakatime.com/developers/#summaries
IntervalWakatimeToday string = "Today"
IntervalWakatimeYesterday string = "Yesterday"
IntervalWakatimeLast7Days string = "Last 7 Days"
IntervalWakatimeLast7DaysYesterday string = "Last 7 Days from Yesterday"
IntervalWakatimeLast14Days string = "Last 14 Days"
IntervalWakatimeLast30Days string = "Last 30 Days"
IntervalWakatimeThisWeek string = "This Week"
IntervalWakatimeLastWeek string = "Last Week"
IntervalWakatimeThisMonth string = "This Month"
IntervalWakatimeLastMonth string = "Last Month"
)
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"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
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"`
@ -67,7 +37,7 @@ type SummaryItem struct {
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 {
@ -77,12 +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 {
@ -121,6 +93,10 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
}
}
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
}
/* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"

View File

@ -1,13 +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:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`
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 {
@ -32,6 +39,11 @@ type TimeByUser struct {
Time CustomTime
}
type CountByUser struct {
User string
Count int64
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat

View File

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

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.
@ -44,6 +64,57 @@ func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
return result, nil
}
func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
var counts []*models.CountByUser
userIds := make([]string, len(users))
for i, u := range users {
userIds[i] = u.ID
}
if err := r.db.
Model(&models.User{}).
Select("users.id as user, count(heartbeats.id) as count").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Where("user_id in ?", userIds).
Group("user").
Find(&counts).Error; err != nil {
return counts, err
}
return counts, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db.
Where("time <= ?", t).

View File

@ -19,6 +19,11 @@ 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
}
@ -44,9 +49,14 @@ type ISummaryRepository interface {
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

@ -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(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,10 +1,12 @@
package routes
package api
import (
"encoding/json"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -12,15 +14,17 @@ import (
"github.com/muety/wakapi/models"
)
type HeartbeatHandler struct {
type HeartbeatApiHandler struct {
config *conf.Config
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *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,13 +34,24 @@ type heartbeatResponseVm struct {
Responses [][]interface{} `json:"responses"`
}
func (h *HeartbeatHandler) RegisterRoutes(router *mux.Router) {}
func (h *HeartbeatHandler) RegisterAPIRoutes(router *mux.Router) {
router.Methods(http.MethodPost).HandlerFunc(h.ApiPost)
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)
}
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
// @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"))
@ -58,7 +73,7 @@ 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
}
@ -67,10 +82,21 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError)
logbuch.Error(err.Error())
w.Write([]byte(conf.ErrInternalServerError))
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

@ -10,6 +10,7 @@ import (
"net/http"
"regexp"
"strings"
"time"
)
const (
@ -31,13 +32,23 @@ func NewBadgeHandler(summaryService services.ISummaryService, userService servic
}
}
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {}
func (h *BadgeHandler) RegisterAPIRoutes(router *mux.Router) {
router.Methods(http.MethodGet).HandlerFunc(h.ApiGet)
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)
}
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
// @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)
@ -46,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]
@ -60,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
@ -90,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

View File

@ -3,6 +3,7 @@ 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"
@ -14,23 +15,36 @@ import (
type AllTimeHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
func NewAllTimeHandler(userService services.IUserService, summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: conf.Get(),
}
}
func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {}
func (h *AllTimeHandler) RegisterAPIRoutes(router *mux.Router) {
router.Path("/all_time_since_today").Methods(http.MethodGet).HandlerFunc(h.ApiGet)
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)
}
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
// @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)

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

@ -4,6 +4,7 @@ import (
"errors"
"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"
@ -15,20 +16,24 @@ import (
type SummariesHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
func NewSummariesHandler(userService services.IUserService, summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: conf.Get(),
}
}
func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {}
func (h *SummariesHandler) RegisterAPIRoutes(router *mux.Router) {
router.Path("/summaries").Methods(http.MethodGet).HandlerFunc(h.ApiGet)
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)
}
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
@ -36,7 +41,19 @@ func (h *SummariesHandler) RegisterAPIRoutes(router *mux.Router) {
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108.
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
// @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)
@ -70,12 +87,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
var start, end time.Time
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveInterval(rangeParam); err == nil {
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.ResolveInterval(startParam); err == nil && startParam == endParam {
} 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 {

View File

@ -4,5 +4,4 @@ import "github.com/gorilla/mux"
type Handler interface {
RegisterRoutes(router *mux.Router)
RegisterAPIRoutes(router *mux.Router)
}

View File

@ -1,34 +0,0 @@
package routes
import (
"fmt"
"github.com/gorilla/mux"
"gorm.io/gorm"
"net/http"
)
type HealthHandler struct {
db *gorm.DB
}
func NewHealthHandler(db *gorm.DB) *HealthHandler {
return &HealthHandler{db: db}
}
func (h *HealthHandler) RegisterRoutes(router *mux.Router) {}
func (h *HealthHandler) RegisterAPIRoutes(router *mux.Router) {
router.Methods(http.MethodGet).HandlerFunc(h.ApiGet)
}
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)))
}

View File

@ -32,8 +32,6 @@ func (h *HomeHandler) RegisterRoutes(router *mux.Router) {
router.Path("/").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
func (h *HomeHandler) RegisterAPIRoutes(router *mux.Router) {}
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()

View File

@ -25,8 +25,6 @@ func (h *ImprintHandler) RegisterRoutes(router *mux.Router) {
router.Path("/imprint").Methods(http.MethodGet).HandlerFunc(h.GetImprint)
}
func (h *ImprintHandler) RegisterAPIRoutes(router *mux.Router) {}
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()

View File

@ -32,8 +32,6 @@ func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
}
func (h *LoginHandler) RegisterAPIRoutes(router *mux.Router) {}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
@ -123,6 +121,12 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
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
@ -146,7 +150,9 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
numUsers, _ := h.userSrvc.Count()
_, created, err := h.userSrvc.CreateOrGet(&signup, numUsers == 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
@ -162,8 +168,11 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
numUsers, _ := h.userSrvc.Count()
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
TotalUsers: int(numUsers),
}
}

View File

@ -8,6 +8,7 @@ import (
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"net/http"
"path"
"strings"
)
@ -16,20 +17,24 @@ func Init() {
loadTemplates()
}
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template
func loadTemplates() {
const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"capitalize": utils.Capitalize,
"toRunes": utils.ToRunes,
"entityTypes": models.SummaryTypes,
"typeName": typeName,
"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
},
@ -99,3 +104,7 @@ func typeName(t uint8) string {
}
return "unknown"
}
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -7,9 +7,11 @@ import (
"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"
"strconv"
@ -20,15 +22,25 @@ type SettingsHandler struct {
config *conf.Config
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.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *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,
@ -36,25 +48,21 @@ func NewSettingsHandler(userService services.IUserService, summaryService servic
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
router.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
router.Path("/credentials").Methods(http.MethodPost).HandlerFunc(h.PostCredentials)
router.Path("/aliases").Methods(http.MethodPost).HandlerFunc(h.PostAlias)
router.Path("/aliases/delete").Methods(http.MethodPost).HandlerFunc(h.DeleteAlias)
router.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(h.PostLanguageMapping)
router.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(h.DeleteLanguageMapping)
router.Path("/reset").Methods(http.MethodPost).HandlerFunc(h.PostResetApiKey)
router.Path("/badges").Methods(http.MethodPost).HandlerFunc(h.PostToggleBadges)
router.Path("/wakatime_integration").Methods(http.MethodPost).HandlerFunc(h.PostSetWakatimeApiKey)
router.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(h.PostRegenerateSummaries)
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) RegisterAPIRoutes(router *mux.Router) {}
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
@ -63,7 +71,77 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
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()
}
@ -72,41 +150,29 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
return http.StatusBadRequest, "", "missing parameters"
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
return http.StatusBadRequest, "", "missing parameters"
}
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
return http.StatusUnauthorized, "", "invalid credentials"
}
if !credentials.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
return http.StatusBadRequest, "", "invalid parameters"
}
user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
login := &models.Login{
@ -115,75 +181,56 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
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)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
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 {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
return http.StatusBadRequest, "", "invalid input"
}
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping not found"))
return
} else if mapping.UserID != user.ID {
w.WriteHeader(http.StatusForbidden)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("not allowed to delete mapping"))
return
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal sever error"
}
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
return http.StatusOK, "settings updated", ""
}
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
w.WriteHeader(http.StatusConflict)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
}
func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -196,19 +243,15 @@ func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
}
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("aliases not found"))
return
return http.StatusNotFound, "", "aliases not found"
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete aliases"))
return
return http.StatusInternalServerError, "", "could not delete aliases"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("aliases deleted successfully"))
return http.StatusOK, "aliases deleted successfully", ""
}
func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -228,32 +271,63 @@ func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
}
if _, err := h.aliasSrvc.Create(alias); err != nil {
w.WriteHeader(http.StatusBadRequest)
// TODO: distinguish between bad request, conflict and server error
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid input"))
return
return http.StatusBadRequest, "", "invalid input"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("alias added successfully"))
return http.StatusOK, "alias added successfully", ""
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionDeleteLanguageMapping(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 {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
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"
}
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
return http.StatusOK, "mapping deleted successfully", ""
}
func (h *SettingsHandler) PostSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
return http.StatusConflict, "", "mapping already exists"
}
return http.StatusOK, "mapping added successfully", ""
}
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -263,57 +337,117 @@ func (h *SettingsHandler) PostSetWakatimeApiKey(w http.ResponseWriter, r *http.R
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to connect to WakaTime, API key invalid?"))
return
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
}
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", conf.ErrInternalServerError
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("Wakatime API Key updated successfully"))
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 _, err := h.userSrvc.ToggleBadges(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
if user.WakatimeApiKey == "" {
return http.StatusForbidden, "", "not connected to wakatime"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
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) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
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)
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)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
return
if err := h.regenerateSummaries(user); err != nil {
return http.StatusInternalServerError, "", "failed to regenerate summaries"
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
return
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()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated this may take a few seconds"))
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 {
@ -326,7 +460,7 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
request, err := http.NewRequest(
http.MethodGet,
conf.WakatimeApiUrl+conf.WakatimeApiUserEndpoint,
conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
nil,
)
if err != nil {
@ -343,6 +477,21 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
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)

View File

@ -3,8 +3,10 @@ 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"
@ -12,33 +14,24 @@ import (
type SummaryHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
}
}
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
router.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
func (h *SummaryHandler) RegisterAPIRoutes(router *mux.Router) {
router.Methods(http.MethodGet).HandlerFunc(h.ApiGet)
}
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)
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) {
@ -46,13 +39,14 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
rawQuery := r.URL.RawQuery
q := r.URL.Query()
if q.Get("interval") == "" && q.Get("from") == "" {
q.Set("interval", "today")
r.URL.RawQuery = q.Encode()
}
summary, err, status := h.loadUserSummary(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
@ -68,34 +62,17 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{
Summary: 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
}
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),

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
}

View File

@ -85,7 +85,7 @@ def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
def randomword(length: int) -> str:
letters = string.ascii_lowercase
letters = string.ascii_lowercase + 'äöü💩' # test utf8 and utf8mb4 characters as well
return ''.join(random.choice(letters) for _ in range(length))

View File

@ -97,10 +97,10 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
logbuch.Error(err.Error())
return err
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, len(userIds))
for i, u := range allUsers {
users = make([]*models.User, 0)
for _, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users[i] = u
users = append(users, u)
}
}
} else {

View File

@ -22,10 +22,26 @@ func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, 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 {
@ -34,6 +50,10 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID)
}
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()
}

View File

@ -0,0 +1,11 @@
package imports
import (
"github.com/muety/wakapi/models"
"time"
)
type HeartbeatImporter interface {
Import(*models.User, time.Time, time.Time) <-chan *models.Heartbeat
ImportAll(*models.User) <-chan *models.Heartbeat
}

View File

@ -0,0 +1,271 @@
package imports
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"golang.org/x/sync/semaphore"
"net/http"
"time"
)
const OriginWakatime = "wakatime"
const maxWorkers = 6
type WakatimeHeartbeatImporter struct {
ApiKey string
}
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
return &WakatimeHeartbeatImporter{
ApiKey: apiKey,
}
}
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
out := make(chan *models.Heartbeat)
go func(user *models.User, out chan *models.Heartbeat) {
startDate, endDate, err := w.fetchRange()
if err != nil {
logbuch.Error("failed to fetch date range while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
if startDate.Before(minFrom) {
startDate = minFrom
}
if endDate.After(maxTo) {
endDate = maxTo
}
userAgents, err := w.fetchUserAgents()
if err != nil {
logbuch.Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
machinesNames, err := w.fetchMachineNames()
if err != nil {
logbuch.Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
days := generateDays(startDate, endDate)
c := atomic.NewUint32(uint32(len(days)))
ctx := context.TODO()
sem := semaphore.NewWeighted(maxWorkers)
for _, d := range days {
if err := sem.Acquire(ctx, 1); err != nil {
logbuch.Error("failed to acquire semaphore %v", err)
break
}
go func(day time.Time) {
defer sem.Release(1)
d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d)
if err != nil {
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", day, user.ID, err)
}
for _, h := range heartbeats {
out <- mapHeartbeat(h, userAgents, machinesNames, user)
}
if c.Dec() == 0 {
close(out)
}
}(d)
}
}(user, out)
return out
}
func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.Heartbeat {
return w.Import(user, time.Time{}, time.Now())
}
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiHeartbeatsUrl, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Add("date", day)
req.URL.RawQuery = q.Encode()
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var heartbeatsData wakatime.HeartbeatsViewModel
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
return nil, err
}
return heartbeatsData.Data, nil
}
// https://wakatime.com/api/v1/users/current/all_time_since_today
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
notime := time.Time{}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiAllTimeUrl, nil)
if err != nil {
return notime, notime, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return notime, notime, err
}
var allTimeData wakatime.AllTimeViewModel
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
return notime, notime, err
}
startDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.StartDate)
if err != nil {
return notime, notime, err
}
endDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.EndDate)
if err != nil {
return notime, notime, err
}
return startDate, endDate, nil
}
// https://wakatime.com/api/v1/users/current/user_agents
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiUserAgentsUrl, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var userAgentsData wakatime.UserAgentsViewModel
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
return nil, err
}
userAgents := make(map[string]*wakatime.UserAgentEntry)
for _, ua := range userAgentsData.Data {
userAgents[ua.Id] = ua
}
return userAgents, nil
}
// https://wakatime.com/api/v1/users/current/machine_names
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiMachineNamesUrl, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var machineData wakatime.MachineViewModel
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
return nil, err
}
machines := make(map[string]*wakatime.MachineEntry)
for _, ma := range machineData.Data {
machines[ma.Id] = ma
}
return machines, nil
}
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
return req
}
func mapHeartbeat(
entry *wakatime.HeartbeatEntry,
userAgents map[string]*wakatime.UserAgentEntry,
machineNames map[string]*wakatime.MachineEntry,
user *models.User,
) *models.Heartbeat {
ua := userAgents[entry.UserAgentId]
if ua == nil {
ua = &wakatime.UserAgentEntry{
Editor: "unknown",
Os: "unknown",
}
}
ma := machineNames[entry.MachineNameId]
if ma == nil {
ma = &wakatime.MachineEntry{
Id: entry.MachineNameId,
Value: entry.MachineNameId,
}
}
return (&models.Heartbeat{
User: user,
UserID: user.ID,
Entity: entry.Entity,
Type: entry.Type,
Category: entry.Category,
Project: entry.Project,
Branch: entry.Branch,
Language: entry.Language,
IsWrite: entry.IsWrite,
Editor: ua.Editor,
OperatingSystem: ua.Os,
Machine: ma.Value,
Time: entry.Time,
Origin: OriginWakatime,
OriginId: entry.Id,
}).Hashed()
}
func generateDays(from, to time.Time) []time.Time {
days := make([]time.Time, 0)
from = utils.StartOfDay(from)
to = utils.StartOfDay(to.Add(24 * time.Hour))
for d := from; d.Before(to); d = d.Add(24 * time.Hour) {
days = append(days, d)
}
return days
}

View File

@ -22,6 +22,17 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
return srv.repository.GetString(key)
}
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
kv, err := srv.repository.GetString(key)
if err != nil {
return &models.KeyStringValue{
Key: key,
Value: "",
}
}
return kv
}
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
return srv.repository.PutString(kv)
}

View File

@ -46,7 +46,7 @@ func (srv *MiscService) ScheduleCountTotalTime() {
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
s.Every(1).Hour().Do(srv.runCountTotalTime)
s.StartBlocking()
}

View File

@ -26,14 +26,20 @@ type IAliasService interface {
}
type IHeartbeatService interface {
Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error
Count() (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
DeleteBefore(time.Time) error
}
type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error)
MustGetString(string) *models.KeyStringValue
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
@ -59,10 +65,13 @@ type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error)
GetActive() ([]*models.User, error)
Count() (int64, error)
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
Delete(*models.User) error
ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
SetWakatimeApiKey(*models.User, string) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
FlushCache()
}

View File

@ -56,11 +56,21 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
return srv.repository.GetAll()
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
func (srv *UserService) GetActive() ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.Config.App.InactiveDays))
return srv.repository.GetByLastActiveAfter(minDate)
}
func (srv *UserService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
u := &models.User{
ID: signup.Username,
ApiKey: uuid.NewV4().String(),
Password: signup.Password,
IsAdmin: isAdmin,
}
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {
@ -83,11 +93,6 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
return srv.Update(user)
}
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
srv.cache.Flush()
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
srv.cache.Flush()
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
@ -103,3 +108,12 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi
}
return srv.repository.UpdateField(user, "password", user.Password)
}
func (srv *UserService) Delete(user *models.User) error {
srv.cache.Flush()
return srv.repository.Delete(user)
}
func (srv *UserService) FlushCache() {
srv.cache.Flush()
}

View File

@ -1,3 +1,12 @@
body {
font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
}
.bg-gray-850 {
background-color: #242b3a;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}

View File

@ -28,6 +28,9 @@ let charts = []
let showTopN = []
let resizeCount = 0
Chart.defaults.global.defaultFontColor = "#E2E8F0"
Chart.defaults.global.defaultColor = "#E2E8F0"
String.prototype.toHHMMSS = function () {
var sec_num = parseInt(this, 10)
var hours = Math.floor(sec_num / 3600)
@ -46,14 +49,20 @@ String.prototype.toHHMMSS = function () {
return hours + ':' + minutes + ':' + seconds
}
String.prototype.toHHMM = function () {
const sec_num = parseInt(this, 10)
const hours = Math.floor(sec_num / 3600)
const minutes = Math.floor((sec_num - (hours * 3600)) / 60)
return hours + ':' + minutes
}
function draw(subselection) {
function getTooltipOptions(key, type) {
function getTooltipOptions(key) {
return {
mode: 'single',
callbacks: {
label: (item) => {
let idx = type === 'pie' ? item.index : item.datasetIndex
let d = wakapiData[key][idx]
let d = wakapiData[key][item.index]
return `${d.key}: ${d.total.toString().toHHMMSS()}`
},
title: () => 'Total Time'
@ -73,18 +82,29 @@ function draw(subselection) {
? new Chart(projectsCanvas.getContext('2d'), {
type: 'horizontalBar',
data: {
datasets: wakapiData.projects
datasets: [{
data: wakapiData.projects
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
borderWidth: 2
}],
labels: wakapiData.projects
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => {
return {
label: p.key,
data: [parseInt(p.total) / 60],
backgroundColor: getRandomColor(p.key)
}
})
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('projects', 'bar'),
legend: {
display: false
},
@ -96,6 +116,7 @@ function draw(subselection) {
}
}]
},
tooltips: getTooltipOptions('projects'),
maintainAspectRatio: false,
onResize: onChartResize
}
@ -110,14 +131,25 @@ function draw(subselection) {
data: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => osColors[p.key.toLowerCase()] || getRandomColor(p.key))
backgroundColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('operatingSystems', 'pie'),
tooltips: getTooltipOptions('operatingSystems'),
maintainAspectRatio: false,
onResize: onChartResize
}
@ -132,14 +164,25 @@ function draw(subselection) {
data: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
backgroundColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('editors', 'pie'),
tooltips: getTooltipOptions('editors'),
maintainAspectRatio: false,
onResize: onChartResize
}
@ -154,14 +197,25 @@ function draw(subselection) {
data: wakapiData.languages
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
backgroundColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.languages
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('languages', 'pie'),
tooltips: getTooltipOptions('languages'),
maintainAspectRatio: false,
onResize: onChartResize
}
@ -176,14 +230,25 @@ function draw(subselection) {
data: wakapiData.machines
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
backgroundColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.machines
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('machines', 'pie'),
tooltips: getTooltipOptions('machines'),
maintainAspectRatio: false,
onResize: onChartResize
}
@ -259,8 +324,11 @@ function equalizeHeights() {
}
function getTotal(items) {
let total = items.reduce((acc, d) => acc + d.total, 0)
document.getElementById('total-span').innerText = total.toString().toHHMMSS()
const el = document.getElementById('total-span')
if (!el) return
const total = items.reduce((acc, d) => acc + d.total, 0)
const formatted = total.toString().toHHMM()
el.innerText = `${formatted.split(':')[0]} hours, ${formatted.split(':')[1]} minutes`
}
function getRandomColor(seed) {
@ -274,6 +342,20 @@ function getRandomColor(seed) {
return color
}
// https://stackoverflow.com/a/5624139/3112139
function hexToRgb(hex) {
var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, function(m, r, g, b) {
return r + r + g + g + b + b;
});
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function showApiKeyPopup(event) {
const el = document.getElementById('api-key-popup')
el.classList.remove('hidden')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

150
static/assets/images/logo-gh.svg Executable file
View File

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="275.9418mm"
height="107.06042mm"
viewBox="0 0 275.9418 107.06042"
version="1.1"
id="svg1621"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="logo-text-only.svg">
<defs
id="defs1615">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath20">
<path
d="M 0,700 H 1100 V 0 H 0 Z"
id="path18" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-275.67803"
inkscape:cy="605.17604"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1346"
inkscape:window-height="1198"
inkscape:window-x="149"
inkscape:window-y="113"
inkscape:window-maximized="0" />
<metadata
id="metadata1618">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-178.77314,11.952827)">
<g
id="g14"
transform="matrix(0.35277777,0,0,-0.35277777,105.01126,165.48796)">
<g
id="g16"
clip-path="url(#clipPath20)">
<g
id="g22"
transform="translate(600.9092,345.958)" />
<g
id="g26"
transform="translate(706.707,299.5566)" />
<g
id="g30"
transform="translate(810.2764,345.958)" />
<g
id="g34"
transform="translate(930.5527,345.958)" />
<g
id="g40"
transform="translate(503.8398,367.5195)">
<path
d="m 8.7266003,-16.286056 c 0,-3.170064 -0.10674,-6.340128 -0.32021,-9.492657 -0.85541,-13.857403 -3.56194,-27.197135 -7.89009005,-39.789715 C -18.95161,-122.31318 -71.13644,-163.86557 -133.5283,-167.69511 c -3.13424,-0.23101 -6.30506,-0.32097 -9.49342,-0.32097 -3.1876,0 -6.33937,0.09 -9.4919,0.32097 -76.24773,4.68419 -137.23296,65.6679 -141.91639,141.916397 -0.23101,3.152529 -0.32098,6.322593 -0.32098,9.492657 0,3.188361 0.09,6.358425 0.32098,9.493419 4.68343,76.22944 65.66866,137.249737 141.91639,141.933927 3.15253,0.21348 6.3043,0.32098 9.4919,0.32098 3.18836,0 6.35918,-0.1075 9.49342,-0.32098 V 94.354359 h -18.98532 V 110.75818 C -215.22645,106.14489 -265.43361,55.901133 -270.06444,-6.792637 h 14.65793 v -18.986076 h -14.65793 c 4.63083,-62.712067 54.83799,-112.920757 117.55082,-117.551577 v 16.72479 h 18.98532 v -16.72479 c 46.32654,3.42013 85.86619,31.73876 105.20907,71.59937 6.83873,14.070117 11.14859,29.584217 12.34251,45.952207 h -0.0358 l 8.8697703,18.986076 h 15.54917 c 0.21347,-3.134994 0.32021,-6.305058 0.32021,-9.493419"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path42" />
</g>
<g
id="g44"
transform="translate(504.2754,431.2373)">
<path
d="m 11.6231,-31.425333 -17.1029497,8.252992 -4.6666503,-9.703842 -9.61922,-20.006168 -37.98968,-78.855149 -0.0572,-0.0854 h -37.47886 l -0.0282,0.0572 0.0282,0.0854 14.77,30.64853 3.78456,7.85425 0.19898,0.42618 19.2087,39.869009 7.37013,15.25336 2.27653,4.752808 11.72497,24.386926 -5.77672,2.761416 -9.59024,4.638449 49.9708203,23.249422 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path46" />
</g>
<g
id="g48"
transform="translate(258.5654,361.2139)">
<path
d="m 65.50352,-19.287853 v -0.0183 L 46.73396,-58.308305 12.56848,12.654468 2.9523203,32.646912 H 40.49066 l 9.63446,-19.992444 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path50" />
</g>
<g
id="g52"
transform="translate(320.9248,367.3867)">
<path
d="M 52.18699,-16.254503 27.28545,-67.966647 v -0.01906 h -37.57494 v 0.01906 l 18.7870903,39.002155 19.2506297,39.94677 5.65168,11.764611 5.66921,-11.764611 9.2647,-19.232331 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path54" />
</g>
<g
id="g56"
transform="translate(383.7656,359.833)">
<path
d="m 38.75609,-18.959752 -3.78456,-7.854257 -14.7982,-30.591345 -0.0282,-0.08539 -25.2979997,52.560696 -9.1358503,18.95253 -9.61769,20.005406 h 37.10759 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path58" />
</g>
</g>
</g>
<g
aria-label="akapi"
transform="matrix(0.35277777,0,0,0.35277777,121.30914,170.32937)"
id="text856"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:7.5px;fill:#2f855a;fill-opacity:1;stroke:none;stroke-width:0.75">
<path
d="m 565.57956,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00781,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03906,-11.51563 -4.03907,-4.38281 -12.28907,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55468,3.52343 -4.55468,9.02343 h -20.88282 q 0,-7.64843 5.07032,-14.26562 5.07031,-6.70313 13.75,-10.48438 8.76562,-3.78125 19.50781,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94531,-15.03906 q 6.1875,0 11.60156,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 H 551.228 q -11.60157,0 -17.44532,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74219,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path851" />
<path
d="m 642.94675,-353.28607 -9.28125,9.53906 v 30.33594 h -20.88282 v -132 h 20.88282 v 76.14062 l 6.53125,-8.16406 25.69531,-28.96094 h 25.09375 l -34.54688,38.75782 38.24219,54.22656 h -24.14844 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path853" />
<path
d="m 767.6655,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00782,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03907,-11.51563 -4.03906,-4.38281 -12.28906,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55469,3.52343 -4.55469,9.02343 h -20.88281 q 0,-7.64843 5.07031,-14.26562 5.07032,-6.70313 13.75,-10.48438 8.76563,-3.78125 19.50782,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94532,-15.03906 q 6.1875,0 11.60157,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 h -11.25782 q -11.60156,0 -17.44531,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74218,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path855" />
<path
d="m 896.25143,-358.95795 q 0,21.57032 -9.79687,34.46094 -9.79688,12.80469 -26.29688,12.80469 -15.29687,0 -24.49218,-10.05469 v 44.08594 h -20.88282 v -128.73438 h 19.25 l 0.85938,9.45313 q 9.19531,-11.17188 25.00781,-11.17188 17.01563,0 26.64063,12.71875 9.71093,12.63282 9.71093,35.14844 z m -20.79687,-1.80468 q 0,-13.92188 -5.58594,-22.08594 -5.5,-8.16406 -15.8125,-8.16406 -12.80469,0 -18.39062,10.57031 v 41.25 q 5.67187,10.82812 18.5625,10.82812 9.96875,0 15.55468,-7.99218 5.67188,-8.07813 5.67188,-24.40625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path857" />
<path
d="m 943.62643,-313.41107 h -20.88281 v -92.98438 h 20.88281 z m -22.17187,-117.13281 q 0,-4.8125 3.00781,-7.99219 3.09375,-3.17969 8.76563,-3.17969 5.67187,0 8.76562,3.17969 3.09375,3.17969 3.09375,7.99219 0,4.72656 -3.09375,7.90625 -3.09375,3.09375 -8.76562,3.09375 -5.67188,0 -8.76563,-3.09375 -3.00781,-3.17969 -3.00781,-7.90625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path859" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

150
static/assets/images/logo.svg Executable file
View File

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="275.9418mm"
height="107.06042mm"
viewBox="0 0 275.9418 107.06042"
version="1.1"
id="svg900"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="logo-dark-bg.svg">
<defs
id="defs894">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath20">
<path
d="M 0,700 H 1100 V 0 H 0 Z"
id="path18" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="501.46484"
inkscape:cy="865.17604"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1346"
inkscape:window-height="1198"
inkscape:window-x="149"
inkscape:window-y="113"
inkscape:window-maximized="0" />
<metadata
id="metadata897">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(26.845906,80.744493)">
<g
id="g14"
transform="matrix(0.35277777,0,0,-0.35277777,-100.60779,96.696294)">
<g
id="g16"
clip-path="url(#clipPath20)">
<g
id="g22"
transform="translate(600.9092,345.958)" />
<g
id="g26"
transform="translate(706.707,299.5566)" />
<g
id="g30"
transform="translate(810.2764,345.958)" />
<g
id="g34"
transform="translate(930.5527,345.958)" />
<g
id="g40"
transform="translate(503.8398,367.5195)">
<path
d="m 8.7266003,-16.286056 c 0,-3.170064 -0.10674,-6.340128 -0.32021,-9.492657 -0.85541,-13.857403 -3.56194,-27.197135 -7.89009005,-39.789715 C -18.95161,-122.31318 -71.13644,-163.86557 -133.5283,-167.69511 c -3.13424,-0.23101 -6.30506,-0.32097 -9.49342,-0.32097 -3.1876,0 -6.33937,0.09 -9.4919,0.32097 -76.24773,4.68419 -137.23296,65.6679 -141.91639,141.916397 -0.23101,3.152529 -0.32098,6.322593 -0.32098,9.492657 0,3.188361 0.09,6.358425 0.32098,9.493419 4.68343,76.22944 65.66866,137.249737 141.91639,141.933927 3.15253,0.21348 6.3043,0.32098 9.4919,0.32098 3.18836,0 6.35918,-0.1075 9.49342,-0.32098 V 94.354359 h -18.98532 V 110.75818 C -215.22645,106.14489 -265.43361,55.901133 -270.06444,-6.792637 h 14.65793 v -18.986076 h -14.65793 c 4.63083,-62.712067 54.83799,-112.920757 117.55082,-117.551577 v 16.72479 h 18.98532 v -16.72479 c 46.32654,3.42013 85.86619,31.73876 105.20907,71.59937 6.83873,14.070117 11.14859,29.584217 12.34251,45.952207 h -0.0358 l 8.8697703,18.986076 h 15.54917 c 0.21347,-3.134994 0.32021,-6.305058 0.32021,-9.493419"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path42" />
</g>
<g
id="g44"
transform="translate(504.2754,431.2373)">
<path
d="m 11.6231,-31.425333 -17.1029497,8.252992 -4.6666503,-9.703842 -9.61922,-20.006168 -37.98968,-78.855149 -0.0572,-0.0854 h -37.47886 l -0.0282,0.0572 0.0282,0.0854 14.77,30.64853 3.78456,7.85425 0.19898,0.42618 19.2087,39.869009 7.37013,15.25336 2.27653,4.752808 11.72497,24.386926 -5.77672,2.761416 -9.59024,4.638449 49.9708203,23.249422 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path46" />
</g>
<g
id="g48"
transform="translate(258.5654,361.2139)">
<path
d="m 65.50352,-19.287853 v -0.0183 L 46.73396,-58.308305 12.56848,12.654468 2.9523203,32.646912 H 40.49066 l 9.63446,-19.992444 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path50" />
</g>
<g
id="g52"
transform="translate(320.9248,367.3867)">
<path
d="M 52.18699,-16.254503 27.28545,-67.966647 v -0.01906 h -37.57494 v 0.01906 l 18.7870903,39.002155 19.2506297,39.94677 5.65168,11.764611 5.66921,-11.764611 9.2647,-19.232331 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path54" />
</g>
<g
id="g56"
transform="translate(383.7656,359.833)">
<path
d="m 38.75609,-18.959752 -3.78456,-7.854257 -14.7982,-30.591345 -0.0282,-0.08539 -25.2979997,52.560696 -9.1358503,18.95253 -9.61769,20.005406 h 37.10759 z"
style="fill:#2f855a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.762401"
id="path58" />
</g>
</g>
</g>
<g
aria-label="akapi"
transform="matrix(0.35277777,0,0,0.35277777,-84.30991,101.5377)"
id="text856"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:7.5px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.75">
<path
d="m 565.57956,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00781,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03906,-11.51563 -4.03907,-4.38281 -12.28907,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55468,3.52343 -4.55468,9.02343 h -20.88282 q 0,-7.64843 5.07032,-14.26562 5.07031,-6.70313 13.75,-10.48438 8.76562,-3.78125 19.50781,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94531,-15.03906 q 6.1875,0 11.60156,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 H 551.228 q -11.60157,0 -17.44532,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74219,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path851" />
<path
d="m 642.94675,-353.28607 -9.28125,9.53906 v 30.33594 h -20.88282 v -132 h 20.88282 v 76.14062 l 6.53125,-8.16406 25.69531,-28.96094 h 25.09375 l -34.54688,38.75782 38.24219,54.22656 h -24.14844 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path853" />
<path
d="m 767.6655,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00782,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03907,-11.51563 -4.03906,-4.38281 -12.28906,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55469,3.52343 -4.55469,9.02343 h -20.88281 q 0,-7.64843 5.07031,-14.26562 5.07032,-6.70313 13.75,-10.48438 8.76563,-3.78125 19.50782,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94532,-15.03906 q 6.1875,0 11.60157,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 h -11.25782 q -11.60156,0 -17.44531,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74218,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path855" />
<path
d="m 896.25143,-358.95795 q 0,21.57032 -9.79687,34.46094 -9.79688,12.80469 -26.29688,12.80469 -15.29687,0 -24.49218,-10.05469 v 44.08594 h -20.88282 v -128.73438 h 19.25 l 0.85938,9.45313 q 9.19531,-11.17188 25.00781,-11.17188 17.01563,0 26.64063,12.71875 9.71093,12.63282 9.71093,35.14844 z m -20.79687,-1.80468 q 0,-13.92188 -5.58594,-22.08594 -5.5,-8.16406 -15.8125,-8.16406 -12.80469,0 -18.39062,10.57031 v 41.25 q 5.67187,10.82812 18.5625,10.82812 9.96875,0 15.55468,-7.99218 5.67188,-8.07813 5.67188,-24.40625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path857" />
<path
d="m 943.62643,-313.41107 h -20.88281 v -92.98438 h 20.88281 z m -22.17187,-117.13281 q 0,-4.8125 3.00781,-7.99219 3.09375,-3.17969 8.76563,-3.17969 5.67187,0 8.76562,3.17969 3.09375,3.17969 3.09375,7.99219 0,4.72656 -3.09375,7.90625 -3.09375,3.09375 -8.76562,3.09375 -5.67188,0 -8.76563,-3.09375 -3.00781,-3.17969 -3.00781,-7.90625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path859" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -13,7 +13,7 @@
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "#2f855a",
"background_color": "#242B3A",
"display": "standalone"
}

769
static/docs/docs.go Normal file
View File

@ -0,0 +1,769 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag
package docs
import (
"bytes"
"encoding/json"
"strings"
"github.com/alecthomas/template"
"github.com/swaggo/swag"
)
var doc = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{.Description}}",
"title": "{{.Title}}",
"contact": {
"name": "Ferdinand Mütsch",
"url": "https://github.com/muety",
"email": "ferdinand@muetsch.io"
},
"license": {
"name": "GPL-3.0",
"url": "https://github.com/muety/wakapi/blob/master/LICENSE"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/compat/shields/v1/{user}/{interval}/{filter}": {
"get": {
"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.",
"produces": [
"application/json"
],
"tags": [
"badges"
],
"summary": "Get badge data",
"operationId": "get-badge",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Interval to aggregate data for",
"name": "interval",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Filter to apply (e.g. 'project:wakapi' or 'language:Go')",
"name": "filter",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.BadgeData"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/all_time_since_today": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#all_time_since_today",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve summary for all time",
"operationId": "get-all-time",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.AllTimeViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#summaries.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve WakaTime-compatible summaries",
"operationId": "get-wakatime-summaries",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "query"
},
{
"type": "string",
"description": "Start date (e.g. '2021-02-07')",
"name": "start",
"in": "query"
},
{
"type": "string",
"description": "End date (e.g. '2021-02-08')",
"name": "end",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.SummariesViewModel"
}
}
}
}
},
"/health": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"misc"
],
"summary": "Check the application's health status",
"operationId": "get-health",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/heartbeat": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat",
"parameters": [
{
"description": "A heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/summary": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"summary"
],
"summary": "Retrieve a summary",
"operationId": "get-summary",
"parameters": [
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Interval identifier",
"name": "interval",
"in": "query"
},
{
"type": "string",
"description": "Start date (e.g. '2021-02-07')",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "End date (e.g. '2021-02-08')",
"name": "to",
"in": "query"
},
{
"type": "boolean",
"description": "Whether to recompute the summary from raw heartbeat or use cache",
"name": "recompute",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Summary"
}
}
}
}
},
"/v1/users/{user}/stats/{range}": {
"get": {
"description": "Mimics https://wakatime.com/developers#stats. Requires public data access to be allowed.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve stats",
"operationId": "get-stats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
}
},
"definitions": {
"models.Heartbeat": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"category": {
"type": "string"
},
"editor": {
"description": "ignored because editor might be parsed differently by wakatime",
"type": "string"
},
"entity": {
"type": "string"
},
"id": {
"type": "integer"
},
"is_write": {
"type": "boolean"
},
"language": {
"type": "string"
},
"machine": {
"description": "ignored because wakatime api doesn't return machines currently",
"type": "string"
},
"operating_system": {
"description": "ignored because os might be parsed differently by wakatime",
"type": "string"
},
"project": {
"type": "string"
},
"time": {
"type": "number"
},
"type": {
"type": "string"
}
}
},
"models.Summary": {
"type": "object",
"properties": {
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"from": {
"type": "string",
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"to": {
"type": "string",
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"user_id": {
"type": "string"
}
}
},
"models.SummaryItem": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"total": {
"type": "integer"
}
}
},
"v1.AllTimeData": {
"type": "object",
"properties": {
"is_up_to_date": {
"description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e",
"type": "boolean"
},
"text": {
"description": "total time logged since account created as human readable string\u003e",
"type": "string"
},
"total_seconds": {
"description": "total number of seconds logged since account created",
"type": "number"
}
}
},
"v1.AllTimeViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.AllTimeData"
}
}
},
"v1.BadgeData": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"label": {
"type": "string"
},
"message": {
"type": "string"
},
"schemaVersion": {
"type": "integer"
}
}
},
"v1.StatsData": {
"type": "object",
"properties": {
"daily_average": {
"type": "number"
},
"days_including_holidays": {
"type": "integer"
},
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"end": {
"type": "string"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"start": {
"type": "string"
},
"total_seconds": {
"type": "number"
},
"user_id": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"v1.StatsViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.StatsData"
}
}
},
"v1.SummariesData": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"dependencies": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"grand_total": {
"$ref": "#/definitions/v1.SummariesGrandTotal"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"range": {
"$ref": "#/definitions/v1.SummariesRange"
}
}
},
"v1.SummariesEntry": {
"type": "object",
"properties": {
"digital": {
"type": "string"
},
"hours": {
"type": "integer"
},
"minutes": {
"type": "integer"
},
"name": {
"type": "string"
},
"percent": {
"type": "number"
},
"seconds": {
"type": "integer"
},
"text": {
"type": "string"
},
"total_seconds": {
"type": "number"
}
}
},
"v1.SummariesGrandTotal": {
"type": "object",
"properties": {
"digital": {
"type": "string"
},
"hours": {
"type": "integer"
},
"minutes": {
"type": "integer"
},
"text": {
"type": "string"
},
"total_seconds": {
"type": "number"
}
}
},
"v1.SummariesRange": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"end": {
"type": "string"
},
"start": {
"type": "string"
},
"text": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"v1.SummariesViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesData"
}
},
"end": {
"type": "string"
},
"start": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"ApiKeyAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Schemes []string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{
Version: "1.0",
Host: "",
BasePath: "/api",
Schemes: []string{},
Title: "Wakapi API",
Description: "REST API to interact with [Wakapi](https://wakapi.dev)\n\n## Authentication\nSet header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`\n**Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`",
}
type s struct{}
func (s *s) ReadDoc() string {
sInfo := SwaggerInfo
sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
t, err := template.New("swagger_info").Funcs(template.FuncMap{
"marshal": func(v interface{}) string {
a, _ := json.Marshal(v)
return string(a)
},
}).Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, sInfo); err != nil {
return doc
}
return tpl.String()
}
func init() {
swag.Register(swag.Name, &s{})
}

706
static/docs/swagger.json Normal file
View File

@ -0,0 +1,706 @@
{
"swagger": "2.0",
"info": {
"description": "REST API to interact with [Wakapi](https://wakapi.dev)\n\n## Authentication\nSet header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`\n**Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`",
"title": "Wakapi API",
"contact": {
"name": "Ferdinand Mütsch",
"url": "https://github.com/muety",
"email": "ferdinand@muetsch.io"
},
"license": {
"name": "GPL-3.0",
"url": "https://github.com/muety/wakapi/blob/master/LICENSE"
},
"version": "1.0"
},
"basePath": "/api",
"paths": {
"/compat/shields/v1/{user}/{interval}/{filter}": {
"get": {
"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.",
"produces": [
"application/json"
],
"tags": [
"badges"
],
"summary": "Get badge data",
"operationId": "get-badge",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Interval to aggregate data for",
"name": "interval",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Filter to apply (e.g. 'project:wakapi' or 'language:Go')",
"name": "filter",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.BadgeData"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/all_time_since_today": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#all_time_since_today",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve summary for all time",
"operationId": "get-all-time",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.AllTimeViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#summaries.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve WakaTime-compatible summaries",
"operationId": "get-wakatime-summaries",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "query"
},
{
"type": "string",
"description": "Start date (e.g. '2021-02-07')",
"name": "start",
"in": "query"
},
{
"type": "string",
"description": "End date (e.g. '2021-02-08')",
"name": "end",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.SummariesViewModel"
}
}
}
}
},
"/health": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"misc"
],
"summary": "Check the application's health status",
"operationId": "get-health",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/heartbeat": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat",
"parameters": [
{
"description": "A heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/summary": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"summary"
],
"summary": "Retrieve a summary",
"operationId": "get-summary",
"parameters": [
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Interval identifier",
"name": "interval",
"in": "query"
},
{
"type": "string",
"description": "Start date (e.g. '2021-02-07')",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "End date (e.g. '2021-02-08')",
"name": "to",
"in": "query"
},
{
"type": "boolean",
"description": "Whether to recompute the summary from raw heartbeat or use cache",
"name": "recompute",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Summary"
}
}
}
}
},
"/v1/users/{user}/stats/{range}": {
"get": {
"description": "Mimics https://wakatime.com/developers#stats. Requires public data access to be allowed.",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve stats",
"operationId": "get-stats",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"enum": [
"today",
"yesterday",
"week",
"month",
"year",
"7_days",
"last_7_days",
"30_days",
"last_30_days",
"12_months",
"last_12_months",
"any"
],
"type": "string",
"description": "Range interval identifier",
"name": "range",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.StatsViewModel"
}
}
}
}
}
},
"definitions": {
"models.Heartbeat": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"category": {
"type": "string"
},
"editor": {
"description": "ignored because editor might be parsed differently by wakatime",
"type": "string"
},
"entity": {
"type": "string"
},
"id": {
"type": "integer"
},
"is_write": {
"type": "boolean"
},
"language": {
"type": "string"
},
"machine": {
"description": "ignored because wakatime api doesn't return machines currently",
"type": "string"
},
"operating_system": {
"description": "ignored because os might be parsed differently by wakatime",
"type": "string"
},
"project": {
"type": "string"
},
"time": {
"type": "number"
},
"type": {
"type": "string"
}
}
},
"models.Summary": {
"type": "object",
"properties": {
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"from": {
"type": "string",
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"to": {
"type": "string",
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"user_id": {
"type": "string"
}
}
},
"models.SummaryItem": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"total": {
"type": "integer"
}
}
},
"v1.AllTimeData": {
"type": "object",
"properties": {
"is_up_to_date": {
"description": "true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon\u003e",
"type": "boolean"
},
"text": {
"description": "total time logged since account created as human readable string\u003e",
"type": "string"
},
"total_seconds": {
"description": "total number of seconds logged since account created",
"type": "number"
}
}
},
"v1.AllTimeViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.AllTimeData"
}
}
},
"v1.BadgeData": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"label": {
"type": "string"
},
"message": {
"type": "string"
},
"schemaVersion": {
"type": "integer"
}
}
},
"v1.StatsData": {
"type": "object",
"properties": {
"daily_average": {
"type": "number"
},
"days_including_holidays": {
"type": "integer"
},
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"end": {
"type": "string"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"start": {
"type": "string"
},
"total_seconds": {
"type": "number"
},
"user_id": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"v1.StatsViewModel": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/v1.StatsData"
}
}
},
"v1.SummariesData": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"dependencies": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"editors": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"grand_total": {
"$ref": "#/definitions/v1.SummariesGrandTotal"
},
"languages": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"machines": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"operating_systems": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"range": {
"$ref": "#/definitions/v1.SummariesRange"
}
}
},
"v1.SummariesEntry": {
"type": "object",
"properties": {
"digital": {
"type": "string"
},
"hours": {
"type": "integer"
},
"minutes": {
"type": "integer"
},
"name": {
"type": "string"
},
"percent": {
"type": "number"
},
"seconds": {
"type": "integer"
},
"text": {
"type": "string"
},
"total_seconds": {
"type": "number"
}
}
},
"v1.SummariesGrandTotal": {
"type": "object",
"properties": {
"digital": {
"type": "string"
},
"hours": {
"type": "integer"
},
"minutes": {
"type": "integer"
},
"text": {
"type": "string"
},
"total_seconds": {
"type": "number"
}
}
},
"v1.SummariesRange": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"end": {
"type": "string"
},
"start": {
"type": "string"
},
"text": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"v1.SummariesViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesData"
}
},
"end": {
"type": "string"
},
"start": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"ApiKeyAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

488
static/docs/swagger.yaml Normal file
View File

@ -0,0 +1,488 @@
basePath: /api
definitions:
models.Heartbeat:
properties:
branch:
type: string
category:
type: string
editor:
description: ignored because editor might be parsed differently by wakatime
type: string
entity:
type: string
id:
type: integer
is_write:
type: boolean
language:
type: string
machine:
description: ignored because wakatime api doesn't return machines currently
type: string
operating_system:
description: ignored because os might be parsed differently by wakatime
type: string
project:
type: string
time:
type: number
type:
type: string
type: object
models.Summary:
properties:
editors:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
from:
example: "2006-01-02 15:04:05.000"
format: date
type: string
languages:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
machines:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
operating_systems:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
projects:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
to:
example: "2006-01-02 15:04:05.000"
format: date
type: string
user_id:
type: string
type: object
models.SummaryItem:
properties:
key:
type: string
total:
type: integer
type: object
v1.AllTimeData:
properties:
is_up_to_date:
description: true if the stats are up to date; when false, a 202 response
code is returned and stats will be refreshed soon>
type: boolean
text:
description: total time logged since account created as human readable string>
type: string
total_seconds:
description: total number of seconds logged since account created
type: number
type: object
v1.AllTimeViewModel:
properties:
data:
$ref: '#/definitions/v1.AllTimeData'
type: object
v1.BadgeData:
properties:
color:
type: string
label:
type: string
message:
type: string
schemaVersion:
type: integer
type: object
v1.StatsData:
properties:
daily_average:
type: number
days_including_holidays:
type: integer
editors:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
end:
type: string
languages:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
machines:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
operating_systems:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
projects:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
start:
type: string
total_seconds:
type: number
user_id:
type: string
username:
type: string
type: object
v1.StatsViewModel:
properties:
data:
$ref: '#/definitions/v1.StatsData'
type: object
v1.SummariesData:
properties:
categories:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
dependencies:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
editors:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
grand_total:
$ref: '#/definitions/v1.SummariesGrandTotal'
languages:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
machines:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
operating_systems:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
projects:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
range:
$ref: '#/definitions/v1.SummariesRange'
type: object
v1.SummariesEntry:
properties:
digital:
type: string
hours:
type: integer
minutes:
type: integer
name:
type: string
percent:
type: number
seconds:
type: integer
text:
type: string
total_seconds:
type: number
type: object
v1.SummariesGrandTotal:
properties:
digital:
type: string
hours:
type: integer
minutes:
type: integer
text:
type: string
total_seconds:
type: number
type: object
v1.SummariesRange:
properties:
date:
type: string
end:
type: string
start:
type: string
text:
type: string
timezone:
type: string
type: object
v1.SummariesViewModel:
properties:
data:
items:
$ref: '#/definitions/v1.SummariesData'
type: array
end:
type: string
start:
type: string
type: object
info:
contact:
email: ferdinand@muetsch.io
name: Ferdinand Mütsch
url: https://github.com/muety
description: |-
REST API to interact with [Wakapi](https://wakapi.dev)
## Authentication
Set header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`
**Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`
license:
name: GPL-3.0
url: https://github.com/muety/wakapi/blob/master/LICENSE
title: Wakapi API
version: "1.0"
paths:
/compat/shields/v1/{user}/{interval}/{filter}:
get:
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.
operationId: get-badge
parameters:
- description: User ID to fetch data for
in: path
name: user
required: true
type: string
- description: Interval to aggregate data for
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: path
name: interval
required: true
type: string
- description: Filter to apply (e.g. 'project:wakapi' or 'language:Go')
in: path
name: filter
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.BadgeData'
summary: Get badge data
tags:
- badges
/compat/wakatime/v1/users/{user}/all_time_since_today:
get:
description: Mimics https://wakatime.com/developers#all_time_since_today
operationId: get-all-time
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.AllTimeViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve summary for all time
tags:
- wakatime
/compat/wakatime/v1/users/{user}/summaries:
get:
description: Mimics https://wakatime.com/developers#summaries.
operationId: get-wakatime-summaries
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Range interval identifier
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: query
name: range
type: string
- description: Start date (e.g. '2021-02-07')
in: query
name: start
type: string
- description: End date (e.g. '2021-02-08')
in: query
name: end
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.SummariesViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve WakaTime-compatible summaries
tags:
- wakatime
/health:
get:
operationId: get-health
produces:
- text/plain
responses:
"200":
description: OK
schema:
type: string
summary: Check the application's health status
tags:
- misc
/heartbeat:
post:
consumes:
- application/json
operationId: post-heartbeat
parameters:
- description: A heartbeat
in: body
name: heartbeat
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new heartbeat
tags:
- heartbeat
/summary:
get:
operationId: get-summary
parameters:
- description: Interval identifier
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: query
name: interval
type: string
- description: Start date (e.g. '2021-02-07')
in: query
name: from
type: string
- description: End date (e.g. '2021-02-08')
in: query
name: to
type: string
- description: Whether to recompute the summary from raw heartbeat or use cache
in: query
name: recompute
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Summary'
security:
- ApiKeyAuth: []
summary: Retrieve a summary
tags:
- summary
/v1/users/{user}/stats/{range}:
get:
description: Mimics https://wakatime.com/developers#stats. Requires public data
access to be allowed.
operationId: get-stats
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Range interval identifier
enum:
- today
- yesterday
- week
- month
- year
- 7_days
- last_7_days
- 30_days
- last_30_days
- 12_months
- last_12_months
- any
in: path
name: range
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.StatsViewModel'
summary: Retrieve stats
tags:
- wakatime
securityDefinitions:
ApiKeyAuth:
in: header
name: Authorization
type: apiKey
swagger: "2.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1,60 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "/docs/swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>

View File

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
</body>
</html>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&")
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}
isValid = qp.state === sentState
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
window.addEventListener('DOMContentLoaded', function () {
run();
});
</script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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