mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
141 Commits
Author | SHA1 | Date | |
---|---|---|---|
4f7cc3c57e | |||
c6139e5366 | |||
28269aa329 | |||
b7ae15496d | |||
f483488dd5 | |||
0c3f3b37b0 | |||
dc1a0c7983 | |||
e4b38d3f51 | |||
665ffe8bd1 | |||
3e5a51c272 | |||
979549448c | |||
105f96ff72 | |||
31013ad986 | |||
db4cb92c26 | |||
779108ad88 | |||
61f8a22cff | |||
179a107c2a | |||
ef0c76e2ff | |||
617d9ad7e4 | |||
fd239e4f21 | |||
417d4789ab | |||
a6aff07b21 | |||
b732eea9b7 | |||
71d1b2177b | |||
b2a3579be9 | |||
42a6e9d923 | |||
1f44ccadba | |||
6ea72c6d02 | |||
d93348842a | |||
fb92747129 | |||
4e6e665e19 | |||
a3d8c4d464 | |||
e9eaa9da53 | |||
5adb795f59 | |||
a552073d18 | |||
de0401d4bb | |||
c39538db13 | |||
189a09d91f | |||
d57c02af7c | |||
16b683fcbd | |||
acda62488d | |||
1aecfc4ca3 | |||
cd97976ed5 | |||
3a4504d56a | |||
a018f70c3f | |||
a03e49e7f0 | |||
ec81d9fe5d | |||
b7a1e2d795 | |||
98b62b33c8 | |||
262bee9022 | |||
9766d8e903 | |||
39c4777fc8 | |||
143c80b7b4 | |||
72e42a9c42 | |||
439a87dec9 | |||
e8067bb13e | |||
219e969957 | |||
e610bb3ee3 | |||
889edd7a33 | |||
4161623c24 | |||
67fe6eea56 | |||
095fef4868 | |||
a0e64ca955 | |||
903defca99 | |||
16b9aa2282 | |||
4a78f66778 | |||
f4328c452f | |||
e806e5455e | |||
97e1fb27eb | |||
ad8168801c | |||
35cdc7b485 | |||
664714de8f | |||
7befb82814 | |||
2f12d8efde | |||
8ddd9904a0 | |||
78874566a4 | |||
e269b37b0e | |||
e6a04cc76d | |||
cb8f68df82 | |||
b4d2ee7d16 | |||
1224024913 | |||
8efc3854ab | |||
755cabb5f4 | |||
96ff490d8d | |||
68e66298b8 | |||
c2d30826f6 | |||
861c81e414 | |||
892d265c4d | |||
e19761337f | |||
3f973a28ea | |||
86fc751e58 | |||
178c417757 | |||
395d039d41 | |||
fdf2289f8e | |||
06b3fdd17c | |||
4506493353 | |||
11728b80ac | |||
b7c7817923 | |||
c78ee5465c | |||
4336d732c9 | |||
177cbb12fc | |||
a4c344aaa1 | |||
c575b2fd5c | |||
67a59561c8 | |||
f7520b2b4a | |||
54a944ec41 | |||
44b6efb6ee | |||
efd4764728 | |||
dd50b4076f | |||
21b822de42 | |||
4d22756b8a | |||
c54f2743fd | |||
a8d5d69629 | |||
0111aa7543 | |||
3bafde7ab1 | |||
b378597594 | |||
29619f09ed | |||
ff3fea0359 | |||
660fefcca9 | |||
2ecbb3ea02 | |||
f843be8d12 | |||
062a9c6f57 | |||
1c0e63e125 | |||
45f372168d | |||
0760be86ff | |||
6e2f3e6731 | |||
d60dddb550 | |||
19a8c61f77 | |||
fde8c35362 | |||
8dca9f5cc0 | |||
570aeebe01 | |||
21567e7601 | |||
a8009e107d | |||
84e9559860 | |||
7c8ea86d4e | |||
587ac6a330 | |||
97cb29ee4d | |||
cecb5e113c | |||
a059c637a7 | |||
75b33d5e42 | |||
50b7a9ec3d |
@ -1 +1,10 @@
|
||||
.env
|
||||
.env
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
*.db
|
||||
*.exe
|
||||
wakapi
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.git*
|
||||
|
@ -1,8 +0,0 @@
|
||||
ENV=dev
|
||||
WAKAPI_DB_TYPE=sqlite3 # mysql, postgres, sqlite3
|
||||
WAKAPI_DB_NAME=wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
WAKAPI_DB_USER=myuser # ignored when using sqlite
|
||||
WAKAPI_DB_PASSWORD=shhh # ignored when using sqlite
|
||||
WAKAPI_DB_HOST=localhost # ignored when using sqlite
|
||||
WAKAPI_DB_PORT=3306 # ignored when using sqlite
|
||||
WAKAPI_PASSWORD_SALT=shhh # CHANGE !
|
22
.github/workflows/linux-build-on-release.yml
vendored
22
.github/workflows/linux-build-on-release.yml
vendored
@ -1,13 +1,15 @@
|
||||
name: Build Wakapi on Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
types:
|
||||
- created
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and add to Release
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@ -22,17 +24,21 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get -v -t -d ./...
|
||||
|
||||
go get github.com/markbates/pkger/cmd/pkger
|
||||
go get
|
||||
go generate
|
||||
|
||||
- name: Build
|
||||
run: GO111MODULE=on go build -v .
|
||||
|
||||
- name: Zip Release
|
||||
uses: TheDoctor0/zip-release@v0.3.0
|
||||
with:
|
||||
filename: release.zip
|
||||
- name: Zip executable and sample config
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
cp config.default.yml config.yml
|
||||
zip -9 release.zip wakapi config.yml
|
||||
|
||||
- name: Upload built executable to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
20
.github/workflows/win-build-on-release.yml
vendored
20
.github/workflows/win-build-on-release.yml
vendored
@ -1,13 +1,15 @@
|
||||
name: Build Wakapi on Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
types:
|
||||
- created
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and add to release
|
||||
name: Build
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
|
||||
@ -22,18 +24,24 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get -v -t -d ./...
|
||||
go get github.com/markbates/pkger/cmd/pkger
|
||||
go get
|
||||
go generate
|
||||
|
||||
- name: Enable Go 1.11 modules
|
||||
run: cmd /c "set GO111MODULE=on"
|
||||
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
|
||||
|
||||
- name: Compress working folder
|
||||
run: Compress-Archive -Path .\* -DestinationPath release.zip
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
cp .\config.default.yml .\config.yml
|
||||
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath release.zip
|
||||
|
||||
- name: Upload built executable to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,8 +1,10 @@
|
||||
launch.json
|
||||
.vscode
|
||||
.env
|
||||
wakapi
|
||||
.idea
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
*.db
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
pkged.go
|
||||
|
53
Dockerfile
53
Dockerfile
@ -1,49 +1,44 @@
|
||||
# Build Stage
|
||||
|
||||
FROM golang:1.13 AS build-env
|
||||
ADD . /src
|
||||
RUN cd /src && go build -o wakapi
|
||||
FROM golang:1.15 AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
|
||||
|
||||
# Final Stage
|
||||
ADD . .
|
||||
RUN go generate && go build -o wakapi
|
||||
|
||||
WORKDIR /app
|
||||
RUN cp /src/wakapi . && \
|
||||
cp /src/config.default.yml config.yml && \
|
||||
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
|
||||
cp /src/wait-for-it.sh .
|
||||
|
||||
# Run Stage
|
||||
|
||||
# When running the application using `docker run`, you can pass environment variables
|
||||
# to override config values from .env using `-e` syntax.
|
||||
# Available options are:
|
||||
# – WAKAPI_DB_TYPE
|
||||
# – WAKAPI_DB_USER
|
||||
# – WAKAPI_DB_PASSWORD
|
||||
# – WAKAPI_DB_HOST
|
||||
# – WAKAPI_DB_PORT
|
||||
# – WAKAPI_DB_NAME
|
||||
# – WAKAPI_PASSWORD_SALT
|
||||
# – WAKAPI_BASE_PATH
|
||||
# to override config values using `-e` syntax.
|
||||
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
||||
|
||||
FROM debian
|
||||
WORKDIR /app
|
||||
|
||||
ENV ENV prod
|
||||
RUN apt update && \
|
||||
apt install -y ca-certificates
|
||||
|
||||
ENV ENVIRONMENT prod
|
||||
ENV WAKAPI_DB_TYPE sqlite3
|
||||
ENV WAKAPI_DB_USER ''
|
||||
ENV WAKAPI_DB_PASSWORD ''
|
||||
ENV WAKAPI_DB_HOST ''
|
||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||
ENV WAKAPI_PASSWORD_SALT ''
|
||||
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
|
||||
ENV WAKAPI_INSECURE_COOKIES 'true'
|
||||
|
||||
COPY --from=build-env /src/wakapi /app/
|
||||
COPY --from=build-env /src/config.ini /app/
|
||||
COPY --from=build-env /src/version.txt /app/
|
||||
COPY --from=build-env /src/.env.example /app/.env
|
||||
|
||||
RUN sed -i 's/listen = 127.0.0.1/listen = 0.0.0.0/g' /app/config.ini
|
||||
RUN sed -i 's/insecure_cookies = false/insecure_cookies = true/g' /app/config.ini
|
||||
|
||||
ADD static /app/static
|
||||
ADD data /app/data
|
||||
ADD migrations /app/migrations
|
||||
ADD views /app/views
|
||||
ADD wait-for-it.sh .
|
||||
COPY --from=build-env /app .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ./wait-for-it.sh
|
||||
ENTRYPOINT ./wait-for-it.sh
|
||||
|
309
README.md
309
README.md
@ -1,95 +1,290 @@
|
||||
# 📈 wakapi
|
||||
<h1 align="center">📊 Wakapi</h1>
|
||||
|
||||
[](https://liberapay.com/muety/)
|
||||
[](https://saythanks.io/to/n1try)
|
||||

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

|
||||
<p align="center">
|
||||
<img src="https://anchr.io/i/bxQ69.png" width="500px">
|
||||
</p>
|
||||
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
## Table of Contents
|
||||
* [User Survey](#-user-survey)
|
||||
* [Features](#-features)
|
||||
* [How to use](#-how-to-use)
|
||||
* [Configuration Options](#-configuration-options)
|
||||
* [API Endpoints](#-api-endpoints)
|
||||
* [Prometheus Export](#-prometheus-export)
|
||||
* [Best Practices](#-best-practices)
|
||||
* [Developer Notes](#-developer-notes)
|
||||
* [Support](#-support)
|
||||
* [FAQs](#-faqs)
|
||||
|
||||
## Demo
|
||||
🔥 **New:** There is hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
|
||||
## 📬 **User Survey**
|
||||
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
||||
|
||||
To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕
|
||||
## 🚀 Features
|
||||
* ✅ 100 % free and open-source
|
||||
* ✅ Built by developers for developers
|
||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||
* ✅ Badges
|
||||
* ✅ REST API
|
||||
* ✅ Partially compatible with WakaTime
|
||||
* ✅ WakaTime relay to use both
|
||||
* ✅ Support for [Prometheus](https://github.com/muety/wakapi#%EF%B8%8F-prometheus-export) exports
|
||||
* ✅ Self-hosted
|
||||
|
||||
## Prerequisites
|
||||
**On the server side:**
|
||||
## ⌨️ 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.
|
||||
|
||||
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
|
||||
If you want to you out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below).
|
||||
|
||||
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||
|
||||
### 🐳 Option 2: Use Docker
|
||||
```bash
|
||||
# Create a persistent volume
|
||||
$ docker volume create wakapi-data
|
||||
|
||||
# Run the container
|
||||
$ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
|
||||
-v wakapi-data:/data \
|
||||
--name wakapi n1try/wakapi
|
||||
```
|
||||
|
||||
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
|
||||
|
||||
### 📦 Option 3: Run a release
|
||||
```bash
|
||||
# Download the release and unpack it
|
||||
$ wget https://github.com/muety/wakapi/releases/download/1.20.2/wakapi_linux_amd64.zip
|
||||
$ unzip wakapi_linux_amd64.zip
|
||||
|
||||
# Optionally adapt config to your needs
|
||||
$ vi config.yml
|
||||
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
```
|
||||
|
||||
### 🧑💻 Option 4: Run from source
|
||||
#### Prerequisites
|
||||
* Go >= 1.13 (with `$GOPATH` properly set)
|
||||
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||
* Fedora / RHEL: `dnf install @development-tools`
|
||||
* Ubuntu / Debian: `apt install build-essential`
|
||||
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
|
||||
* _Optional_: A MySQL- or Postgres database
|
||||
|
||||
**On your local machine:**
|
||||
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
|
||||
## Server Setup
|
||||
### Run from source
|
||||
1. Clone the project
|
||||
1. Copy `.env.example` to `.env` and set database credentials
|
||||
1. Adapt `config.ini` to your needs
|
||||
1. Build executable: `GO111MODULE=on go build`
|
||||
1. Run server: `./wakapi`
|
||||
# Install packaging tool
|
||||
$ export GO111MODULE=on
|
||||
$ go get github.com/markbates/pkger/cmd/pkger
|
||||
|
||||
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
|
||||
# Build the executable
|
||||
$ go generate
|
||||
$ go build -o wakapi
|
||||
|
||||
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` in `.env` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](i#best-practices)) or set `insecure_cookies = true` in `config.ini`.
|
||||
|
||||
### Run with Docker
|
||||
```
|
||||
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
```
|
||||
|
||||
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [.env.example](https://github.com/muety/wakapi/blob/master/.env.example) for further options.
|
||||
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies = true` in `config.yml`.
|
||||
|
||||
## Client Setup
|
||||
### 💻 Client Setup
|
||||
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
|
||||
|
||||
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
|
||||
2. Make your local WakaTime client talk to Wakapi by **editing your local `~/.wakatime.cfg`** file as follows
|
||||
2. **Editing your local `~/.wakatime.cfg`** file as follows
|
||||
|
||||
```
|
||||
api_url = https://your.server:someport/api/heartbeat`
|
||||
api_key = the_api_key_printed_to_the_console_after_starting_the_server`
|
||||
```ini
|
||||
[settings]
|
||||
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
|
||||
api_url = http://localhost:3000/api/heartbeat
|
||||
|
||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
```
|
||||
|
||||
You can view your API Key after logging in to the web interface.
|
||||
Optionally, you can set up a [client-side proxy](docs/advanced_setup.md) in addition.
|
||||
|
||||
## Customization
|
||||
## 🔧 Configuration Options
|
||||
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
|
||||
|
||||
### Aliases
|
||||
There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects – `myapp-frontend` and `myapp-backend` – two a common project name – `myapp-web` – in your statistics, you can add project aliases.
|
||||
| YAML Key | Environment Variable | Default | Description |
|
||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
||||
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
||||
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
||||
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
|
||||
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
|
||||
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
|
||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
||||
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
||||
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
|
||||
At the moment, this can only be done via raw database queries. For the above example, you would need to add two aliases, like this:
|
||||
### Supported databases
|
||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||
* [SQLite](https://sqlite.org/) (_default, easy setup_)
|
||||
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
|
||||
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
|
||||
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
|
||||
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
|
||||
|
||||
```sql
|
||||
INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_username', 'myapp-web', 'myapp-frontend');
|
||||
```
|
||||
### Client-side proxy (`optional`)
|
||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
||||
|
||||
#### Types
|
||||
* Project ~ type **0**
|
||||
* Language ~ type **1**
|
||||
* Editor ~ type **2**
|
||||
* OS ~ type **3**
|
||||
* Machine ~ type **4**
|
||||
## 🔧 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)).
|
||||
|
||||
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
||||
* `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`
|
||||
|
||||
## Best Practices
|
||||
## ⤴️ Prometheus Export
|
||||
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
|
||||
|
||||
[](https://github.com/MacroPower/wakatime_exporter)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 👍 Best Practices
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `listen = 0.0.0.0` in `config.ini`
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
||||
|
||||
## Important Note
|
||||
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
|
||||
## 🤓 Developer Notes
|
||||
### Running tests
|
||||
```bash
|
||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
## License
|
||||
## 🙏 Support
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
|
||||
## ❔ FAQs
|
||||
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
|
||||
|
||||
<details>
|
||||
<summary><b>What data is sent to Wakapi?</b></summary>
|
||||
|
||||
<ul>
|
||||
<li>File names</li>
|
||||
<li>Project names</li>
|
||||
<li>Editor names</li>
|
||||
<li>You computer's host name</li>
|
||||
<li>Timestamps for every action you take in your editor</li>
|
||||
<li>...</li>
|
||||
</ul>
|
||||
|
||||
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
|
||||
|
||||
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens if I'm offline?</b></summary>
|
||||
|
||||
All data is cached locally on your machine and sent in batches once you're online again.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How did Wakapi come about?</b></summary>
|
||||
|
||||
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing)">9 $ a month</a> back then. Luckily, most parts of WakaTime are open source!
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How does Wakapi compare to WakaTime?</b></summary>
|
||||
|
||||
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi, include:
|
||||
|
||||
<ul>
|
||||
<li>Leaderboards</li>
|
||||
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
|
||||
<li>Personal Goals</li>
|
||||
<li>Team- / Organization Support</li>
|
||||
<li>Integrations (with GitLab, etc.)</li>
|
||||
<li>Richer API</li>
|
||||
</ul>
|
||||
|
||||
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How are durations calculated?</b></summary>
|
||||
|
||||
Inferring a measure for your coding time from heartbeats works a bit different than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat, that occurs after a longer pause, with 2 extra minutes.
|
||||
|
||||
Here is an example (circles are heartbeats):
|
||||
|
||||
```
|
||||
|---o---o--------------o---o---|
|
||||
| |10s| 3m |10s| |
|
||||
|
||||
```
|
||||
|
||||
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen find a solution, but not actually typing code.
|
||||
|
||||
<ul>
|
||||
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
|
||||
<li><b>WakaTime</b> (with 2 min timeout): 20 sec
|
||||
<li><b>Wakapi:</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
|
||||
</ul>
|
||||
|
||||
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
||||
</details>
|
||||
|
||||
## 🙏 Thanks
|
||||
I highly appreciate the efforts of [@alanhamlett](https://github.com/alanhamlett) and the WakaTime team and am thankful for their software being open source.
|
||||
|
||||
## 📓 License
|
||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||
|
31
config.default.yml
Normal file
31
config.default.yml
Normal file
@ -0,0 +1,31 @@
|
||||
env: development
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||
tls_cert_path: # leave blank to not use https
|
||||
tls_key_path: # leave blank to not use https
|
||||
port: 3000
|
||||
base_path: /
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
|
||||
db:
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
user: # leave blank when using sqlite3
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
insecure_cookies: false
|
||||
cookie_max_age: 172800
|
14
config.ini
14
config.ini
@ -1,14 +0,0 @@
|
||||
[server]
|
||||
listen = 127.0.0.1
|
||||
port = 3000
|
||||
base_path = /
|
||||
insecure_cookies = false
|
||||
|
||||
[app]
|
||||
cleanup = false
|
||||
|
||||
[database]
|
||||
max_connections = 2
|
||||
|
||||
[languages]
|
||||
vue = Vue
|
317
config/config.go
Normal file
317
config/config.go
Normal file
@ -0,0 +1,317 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/muety/wakapi/models"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "config.yml"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
)
|
||||
|
||||
const (
|
||||
WakatimeApiUrl = "https://wakatime.com/api/v1"
|
||||
WakatimeApiHeartbeatsEndpoint = "/users/current/heartbeats.bulk"
|
||||
WakatimeApiUserEndpoint = "/users/current"
|
||||
)
|
||||
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
type securityConfig struct {
|
||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
|
||||
SecureCookie *securecookie.SecureCookie `yaml:"-"`
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
Host string `env:"WAKAPI_DB_HOST"`
|
||||
Port uint `env:"WAKAPI_DB_PORT"`
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||
Dialect string `yaml:"-"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
||||
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
|
||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Env string `default:"dev" env:"ENVIRONMENT"`
|
||||
Version string `yaml:"-"`
|
||||
App appConfig
|
||||
Security securityConfig
|
||||
Db dbConfig
|
||||
Server serverConfig
|
||||
}
|
||||
|
||||
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
|
||||
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
|
||||
}
|
||||
|
||||
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
|
||||
return c.createCookie(name, "", path, -1)
|
||||
}
|
||||
|
||||
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
MaxAge: maxAge,
|
||||
Secure: !c.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) IsDev() bool {
|
||||
return IsDev(c.Env)
|
||||
}
|
||||
|
||||
func (c *Config) UseTLS() bool {
|
||||
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
|
||||
}
|
||||
|
||||
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
db.AutoMigrate(&models.Alias{})
|
||||
db.AutoMigrate(&models.Heartbeat{})
|
||||
db.AutoMigrate(&models.Summary{})
|
||||
db.AutoMigrate(&models.SummaryItem{})
|
||||
db.AutoMigrate(&models.LanguageMapping{})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.HttpFileSystemMigrationSource{
|
||||
FileSystem: pkger.Dir("/migrations"),
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
sqlDb, _ := db.DB()
|
||||
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logbuch.Info("applied %d fixtures", n)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Name,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
sslmode,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
||||
|
||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetLanguageColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["languages"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetEditorColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["editors"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetOSColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
file, err := pkger.Open("/version.txt")
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(bytes))
|
||||
}
|
||||
|
||||
func readColors() map[string]map[string]string {
|
||||
// Read language colors
|
||||
// Source:
|
||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// – https://wakatime.com/colors/operating_systems
|
||||
// - https://wakatime.com/colors/editors
|
||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
var colors = make(map[string]map[string]string)
|
||||
|
||||
file, err := pkger.Open("/data/colors.json")
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bytes, &colors); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
func mustReadConfigLocation() string {
|
||||
if _, err := os.Stat(*cFlag); err != nil {
|
||||
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
|
||||
}
|
||||
return *cFlag
|
||||
}
|
||||
|
||||
func resolveDbDialect(dbType string) string {
|
||||
if dbType == "cockroach" {
|
||||
return "postgres"
|
||||
}
|
||||
return dbType
|
||||
}
|
||||
|
||||
func Set(config *Config) {
|
||||
cfg = config
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
|
||||
logbuch.Fatal("failed to read config: %v", err)
|
||||
}
|
||||
|
||||
config.Version = readVersion()
|
||||
config.App.Colors = readColors()
|
||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
)
|
||||
|
||||
if strings.HasSuffix(config.Server.BasePath, "/") {
|
||||
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
|
||||
}
|
||||
|
||||
for k, v := range config.App.CustomLanguages {
|
||||
if v == "" {
|
||||
config.App.CustomLanguages[k] = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
}
|
66
config/config_test.go
Normal file
66
config/config_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_IsDev(t *testing.T) {
|
||||
assert.True(t, IsDev("dev"))
|
||||
assert.True(t, IsDev("development"))
|
||||
assert.False(t, IsDev("prod"))
|
||||
assert.False(t, IsDev("production"))
|
||||
assert.False(t, IsDev("anything else"))
|
||||
}
|
||||
|
||||
func Test_mysqlConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Host: "test_host",
|
||||
Port: 9999,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Name: "test_name",
|
||||
Dialect: "mysql",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
c.User,
|
||||
c.Password,
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.Name,
|
||||
"Local",
|
||||
), mysqlConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_postgresConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Host: "test_host",
|
||||
Port: 9999,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Name: "test_name",
|
||||
Dialect: "postgres",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.User,
|
||||
c.Name,
|
||||
c.Password,
|
||||
), postgresConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_sqliteConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Name: "test_name",
|
||||
Dialect: "sqlite3",
|
||||
}
|
||||
assert.Equal(t, c.Name, sqliteConnectionString(c))
|
||||
}
|
10
config/templates.go
Normal file
10
config/templates.go
Normal file
@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
IndexTemplate = "index.tpl.html"
|
||||
LoginTemplate = "login.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
)
|
14
config/utils.go
Normal file
14
config/utils.go
Normal file
@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
func cloneStringMap(m map[string]string, keysToLower bool) map[string]string {
|
||||
m2 := make(map[string]string)
|
||||
for k, v := range m {
|
||||
if keysToLower {
|
||||
k = strings.ToLower(k)
|
||||
}
|
||||
m2[k] = v
|
||||
}
|
||||
return m2
|
||||
}
|
558
coverage/coverage.out
Normal file
558
coverage/coverage.out
Normal file
@ -0,0 +1,558 @@
|
||||
mode: set
|
||||
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
|
||||
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
|
||||
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
|
||||
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
|
||||
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
|
||||
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
|
||||
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
|
||||
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
|
||||
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
|
||||
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
|
||||
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
|
||||
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
|
||||
github.com/muety/wakapi/models/filters.go:32.47,33.21 1 1
|
||||
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 1
|
||||
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 1
|
||||
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 1
|
||||
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 1
|
||||
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 1
|
||||
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
|
||||
github.com/muety/wakapi/models/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/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/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/date.go:8.31,10.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
|
||||
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
|
||||
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
|
||||
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
|
||||
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
|
||||
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
|
||||
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
|
||||
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
|
||||
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
|
||||
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
|
||||
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
|
||||
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
|
||||
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
|
||||
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
|
||||
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
|
||||
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
|
||||
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
|
||||
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
|
||||
github.com/muety/wakapi/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/services/summary.go:27.149,35.2 1 1
|
||||
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
|
||||
github.com/muety/wakapi/services/summary.go:53.2,53.65 1 1
|
||||
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
|
||||
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:53.65,55.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
|
||||
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
|
||||
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
|
||||
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
|
||||
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
|
||||
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
|
||||
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
|
||||
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
|
||||
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
|
||||
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
|
||||
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
|
||||
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
|
||||
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
|
||||
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
|
||||
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
|
||||
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
|
||||
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
|
||||
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
|
||||
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
|
||||
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
|
||||
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
|
||||
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
|
||||
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
|
||||
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
|
||||
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
|
||||
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
|
||||
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
|
||||
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
|
||||
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
|
||||
github.com/muety/wakapi/services/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/language_mapping.go:18.118,24.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
|
||||
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
|
||||
github.com/muety/wakapi/services/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
|
1803
data/colors.json
1803
data/colors.json
File diff suppressed because it is too large
Load Diff
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
wakapi:
|
||||
build: .
|
||||
ports:
|
||||
- 3000:3000
|
||||
restart: always
|
||||
environment:
|
||||
WAKAPI_DB_TYPE: "postgres"
|
||||
WAKAPI_DB_NAME: "wakapi"
|
||||
WAKAPI_DB_USER: "wakapi"
|
||||
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
|
||||
WAKAPI_DB_HOST: "db"
|
||||
WAKAPI_DB_PORT: "5432"
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
db:
|
||||
image: postgres:12.3
|
||||
environment:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "CHANGE_ME!!!"
|
||||
POSTGRES_DB: "wakapi"
|
47
docs/advanced_setup.md
Normal file
47
docs/advanced_setup.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Advanced Setup
|
||||
This page contains instructions for additional setup options, none of which are mandatory.
|
||||
|
||||
## Optional: Client-side proxy
|
||||
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API – Wakapi in this case.
|
||||
|
||||
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
|
||||
|
||||
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
|
||||
|
||||
### Option 1: [tinyproxy](https://tinyproxy.github.io) forward proxy (`Linux`, `Mac` only)
|
||||
In this example we use _tinyproxy_ as a small, easy-to-install proxy server, written in C, that runs on your local machine.
|
||||
1. Install [tinyproxy](https://tinyproxy.github.io)
|
||||
* Fedora / RHEL: `dnf install tinyproxy`
|
||||
* Debian / Ubuntu: `apt install tinyproxy`
|
||||
* MacOS: Install from [MacPorts](https://ports.macports.org/port/tinyproxy/summary)
|
||||
1. Enable and start it
|
||||
* Linux: `sudo systemctl start tinyproxy && sudo systemctl enable tinyproxy`
|
||||
* Mac: Not sure, sorry ¯\_(ツ)_/¯
|
||||
1. Update `~/.wakatime.cfg`
|
||||
* Set `proxy = http://localhost:8888`
|
||||
1. Done
|
||||
* All Wakapi requests are passed through tinyproxy now, which keeps a TCP connection with the server open for some time
|
||||
|
||||
### Option 2: [Caddy](https://caddyserver.com) reverse proxy (`Win`, `Linux`, `Mac`)
|
||||
In this example, we misuse Caddy, which is a web server and reverse proxy, to fulfil the above scenario.
|
||||
|
||||
1. [Install Caddy](https://caddyserver.com/)
|
||||
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
|
||||
1. Create a Caddyfile
|
||||
```
|
||||
# /etc/caddy/Caddyfile
|
||||
|
||||
http://localhost:8070 {
|
||||
reverse_proxy * {
|
||||
to https://wakapi.dev # <-- substitute your own Wakapi host here
|
||||
header_up Host {http.reverse_proxy.upstream.host}
|
||||
header_down -Server
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Restart Caddy
|
||||
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
|
||||
1. Update `~/.wakatime.cfg`
|
||||
* Set `api_url = http://localhost:8070/api/heartbeat`
|
||||
1. Done
|
||||
* All Wakapi requests are passed through Caddy now, which keeps a TCP connection with the server open for some time
|
24
go.mod
24
go.mod
@ -3,18 +3,28 @@ module github.com/muety/wakapi
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/emvi/logbuch v1.1.1
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
github.com/gorilla/handlers v1.4.2
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
|
||||
github.com/jinzhu/gorm v1.9.11
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/pretty v0.2.0 // indirect
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/markbates/pkger v0.17.1
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/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/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
go.uber.org/atomic v1.6.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
gorm.io/driver/mysql v1.0.3
|
||||
gorm.io/driver/postgres v1.0.5
|
||||
gorm.io/driver/sqlite v1.1.3
|
||||
gorm.io/gorm v1.20.11
|
||||
)
|
||||
|
198
go.sum
198
go.sum
@ -1,6 +1,6 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
@ -31,21 +31,24 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 h1:epsH3lb7KVbXHYk7LYGN5EiE0MxcevHU85CKITJ0wUY=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
@ -53,16 +56,18 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
|
||||
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
|
||||
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
|
||||
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
@ -71,12 +76,15 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
|
||||
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
|
||||
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
|
||||
github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
|
||||
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
|
||||
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
|
||||
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
|
||||
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
|
||||
@ -84,17 +92,17 @@ github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b
|
||||
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
|
||||
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@ -106,11 +114,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
@ -152,14 +157,64 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
|
||||
github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
|
||||
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
|
||||
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
||||
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
||||
github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
|
||||
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
|
||||
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
|
||||
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
|
||||
github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
|
||||
github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
|
||||
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
|
||||
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
|
||||
github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
|
||||
github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
@ -167,7 +222,6 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
@ -178,29 +232,40 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
|
||||
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
|
||||
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
|
||||
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
|
||||
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
|
||||
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
@ -208,6 +273,9 @@ 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=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
@ -223,6 +291,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
|
||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
@ -252,6 +322,7 @@ github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9
|
||||
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@ -280,6 +351,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
|
||||
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
@ -289,8 +363,12 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
@ -312,18 +390,21 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
@ -332,10 +413,15 @@ go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@ -343,17 +429,22 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
@ -373,6 +464,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -392,19 +484,24 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -414,24 +511,28 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
@ -446,27 +547,40 @@ google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
|
||||
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
|
||||
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
|
||||
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
|
||||
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
||||
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
|
||||
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
|
||||
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
|
293
main.go
293
main.go
@ -1,97 +1,142 @@
|
||||
package main
|
||||
|
||||
//go:generate $GOPATH/bin/pkger
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/markbates/pkger"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations/common"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"gorm.io/gorm/logger"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
customMiddleware "github.com/muety/wakapi/middlewares/custom"
|
||||
"github.com/muety/wakapi/routes"
|
||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
_ "gorm.io/driver/mysql"
|
||||
_ "gorm.io/driver/postgres"
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
config *models.Config
|
||||
config *conf.Config
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
keyValueService *services.KeyValueService
|
||||
aliasRepository repositories.IAliasRepository
|
||||
heartbeatRepository repositories.IHeartbeatRepository
|
||||
userRepository repositories.IUserRepository
|
||||
languageMappingRepository repositories.ILanguageMappingRepository
|
||||
summaryRepository repositories.ISummaryRepository
|
||||
keyValueRepository repositories.IKeyValueRepository
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService services.IAliasService
|
||||
heartbeatService services.IHeartbeatService
|
||||
userService services.IUserService
|
||||
languageMappingService services.ILanguageMappingService
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
keyValueService services.IKeyValueService
|
||||
miscService services.IMiscService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
|
||||
func main() {
|
||||
config = models.GetConfig()
|
||||
config = conf.Load()
|
||||
|
||||
// Enable line numbers in logging
|
||||
// Set log level
|
||||
if config.IsDev() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
logbuch.SetLevel(logbuch.LevelDebug)
|
||||
} else {
|
||||
logbuch.SetLevel(logbuch.LevelInfo)
|
||||
}
|
||||
|
||||
// Show data loss warning
|
||||
if config.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
// Set up GORM
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Minute,
|
||||
Colorful: false,
|
||||
LogLevel: logger.Silent,
|
||||
},
|
||||
)
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||
if config.DbDialect == "sqlite3" {
|
||||
db.DB().Exec("PRAGMA foreign_keys = ON;")
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||
if config.Db.Dialect == "sqlite3" {
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
db.LogMode(config.IsDev())
|
||||
db.DB().SetMaxIdleConns(int(config.DbMaxConn))
|
||||
db.DB().SetMaxOpenConns(int(config.DbMaxConn))
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, _ := db.DB()
|
||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
log.Fatal("could not connect to database")
|
||||
logbuch.Error(err.Error())
|
||||
logbuch.Fatal("could not connect to database")
|
||||
}
|
||||
// TODO: Graceful shutdown
|
||||
defer db.Close()
|
||||
defer sqlDb.Close()
|
||||
|
||||
// Migrate database schema
|
||||
common.RunCustomPreMigrations(db, config)
|
||||
runDatabaseMigrations()
|
||||
applyFixtures()
|
||||
common.RunCustomPostMigrations(db, config)
|
||||
|
||||
// Repositories
|
||||
aliasRepository = repositories.NewAliasRepository(db)
|
||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||
userRepository = repositories.NewUserRepository(db)
|
||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||
summaryRepository = repositories.NewSummaryRepository(db)
|
||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||
|
||||
// Services
|
||||
aliasService = services.NewAliasService(db)
|
||||
heartbeatService = services.NewHeartbeatService(db)
|
||||
userService = services.NewUserService(db)
|
||||
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(db)
|
||||
aliasService = services.NewAliasService(aliasRepository)
|
||||
userService = services.NewUserService(userRepository)
|
||||
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
|
||||
// Custom migrations and initial data
|
||||
migrateLanguages()
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
// Schedule background tasks
|
||||
go aggregationService.Schedule()
|
||||
go miscService.ScheduleCountTotalTime()
|
||||
|
||||
if config.CleanUp {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
routes.Init()
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
settingsHandler := routes.NewSettingsHandler(userService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
@ -99,89 +144,121 @@ func main() {
|
||||
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||
summaryRouter := publicRouter.PathPrefix("/summary").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
|
||||
recoveryMiddleware := handlers.RecoveryHandler()
|
||||
loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
|
||||
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"},
|
||||
[]string{"/api/health", "/api/compat/shields/v1"},
|
||||
).Handler
|
||||
wakatimeRelayMiddleware := customMiddleware.NewWakatimeRelayMiddleware().Handler
|
||||
|
||||
// Router configs
|
||||
router.Use(loggingMiddleware, recoveryMiddleware)
|
||||
summaryRouter.Use(authenticateMiddleware)
|
||||
settingsRouter.Use(authenticateMiddleware)
|
||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||
heartbeatApiRouter.Use(wakatimeRelayMiddleware)
|
||||
|
||||
// Public Routes
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
||||
// Route registrations
|
||||
homeHandler.RegisterRoutes(publicRouter)
|
||||
loginHandler.RegisterRoutes(publicRouter)
|
||||
imprintHandler.RegisterRoutes(publicRouter)
|
||||
summaryHandler.RegisterRoutes(summaryRouter)
|
||||
settingsHandler.RegisterRoutes(settingsRouter)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||
|
||||
// Settings Routes
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
|
||||
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
|
||||
// API Route registrations
|
||||
summaryHandler.RegisterAPIRoutes(summaryApiRouter)
|
||||
healthHandler.RegisterAPIRoutes(healthApiRouter)
|
||||
heartbeatHandler.RegisterAPIRoutes(heartbeatApiRouter)
|
||||
wakatimeV1AllHandler.RegisterAPIRoutes(wakatimeV1Router)
|
||||
wakatimeV1SummariesHandler.RegisterAPIRoutes(wakatimeV1Router)
|
||||
shieldV1BadgeHandler.RegisterAPIRoutes(shieldsV1Router)
|
||||
|
||||
// Static Routes
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(pkger.Dir("/static")))
|
||||
|
||||
// Listen HTTP
|
||||
portString := config.Addr + ":" + strconv.Itoa(config.Port)
|
||||
s := &http.Server{
|
||||
Handler: router,
|
||||
Addr: portString,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
listen(router)
|
||||
}
|
||||
|
||||
func listen(handler http.Handler) {
|
||||
var s4, s6 *http.Server
|
||||
|
||||
// IPv4
|
||||
if config.Server.ListenIpV4 != "" {
|
||||
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||
s4 = &http.Server{
|
||||
Handler: handler,
|
||||
Addr: bindString4,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
log.Printf("Listening on %+s\n", portString)
|
||||
s.ListenAndServe()
|
||||
|
||||
// IPv6
|
||||
if config.Server.ListenIpV6 != "" {
|
||||
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
|
||||
s6 = &http.Server{
|
||||
Handler: handler,
|
||||
Addr: bindString6,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
if config.UseTLS() {
|
||||
if s4 != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
if s4 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-make(chan interface{}, 1)
|
||||
}
|
||||
|
||||
func runDatabaseMigrations() {
|
||||
if err := config.GetMigrationFunc(config.DbDialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyFixtures() {
|
||||
if err := config.GetFixturesFunc(config.DbDialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLanguages() {
|
||||
for k, v := range config.CustomLanguages {
|
||||
result := db.Model(models.Heartbeat{}).
|
||||
Where("language = ?", "").
|
||||
Where("entity LIKE ?", "%."+k).
|
||||
Updates(models.Heartbeat{Language: v})
|
||||
if result.Error != nil {
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promptAbort(message string, timeoutSec int) {
|
||||
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
|
||||
for i := timeoutSec; i > 0; i-- {
|
||||
log.Printf("Starting in %d seconds ...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}
|
||||
|
@ -2,32 +2,25 @@ package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AuthenticateMiddleware struct {
|
||||
config *models.Config
|
||||
userSrvc *services.UserService
|
||||
cache *cache.Cache
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
whitelistPaths []string
|
||||
}
|
||||
|
||||
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
return &AuthenticateMiddleware{
|
||||
config: models.GetConfig(),
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
whitelistPaths: whitelistPaths,
|
||||
}
|
||||
}
|
||||
@ -57,14 +50,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !m.config.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound)
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.cache.Set(user.ID, user, cache.DefaultExpiration)
|
||||
|
||||
ctx := context.WithValue(r.Context(), models.UserKey, user)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
@ -77,51 +68,26 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
|
||||
|
||||
var user *models.User
|
||||
userKey := strings.TrimSpace(key)
|
||||
cachedUser, ok := m.cache.Get(userKey)
|
||||
if !ok {
|
||||
user, err = m.userSrvc.GetUserByKey(userKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
user = cachedUser.(*models.User)
|
||||
user, err = m.userSrvc.GetUserByKey(userKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||
login, err := utils.ExtractCookieAuth(r, m.config)
|
||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachedUser, ok := m.cache.Get(login.Username)
|
||||
|
||||
if ok {
|
||||
return cachedUser.(*models.User), nil
|
||||
}
|
||||
|
||||
user, err := m.userSrvc.GetUserById(login.Username)
|
||||
user, err := m.userSrvc.GetUserById(*username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !CheckAndMigratePassword(user, login, m.config.PasswordSalt, m.userSrvc) {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
// no need to check password here, as securecookie decoding will fail anyway,
|
||||
// if cookie is not properly signed
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
|
||||
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
|
||||
if utils.IsMd5(user.Password) {
|
||||
if utils.CheckPasswordMd5(user, login.Password) {
|
||||
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
|
||||
userServiceRef.MigrateMd5Password(user, login)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return utils.CheckPasswordBcrypt(user, login.Password, salt)
|
||||
}
|
||||
|
56
middlewares/authenticate_test.go
Normal file
56
middlewares/authenticate_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
||||
testApiKey := "z5uig69cn9ut93n"
|
||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||
testUser := &models.User{ApiKey: testApiKey}
|
||||
|
||||
mockRequest := &http.Request{
|
||||
Header: http.Header{
|
||||
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
|
||||
},
|
||||
}
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testUser, result)
|
||||
}
|
||||
|
||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
||||
testApiKey := "z5uig69cn9ut93n"
|
||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||
|
||||
mockRequest := &http.Request{
|
||||
Header: http.Header{
|
||||
// 'Basic' prefix missing here
|
||||
"Authorization": []string{fmt.Sprintf("%s", testToken)},
|
||||
},
|
||||
}
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
// TODO: somehow test cookie auth function
|
94
middlewares/custom/wakatime.go
Normal file
94
middlewares/custom/wakatime.go
Normal file
@ -0,0 +1,94 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* Middleware to conditionally relay heartbeats to Wakatime */
|
||||
type WakatimeRelayMiddleware struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
||||
return &WakatimeRelayMiddleware{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.ServeHTTP(w, r, h.ServeHTTP)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
defer next(w, r)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil || user.WakatimeApiKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
headers := http.Header{
|
||||
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
|
||||
"Content-Type": r.Header.Values("Content-Type"),
|
||||
"Accept": r.Header.Values("Accept"),
|
||||
"User-Agent": r.Header.Values("User-Agent"),
|
||||
"X-Origin": []string{
|
||||
fmt.Sprintf("wakapi v%s", config.Get().Version),
|
||||
},
|
||||
"Authorization": []string{
|
||||
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
|
||||
},
|
||||
}
|
||||
|
||||
go m.send(
|
||||
http.MethodPost,
|
||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsEndpoint,
|
||||
bytes.NewReader(body),
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header) {
|
||||
request, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
logbuch.Warn("error constructing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
for _, h := range v {
|
||||
request.Header.Set(k, h)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
logbuch.Warn("error executing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
logbuch.Warn("failed to relay request, got status %d", response.StatusCode)
|
||||
}
|
||||
}
|
@ -1,17 +1,135 @@
|
||||
package middlewares
|
||||
|
||||
// Borrowed from https://gist.github.com/elithrar/887d162dfd0c539b700ab4049c76e22b
|
||||
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoggingMiddleware struct{}
|
||||
|
||||
func NewLoggingMiddleware() *LoggingMiddleware {
|
||||
return &LoggingMiddleware{}
|
||||
type LoggingMiddleware struct {
|
||||
handler http.Handler
|
||||
output *log.Logger
|
||||
}
|
||||
|
||||
func (m *LoggingMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return handlers.LoggingHandler(os.Stdout, h)
|
||||
func NewLoggingMiddleware(output *log.Logger) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &LoggingMiddleware{
|
||||
handler: h,
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ww := wrapWriter(w)
|
||||
|
||||
start := time.Now()
|
||||
lg.handler.ServeHTTP(ww, r)
|
||||
end := time.Now()
|
||||
duration := end.Sub(start)
|
||||
|
||||
lg.output.Printf(
|
||||
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n",
|
||||
time.Now().Format(time.RFC3339Nano),
|
||||
ww.Status(),
|
||||
r.Method,
|
||||
r.URL.String(),
|
||||
duration,
|
||||
ww.BytesWritten(),
|
||||
readUserIP(r),
|
||||
)
|
||||
}
|
||||
|
||||
func readUserIP(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
if ip == "" {
|
||||
ip = r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// The below writer-wrapping code has been lifted from
|
||||
// https://github.com/zenazn/goji/blob/master/web/middleware/logger.go - because
|
||||
// it does exactly what is needed, and it's unlikely to change in any
|
||||
// significant way that makes copying worse-off than importing. MIT licensed
|
||||
// and (c) Carl Jackson.
|
||||
|
||||
// writerProxy is a proxy around an http.ResponseWriter that allows you to hook
|
||||
// into various parts of the response process.
|
||||
type writerProxy interface {
|
||||
http.ResponseWriter
|
||||
// Status returns the HTTP status of the request, or 0 if one has not
|
||||
// yet been sent.
|
||||
Status() int
|
||||
// BytesWritten returns the total number of bytes sent to the client.
|
||||
BytesWritten() int
|
||||
// Tee causes the response body to be written to the given io.Writer in
|
||||
// addition to proxying the writes through. Only one io.Writer can be
|
||||
// tee'd to at once: setting a second one will overwrite the first.
|
||||
// Writes will be sent to the proxy before being written to this
|
||||
// io.Writer. It is illegal for the tee'd writer to be modified
|
||||
// concurrently with writes.
|
||||
Tee(io.Writer)
|
||||
// Unwrap returns the original proxied target.
|
||||
Unwrap() http.ResponseWriter
|
||||
}
|
||||
|
||||
// wrapWriter wraps an http.ResponseWriter, returning a proxy that allows you to
|
||||
// hook into various parts of the response process.
|
||||
func wrapWriter(w http.ResponseWriter) writerProxy {
|
||||
return &basicWriter{ResponseWriter: w}
|
||||
}
|
||||
|
||||
// basicWriter wraps a http.ResponseWriter that implements the minimal
|
||||
// http.ResponseWriter interface.
|
||||
type basicWriter struct {
|
||||
http.ResponseWriter
|
||||
wroteHeader bool
|
||||
code int
|
||||
bytes int
|
||||
tee io.Writer
|
||||
}
|
||||
|
||||
func (b *basicWriter) WriteHeader(code int) {
|
||||
if !b.wroteHeader {
|
||||
b.code = code
|
||||
b.wroteHeader = true
|
||||
b.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Write(buf []byte) (int, error) {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
n, err := b.ResponseWriter.Write(buf)
|
||||
if b.tee != nil {
|
||||
_, err2 := b.tee.Write(buf[:n])
|
||||
// Prefer errors generated by the proxied writer.
|
||||
if err == nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
b.bytes += n
|
||||
return n, err
|
||||
}
|
||||
func (b *basicWriter) maybeWriteHeader() {
|
||||
if !b.wroteHeader {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Status() int {
|
||||
return b.code
|
||||
}
|
||||
func (b *basicWriter) BytesWritten() int {
|
||||
return b.bytes
|
||||
}
|
||||
func (b *basicWriter) Tee(w io.Writer) {
|
||||
b.tee = w
|
||||
}
|
||||
func (b *basicWriter) Unwrap() http.ResponseWriter {
|
||||
return b.ResponseWriter
|
||||
}
|
||||
|
11
migrations/common/common.go
Normal file
11
migrations/common/common.go
Normal file
@ -0,0 +1,11 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type migrationFunc struct {
|
||||
f func(db *gorm.DB, cfg *config.Config) error
|
||||
name string
|
||||
}
|
30
migrations/common/custom_post.go
Normal file
30
migrations/common/custom_post.go
Normal file
@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
108
migrations/common/custom_pre.go
Normal file
108
migrations/common/custom_pre.go
Normal file
@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values (`key`, `value`) values ('imprint', 'no content here');
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
delete from key_string_values where `key` = 'imprint';
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
25
migrations/common/languages.go
Normal file
25
migrations/common/languages.go
Normal file
@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
create table aliases
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
type integer not null,
|
||||
user_id varchar(255) not null,
|
||||
key varchar(255) not null,
|
||||
value varchar(255) not null
|
||||
);
|
||||
|
||||
create index idx_alias_type_key
|
||||
on aliases (type, key);
|
||||
|
||||
create index idx_alias_user
|
||||
on aliases (user_id);
|
||||
|
||||
create table summaries
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
user_id varchar(255) not null,
|
||||
from_time timestamp default CURRENT_TIMESTAMP not null,
|
||||
to_time timestamp default CURRENT_TIMESTAMP not null
|
||||
);
|
||||
|
||||
create index idx_time_summary_user
|
||||
on summaries (user_id, from_time, to_time);
|
||||
|
||||
create table summary_items
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
summary_id integer REFERENCES summaries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
type integer,
|
||||
key varchar(255),
|
||||
total bigint
|
||||
);
|
||||
|
||||
create table users
|
||||
(
|
||||
id varchar(255) primary key,
|
||||
api_key varchar(255) unique,
|
||||
password varchar(255)
|
||||
);
|
||||
|
||||
create table heartbeats
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
user_id varchar(255) not null REFERENCES users (id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
entity varchar(255) not null,
|
||||
type varchar(255),
|
||||
category varchar(255),
|
||||
project varchar(255),
|
||||
branch varchar(255),
|
||||
language varchar(255),
|
||||
is_write bool,
|
||||
editor varchar(255),
|
||||
operating_system varchar(255),
|
||||
time timestamp default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create index idx_entity
|
||||
on heartbeats (entity);
|
||||
|
||||
create index idx_language
|
||||
on heartbeats (language);
|
||||
|
||||
create index idx_time
|
||||
on heartbeats (time);
|
||||
|
||||
create index idx_time_user
|
||||
on heartbeats (user_id, time);
|
||||
|
||||
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
DROP INDEX idx_alias_user;
|
||||
DROP INDEX idx_alias_type_key;
|
||||
DROP TABLE aliases;
|
||||
DROP INDEX idx_time_summary_user;
|
||||
DROP TABLE summaries;
|
||||
DROP TABLE summary_items;
|
||||
DROP TABLE heartbeats;
|
||||
DROP INDEX idx_entity;
|
||||
DROP INDEX idx_language;
|
||||
DROP INDEX idx_time;
|
||||
DROP INDEX idx_time_user;
|
@ -1,11 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
create table key_string_values
|
||||
(
|
||||
key varchar(255) primary key,
|
||||
value text
|
||||
);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
drop table key_string_value;
|
@ -1,20 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
-- SQLite does not allow altering a table to add a new column with default of CURRENT_TIMESTAMP
|
||||
-- See https://www.sqlite.org/lang_altertable.html
|
||||
|
||||
alter table users
|
||||
add `created_at` timestamp default '2020-01-01T00:00:00.000' not null;
|
||||
|
||||
alter table users
|
||||
add `last_logged_in_at` timestamp default '2020-01-01T00:00:00.000' not null;
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `created_at`;
|
||||
|
||||
alter table users
|
||||
drop column `last_logged_in_at`;
|
@ -1,11 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table users
|
||||
add `machine` varchar(255);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `machine`;
|
45
mocks/alias_repository.go
Normal file
45
mocks/alias_repository.go
Normal file
@ -0,0 +1,45 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AliasRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndKey(s string, s2 string) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndTypeAndValue(s string, u uint8, s2 string) (*models.Alias, error) {
|
||||
args := m.Called(s, u, s2)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) Insert(s *models.Alias) (*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) Delete(u uint) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) DeleteBatch(u []uint) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
50
mocks/alias_service.go
Normal file
50
mocks/alias_service.go
Normal file
@ -0,0 +1,50 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AliasServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) IsInitialized(s string) bool {
|
||||
args := m.Called(s)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) InitializeUser(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (string, error) {
|
||||
args := m.Called(s, u, s2)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) Create(a *models.Alias) (*models.Alias, error) {
|
||||
args := m.Called(a)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) Delete(s *models.Alias) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) DeleteMulti(a []*models.Alias) error {
|
||||
args := m.Called(a)
|
||||
return args.Error(0)
|
||||
}
|
31
mocks/heartbeat_service.go
Normal file
31
mocks/heartbeat_service.go
Normal file
@ -0,0 +1,31 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
args := m.Called(heartbeats)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
args := m.Called(time, time2, user)
|
||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||
args := m.Called(time)
|
||||
return args.Error(0)
|
||||
}
|
31
mocks/summary_repository.go
Normal file
31
mocks/summary_repository.go
Normal file
@ -0,0 +1,31 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummaryRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
||||
args := m.Called(summary)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(user, time, time2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
55
mocks/user_service.go
Normal file
55
mocks/user_service.go
Normal file
@ -0,0 +1,55 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type UserServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
|
||||
args := m.Called(signup)
|
||||
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) {
|
||||
args := m.Called(user, s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
args := m.Called(user, login)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
@ -2,8 +2,22 @@ package models
|
||||
|
||||
type Alias struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
UserID string `gorm:"not null; index:idx_alias_user"`
|
||||
Key string `gorm:"not null; index:idx_alias_type_key"`
|
||||
Value string `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (a *Alias) IsValid() bool {
|
||||
return a.Key != "" && a.Value != "" && a.validateType()
|
||||
}
|
||||
|
||||
func (a *Alias) validateType() bool {
|
||||
for _, t := range SummaryTypes() {
|
||||
if a.Type == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
37
models/compat/shields/v1/badge.go
Normal file
37
models/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,37 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://shields.io/endpoint
|
||||
|
||||
const (
|
||||
defaultLabel = "coding time"
|
||||
defaultColor = "#2D3748" // not working
|
||||
)
|
||||
|
||||
type BadgeData struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
|
||||
var total time.Duration
|
||||
if hasFilter, _, _ := filters.One(); hasFilter {
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &BadgeData{
|
||||
SchemaVersion: 1,
|
||||
Label: defaultLabel,
|
||||
Message: utils.FmtWakatimeDuration(total),
|
||||
Color: defaultColor,
|
||||
}
|
||||
}
|
36
models/compat/wakatime/v1/all_time.go
Normal file
36
models/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,36 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
|
||||
type AllTimeViewModel struct {
|
||||
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>
|
||||
}
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &AllTimeViewModel{
|
||||
Data: &allTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
},
|
||||
}
|
||||
}
|
176
models/compat/wakatime/v1/summaries.go
Normal file
176
models/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,176 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#summaries
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
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 summariesEntry struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Name string `json:"name"`
|
||||
Percent float64 `json:"percent"`
|
||||
Seconds int `json:"seconds"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesGrandTotal struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesRange struct {
|
||||
Date string `json:"date"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
Text string `json:"text"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||
data := make([]*summariesData, len(summaries))
|
||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||
|
||||
for i, s := range summaries {
|
||||
data[i] = newDataFrom(s)
|
||||
|
||||
if s.FromTime.T().Before(minDate) {
|
||||
minDate = s.FromTime.T()
|
||||
}
|
||||
if s.ToTime.T().After(maxDate) {
|
||||
maxDate = s.ToTime.T()
|
||||
}
|
||||
}
|
||||
|
||||
return &SummariesViewModel{
|
||||
Data: data,
|
||||
End: maxDate,
|
||||
Start: minDate,
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime.T(),
|
||||
Start: s.FromTime.T(),
|
||||
Text: "",
|
||||
Timezone: zone,
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
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) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Editors {
|
||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.OperatingSystems {
|
||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Machines {
|
||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||
}
|
||||
}(data)
|
||||
|
||||
wg.Wait()
|
||||
return data
|
||||
}
|
||||
|
||||
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
|
||||
hrs := int(total.Hours())
|
||||
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
||||
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
||||
percentage := math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100
|
||||
if math.IsNaN(percentage) || math.IsInf(percentage, 0) {
|
||||
percentage = 0
|
||||
}
|
||||
|
||||
return &summariesEntry{
|
||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||
Hours: hrs,
|
||||
Minutes: mins,
|
||||
Name: e.Key,
|
||||
Percent: percentage,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
}
|
||||
}
|
229
models/config.go
229
models/config.go
@ -1,229 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/joho/godotenv"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"gopkg.in/ini.v1"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var cfg *Config
|
||||
|
||||
type Config struct {
|
||||
Env string
|
||||
Version string
|
||||
Port int
|
||||
Addr string
|
||||
BasePath string
|
||||
DbHost string
|
||||
DbPort uint
|
||||
DbUser string
|
||||
DbPassword string
|
||||
DbName string
|
||||
DbDialect string
|
||||
DbMaxConn uint
|
||||
CleanUp bool
|
||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||
PasswordSalt string
|
||||
SecureCookieHashKey string
|
||||
SecureCookieBlockKey string
|
||||
InsecureCookies bool
|
||||
CustomLanguages map[string]string
|
||||
LanguageColors map[string]string
|
||||
SecureCookie *securecookie.SecureCookie
|
||||
}
|
||||
|
||||
func (c *Config) IsDev() bool {
|
||||
return IsDev(c.Env)
|
||||
}
|
||||
|
||||
func (c *Config) GetMigrationFunc(dbDialect string) MigrationFunc {
|
||||
switch dbDialect {
|
||||
case "sqlite3":
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.FileMigrationSource{
|
||||
Dir: "migrations/sqlite3",
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied %d migrations\n", n)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
db.AutoMigrate(&Alias{})
|
||||
db.AutoMigrate(&Summary{})
|
||||
db.AutoMigrate(&SummaryItem{})
|
||||
db.AutoMigrate(&User{})
|
||||
db.AutoMigrate(&Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
|
||||
db.AutoMigrate(&SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
|
||||
db.AutoMigrate(&KeyStringValue{})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetFixturesFunc(dbDialect string) MigrationFunc {
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.FileMigrationSource{
|
||||
Dir: "migrations/common/fixtures",
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied %d fixtures\n", n)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
|
||||
func SetConfig(config *Config) {
|
||||
cfg = config
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func LookupFatal(key string) string {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
log.Fatalf("missing env variable '%s'", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
file, err := os.Open("version.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func readConfig() *Config {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
version := readVersion()
|
||||
|
||||
env := LookupFatal("ENV")
|
||||
dbType := LookupFatal("WAKAPI_DB_TYPE")
|
||||
dbUser := LookupFatal("WAKAPI_DB_USER")
|
||||
dbPassword := LookupFatal("WAKAPI_DB_PASSWORD")
|
||||
dbHost := LookupFatal("WAKAPI_DB_HOST")
|
||||
dbName := LookupFatal("WAKAPI_DB_NAME")
|
||||
dbPortStr := LookupFatal("WAKAPI_DB_PORT")
|
||||
passwordSalt := LookupFatal("WAKAPI_PASSWORD_SALT")
|
||||
dbPort, err := strconv.Atoi(dbPortStr)
|
||||
|
||||
cfg, err := ini.Load("config.ini")
|
||||
if err != nil {
|
||||
log.Fatalf("Fail to read file: %v", err)
|
||||
}
|
||||
|
||||
if dbType == "" {
|
||||
dbType = "mysql"
|
||||
}
|
||||
|
||||
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(1)
|
||||
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
|
||||
insecureCookies := IsDev(env) || cfg.Section("server").Key("insecure_cookies").MustBool(false)
|
||||
port, err := strconv.Atoi(os.Getenv("PORT"))
|
||||
if err != nil {
|
||||
port = cfg.Section("server").Key("port").MustInt()
|
||||
}
|
||||
|
||||
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
|
||||
basePath := cfg.Section("server").Key("base_path").MustString("/")
|
||||
if basePathEnvExists {
|
||||
basePath = basePathEnv
|
||||
}
|
||||
if strings.HasSuffix(basePath, "/") {
|
||||
basePath = basePath[:len(basePath)-1]
|
||||
}
|
||||
|
||||
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
|
||||
|
||||
// Read custom languages
|
||||
customLangs := make(map[string]string)
|
||||
languageKeys := cfg.Section("languages").Keys()
|
||||
for _, k := range languageKeys {
|
||||
customLangs[k.Name()] = k.MustString("unknown")
|
||||
}
|
||||
|
||||
// Read language colors
|
||||
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
var colors = make(map[string]string)
|
||||
var rawColors map[string]struct {
|
||||
Color string `json:"color"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile("data/colors.json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &rawColors); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for k, v := range rawColors {
|
||||
colors[strings.ToLower(k)] = v.Color
|
||||
}
|
||||
|
||||
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
|
||||
secureCookie := securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
)
|
||||
|
||||
return &Config{
|
||||
Env: env,
|
||||
Version: version,
|
||||
Port: port,
|
||||
Addr: addr,
|
||||
BasePath: basePath,
|
||||
DbHost: dbHost,
|
||||
DbPort: uint(dbPort),
|
||||
DbUser: dbUser,
|
||||
DbPassword: dbPassword,
|
||||
DbName: dbName,
|
||||
DbDialect: dbType,
|
||||
DbMaxConn: dbMaxConn,
|
||||
CleanUp: cleanUp,
|
||||
InsecureCookies: insecureCookies,
|
||||
SecureCookie: secureCookie,
|
||||
PasswordSalt: passwordSalt,
|
||||
CustomLanguages: customLangs,
|
||||
LanguageColors: colors,
|
||||
}
|
||||
}
|
45
models/filters.go
Normal file
45
models/filters.go
Normal file
@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
OS string
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
}
|
||||
|
||||
type FilterElement struct {
|
||||
Type uint8
|
||||
Key string
|
||||
}
|
||||
|
||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
switch entity {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{OS: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Language: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Machine: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
||||
func (f *Filters) One() (bool, uint8, string) {
|
||||
if f.Project != "" {
|
||||
return true, SummaryProject, f.Project
|
||||
} else if f.OS != "" {
|
||||
return true, SummaryOS, f.OS
|
||||
} else if f.Language != "" {
|
||||
return true, SummaryLanguage, f.Language
|
||||
} else if f.Editor != "" {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
}
|
||||
return false, 0, ""
|
||||
}
|
@ -1,47 +1,101 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/mitchellh/hashstructure/v2"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Project string `json:"project"`
|
||||
Branch string `json:"branch"`
|
||||
Language string `json:"language" gorm:"index:idx_language"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Editor string `json:"editor"`
|
||||
OperatingSystem string `json:"operating_system"`
|
||||
Machine string `json:"machine"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||
languageRegex *regexp.Regexp
|
||||
ID uint `gorm:"primary_key" hash:"ignore"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Project string `json:"project"`
|
||||
Branch string `json:"branch"`
|
||||
Language string `json:"language" gorm:"index:idx_language"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Editor string `json:"editor"`
|
||||
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"`
|
||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||
languageRegex *regexp.Regexp `hash:"ignore"`
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Valid() bool {
|
||||
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
|
||||
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(customLangs map[string]string) {
|
||||
if h.Language == "" {
|
||||
if h.languageRegex == nil {
|
||||
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
|
||||
}
|
||||
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 2 {
|
||||
return
|
||||
}
|
||||
ending := groups[0][1]
|
||||
if _, ok := customLangs[ending]; !ok {
|
||||
return
|
||||
}
|
||||
h.Language, _ = customLangs[ending]
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
if h.languageRegex == nil {
|
||||
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
|
||||
}
|
||||
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 2 {
|
||||
return
|
||||
}
|
||||
ending := groups[0][1]
|
||||
if _, ok := languageMappings[ending]; !ok {
|
||||
return
|
||||
}
|
||||
h.Language, _ = languageMappings[ending]
|
||||
}
|
||||
|
||||
func (h *Heartbeat) GetKey(t uint8) (key string) {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
key = h.Project
|
||||
case SummaryEditor:
|
||||
key = h.Editor
|
||||
case SummaryLanguage:
|
||||
key = h.Language
|
||||
case SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = UnknownSummaryKey
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func (h *Heartbeat) String() string {
|
||||
return fmt.Sprintf(
|
||||
"Heartbeat {user=%s, entity=%s, type=%s, category=%s, project=%s, branch=%s, language=%s, iswrite=%v, editor=%s, os=%s, machine=%s, time=%d}",
|
||||
h.UserID,
|
||||
h.Entity,
|
||||
h.Type,
|
||||
h.Category,
|
||||
h.Project,
|
||||
h.Branch,
|
||||
h.Language,
|
||||
h.IsWrite,
|
||||
h.Editor,
|
||||
h.OperatingSystem,
|
||||
h.Machine,
|
||||
(time.Time(h.Time)).UnixNano(),
|
||||
)
|
||||
}
|
||||
|
||||
// Hash is used to prevent duplicate heartbeats
|
||||
// Using a UNIQUE INDEX over all relevant columns would be more straightforward,
|
||||
// whereas manually computing this kind of hash is quite cumbersome. However,
|
||||
// such a unique index would, according to https://stackoverflow.com/q/65980064/3112139,
|
||||
// essentially double the space required for heartbeats, so we decided to go this way.
|
||||
|
||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
||||
if err != nil {
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
||||
}
|
||||
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||
return h
|
||||
}
|
||||
|
53
models/heartbeat_test.go
Normal file
53
models/heartbeat_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeat_Valid_Success(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
User: &User{
|
||||
ID: "johndoe@example.org",
|
||||
},
|
||||
UserID: "johndoe@example.org",
|
||||
Time: CustomTime(time.Now()),
|
||||
}
|
||||
assert.True(t, sut.Valid())
|
||||
}
|
||||
|
||||
func TestHeartbeat_Valid_MissingUser(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
Time: CustomTime(time.Now()),
|
||||
}
|
||||
assert.False(t, sut.Valid())
|
||||
}
|
||||
|
||||
func TestHeartbeat_Augment(t *testing.T) {
|
||||
testMappings := map[string]string{
|
||||
"py": "Python3",
|
||||
}
|
||||
|
||||
sut := &Heartbeat{
|
||||
Entity: "~/dev/file.py",
|
||||
Language: "Python",
|
||||
}
|
||||
|
||||
sut.Augment(testMappings)
|
||||
|
||||
assert.Equal(t, "Python3", sut.Language)
|
||||
}
|
||||
|
||||
func TestHeartbeat_GetKey(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
Project: "wakapi",
|
||||
}
|
||||
|
||||
assert.Equal(t, "wakapi", sut.GetKey(SummaryProject))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryOS))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryMachine))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryLanguage))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
|
||||
}
|
38
models/heartbeats.go
Normal file
38
models/heartbeats.go
Normal file
@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import "sort"
|
||||
|
||||
type Heartbeats []*Heartbeat
|
||||
|
||||
func (h Heartbeats) Len() int {
|
||||
return len(h)
|
||||
}
|
||||
|
||||
func (h Heartbeats) Less(i, j int) bool {
|
||||
return h[i].Time.T().Before(h[j].Time.T())
|
||||
}
|
||||
|
||||
func (h Heartbeats) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Sorted() *Heartbeats {
|
||||
sort.Sort(h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Heartbeats) First() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[0]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Last() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[h.Len()-1]
|
||||
}
|
21
models/language_mapping.go
Normal file
21
models/language_mapping.go
Normal file
@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
type LanguageMapping struct {
|
||||
ID uint `json:"id" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
|
||||
Language string `json:"language" gorm:"type:varchar(64)"`
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) IsValid() bool {
|
||||
return m.validateLanguage() && m.validateExtension()
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateLanguage() bool {
|
||||
return len(m.Language) >= 1
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateExtension() bool {
|
||||
return len(m.Extension) >= 1
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
package models
|
||||
|
||||
func init() {
|
||||
SetConfig(readConfig())
|
||||
// nothing no init here, yet
|
||||
}
|
||||
|
@ -4,7 +4,8 @@ import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"gorm.io/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -23,46 +24,71 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Split(strings.Trim(string(b), "\""), ".")[0]
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.Unix(i, 0)
|
||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
||||
*j = CustomTime(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
||||
func (j *CustomTime) Scan(value interface{}) error {
|
||||
var (
|
||||
t time.Time
|
||||
err error
|
||||
)
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
t, err := time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||
}
|
||||
*j = CustomTime(t)
|
||||
case int64:
|
||||
*j = CustomTime(time.Unix(value.(int64), 0))
|
||||
t = time.Unix(0, value.(int64))
|
||||
break
|
||||
case time.Time:
|
||||
*j = CustomTime(value.(time.Time))
|
||||
t = value.(time.Time)
|
||||
break
|
||||
default:
|
||||
return errors.New(fmt.Sprintf("unsupported type: %T", value))
|
||||
}
|
||||
|
||||
t = time.Unix(0, (t.UnixNano()/int64(time.Millisecond))*int64(time.Millisecond)) // round to millisecond precision
|
||||
*j = CustomTime(t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) Value() (driver.Value, error) {
|
||||
return time.Time(j), nil
|
||||
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
|
||||
func (j CustomTime) Time() time.Time {
|
||||
func (j CustomTime) T() time.Time {
|
||||
return time.Time(j)
|
||||
}
|
||||
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -13,22 +14,56 @@ 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"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime time.Time `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime time.Time `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects"`
|
||||
Languages []*SummaryItem `json:"languages"`
|
||||
Editors []*SummaryItem `json:"editors"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
||||
Machines []*SummaryItem `json:"machines"`
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type SummaryItems []*SummaryItem
|
||||
|
||||
type SummaryItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
SummaryID uint `json:"-"`
|
||||
Type uint8 `json:"-"`
|
||||
Key string `json:"key"`
|
||||
@ -43,11 +78,49 @@ type SummaryItemContainer struct {
|
||||
type SummaryViewModel struct {
|
||||
*Summary
|
||||
LanguageColors map[string]string
|
||||
EditorColors map[string]string
|
||||
OSColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type SummaryParams struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
User *User
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Sorted() *Summary {
|
||||
sort.Sort(sort.Reverse(s.Projects))
|
||||
sort.Sort(sort.Reverse(s.Machines))
|
||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||
sort.Sort(sort.Reverse(s.Languages))
|
||||
sort.Sort(sort.Reverse(s.Editors))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
||||
return map[uint8]*SummaryItems{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
}
|
||||
|
||||
/* 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"
|
||||
@ -60,22 +133,13 @@ To avoid having to modify persisted data retrospectively, i.e. inserting a dummy
|
||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||
*/
|
||||
func (s *Summary) FillUnknown() {
|
||||
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
types := s.Types()
|
||||
typeItems := s.MappedItems()
|
||||
missingTypes := make([]uint8, 0)
|
||||
typeItems := map[uint8]*[]*SummaryItem{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
var somePresentType uint8
|
||||
|
||||
for _, t := range types {
|
||||
if len(*typeItems[t]) == 0 {
|
||||
missingTypes = append(missingTypes, t)
|
||||
} else {
|
||||
somePresentType = t
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,11 +148,7 @@ func (s *Summary) FillUnknown() {
|
||||
return
|
||||
}
|
||||
|
||||
// calculate total duration from any of the present sets of items
|
||||
var timeSum time.Duration
|
||||
for _, item := range *typeItems[somePresentType] {
|
||||
timeSum += item.Total
|
||||
}
|
||||
timeSum := s.TotalTime()
|
||||
|
||||
// construct dummy item for all missing types
|
||||
for _, t := range missingTypes {
|
||||
@ -99,3 +159,113 @@ func (s *Summary) FillUnknown() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTime() time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
// calculate total duration from any of the present sets of items
|
||||
for _, t := range s.Types() {
|
||||
if items := mappedItems[t]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum = timeSum + item.Total*time.Second
|
||||
}
|
||||
}
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
if item.Key != key {
|
||||
continue
|
||||
}
|
||||
timeSum = timeSum + item.Total*time.Second
|
||||
}
|
||||
}
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
|
||||
do, typeId, key := filters.One()
|
||||
if do {
|
||||
return s.TotalTimeByKey(typeId, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||
target := make([]*SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
s.Projects = processAliases(s.Projects)
|
||||
s.Editors = processAliases(s.Editors)
|
||||
s.Languages = processAliases(s.Languages)
|
||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||
s.Machines = processAliases(s.Machines)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s SummaryItems) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s SummaryItems) Less(i, j int) bool {
|
||||
return s[i].Total < s[j].Total
|
||||
}
|
||||
|
||||
func (s SummaryItems) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
202
models/summary_test.go
Normal file
202
models/summary_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSummary_FillUnknown(t *testing.T) {
|
||||
testDuration := 10 * time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut.FillUnknown()
|
||||
|
||||
itemLists := [][]*SummaryItem{
|
||||
sut.Machines,
|
||||
sut.OperatingSystems,
|
||||
sut.Languages,
|
||||
sut.Editors,
|
||||
}
|
||||
for _, l := range itemLists {
|
||||
assert.Len(t, l, 1)
|
||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
||||
assert.Equal(t, testDuration, l[0].Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Go",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
|
||||
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
|
||||
}
|
||||
|
||||
func TestSummary_TotalTimeByFilters(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Go",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Specifying filters about multiple entites is not supported at the moment
|
||||
// as the current, very rudimentary, time calculation logic wouldn't make sense then.
|
||||
// Evaluating a filter like (project="wakapi", language="go") can only be realized
|
||||
// before computing the summary in the first place, because afterwards we can't know
|
||||
// what time coded in "Go" was in the "Wakapi" project
|
||||
// See https://github.com/muety/wakapi/issues/108
|
||||
|
||||
filters1 := &Filters{Project: "wakapi"}
|
||||
filters2 := &Filters{Language: "Go"}
|
||||
filters3 := &Filters{}
|
||||
|
||||
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
|
||||
assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2))
|
||||
assert.Zero(t, sut.TotalTimeByFilters(filters3))
|
||||
}
|
||||
|
||||
func TestSummary_WithResolvedAliases(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
|
||||
|
||||
var resolver AliasResolver = func(t uint8, k string) string {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
switch k {
|
||||
case "wakapi-mobile":
|
||||
return "wakapi"
|
||||
}
|
||||
case SummaryLanguage:
|
||||
switch k {
|
||||
case "Java 8":
|
||||
return "Java"
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi-mobile",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Java 8",
|
||||
Total: testDuration4 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut = sut.WithResolvedAliases(resolver)
|
||||
|
||||
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
|
||||
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
|
||||
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
|
||||
assert.Len(t, sut.Projects, 2)
|
||||
assert.Len(t, sut.Languages, 1)
|
||||
assert.Empty(t, sut.Editors)
|
||||
assert.Empty(t, sut.OperatingSystems)
|
||||
assert.Empty(t, sut.Machines)
|
||||
}
|
||||
|
||||
func TestSummaryItems_Sorted(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
Total: testDuration1,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr-mobile",
|
||||
Total: testDuration3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut = sut.Sorted()
|
||||
|
||||
assert.Equal(t, testDuration3, sut.Projects[0].Total)
|
||||
assert.Equal(t, testDuration1, sut.Projects[1].Total)
|
||||
assert.Equal(t, testDuration2, sut.Projects[2].Total)
|
||||
}
|
@ -6,6 +6,8 @@ type User struct {
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -25,6 +27,11 @@ type CredentialsReset struct {
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
User string
|
||||
Time CustomTime
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return validatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
@ -37,7 +44,7 @@ func (s *Signup) IsValid() bool {
|
||||
}
|
||||
|
||||
func validateUsername(username string) bool {
|
||||
return len(username) >= 3
|
||||
return len(username) >= 1 && username != "current"
|
||||
}
|
||||
|
||||
func validatePassword(password string) bool {
|
||||
|
18
models/view/home.go
Normal file
18
models/view/home.go
Normal file
@ -0,0 +1,18 @@
|
||||
package view
|
||||
|
||||
type HomeViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
TotalHours int
|
||||
TotalUsers int
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
22
models/view/imprint.go
Normal file
22
models/view/imprint.go
Normal file
@ -0,0 +1,22 @@
|
||||
package view
|
||||
|
||||
type ImprintViewModel struct {
|
||||
HtmlText string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithHtmlText(t string) *ImprintViewModel {
|
||||
s.HtmlText = t
|
||||
return s
|
||||
}
|
16
models/view/login.go
Normal file
16
models/view/login.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type LoginViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
27
models/view/settings.go
Normal file
27
models/view/settings.go
Normal file
@ -0,0 +1,27 @@
|
||||
package view
|
||||
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
type SettingsVMCombinedAlias struct {
|
||||
Key string
|
||||
Type uint8
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
16
models/view/summary.go
Normal file
16
models/view/summary.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type SummaryViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
89
repositories/alias.go
Normal file
89
repositories/alias.go
Normal file
@ -0,0 +1,89 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AliasRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAliasRepository(db *gorm.DB) *AliasRepository {
|
||||
return &AliasRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{UserID: userId}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Key: key,
|
||||
}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Key: key,
|
||||
Type: summaryType,
|
||||
}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
||||
alias := &models.Alias{}
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Type: summaryType,
|
||||
Value: value,
|
||||
}).
|
||||
First(alias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alias, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) Insert(alias *models.Alias) (*models.Alias, error) {
|
||||
if !alias.IsValid() {
|
||||
return nil, errors.New("invalid alias")
|
||||
}
|
||||
result := r.db.Create(alias)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alias, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) Delete(id uint) error {
|
||||
return r.db.
|
||||
Where("id = ?", id).
|
||||
Delete(models.Alias{}).Error
|
||||
}
|
||||
|
||||
func (r *AliasRepository) DeleteBatch(ids []uint) error {
|
||||
return r.db.
|
||||
Where("id IN ?", ids).
|
||||
Delete(models.Alias{}).Error
|
||||
}
|
54
repositories/heartbeart.go
Normal file
54
repositories/heartbeart.go
Normal file
@ -0,0 +1,54 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
return &HeartbeatRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
if err := r.db.Create(&heartbeats).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time < ?", to).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, min(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
63
repositories/key_value.go
Normal file
63
repositories/key_value.go
Normal file
@ -0,0 +1,63 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type KeyValueRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
||||
return &KeyValueRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := r.db.
|
||||
Where(&models.KeyStringValue{Key: key}).
|
||||
First(&kv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||
result := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
UpdateAll: true,
|
||||
}).
|
||||
Where(&models.KeyStringValue{Key: kv.Key}).
|
||||
Assign(kv).
|
||||
Create(kv)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) DeleteString(key string) error {
|
||||
result := r.db.
|
||||
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing deleted")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
52
repositories/language_mapping.go
Normal file
52
repositories/language_mapping.go
Normal file
@ -0,0 +1,52 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LanguageMappingRepository struct {
|
||||
config *config.Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
|
||||
return &LanguageMappingRepository{config: config.Get(), db: db}
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
mapping := &models.LanguageMapping{}
|
||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||
return mapping, err
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||
var mappings []*models.LanguageMapping
|
||||
if err := r.db.
|
||||
Where(&models.LanguageMapping{UserID: userId}).
|
||||
Find(&mappings).Error; err != nil {
|
||||
return mappings, err
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||
if !mapping.IsValid() {
|
||||
return nil, errors.New("invalid mapping")
|
||||
}
|
||||
result := r.db.Create(mapping)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) Delete(id uint) error {
|
||||
return r.db.
|
||||
Where("id = ?", id).
|
||||
Delete(models.LanguageMapping{}).Error
|
||||
}
|
52
repositories/repositories.go
Normal file
52
repositories/repositories.go
Normal file
@ -0,0 +1,52 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAliasRepository interface {
|
||||
Insert(*models.Alias) (*models.Alias, error)
|
||||
Delete(uint) error
|
||||
DeleteBatch([]uint) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKey(string, string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetByUserAndTypeAndValue(string, uint8, string) (*models.Alias, error)
|
||||
}
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
type IKeyValueRepository interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
Delete(uint) error
|
||||
}
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
}
|
||||
|
||||
type IUserRepository interface {
|
||||
GetById(string) (*models.User, error)
|
||||
GetByApiKey(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
InsertOrGet(*models.User) (*models.User, bool, error)
|
||||
Update(*models.User) (*models.User, error)
|
||||
UpdateField(*models.User, string, interface{}) (*models.User, error)
|
||||
}
|
59
repositories/summary.go
Normal file
59
repositories/summary.go
Normal file
@ -0,0 +1,59 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummaryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
|
||||
return &SummaryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) Insert(summary *models.Summary) error {
|
||||
if err := r.db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(to_time) as time").
|
||||
Joins("left join summaries on users.id = summaries.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Delete(models.Summary{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
80
repositories/user.go
Normal file
80
repositories/user.go
Normal file
@ -0,0 +1,80 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetById(userId string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Table("users").
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
||||
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
|
||||
if err := result.Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if result.RowsAffected == 1 {
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
return user, false, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
result := r.db.Model(user).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
|
||||
result := r.db.Model(user).Update(key, value)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
116
routes/compat/shields/v1/badge.go
Normal file
116
routes/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,116 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {}
|
||||
|
||||
func (h *BadgeHandler) RegisterAPIRoutes(router *mux.Router) {
|
||||
router.Methods(http.MethodGet).HandlerFunc(h.ApiGet)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
intervalReg := regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
if userAgent := r.Header.Get("user-agent"); !strings.HasPrefix(userAgent, "Shields.io/") && !h.config.IsDev() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
if err != nil || !user.BadgesEnabled {
|
||||
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]
|
||||
}
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
interval = groups[1]
|
||||
}
|
||||
|
||||
var filters *models.Filters
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||
case "os":
|
||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||
case "editor":
|
||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||
case "language":
|
||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||
case "machine":
|
||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||
default:
|
||||
filters = &models.Filters{}
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
75
routes/compat/wakatime/v1/all_time.go
Normal file
75
routes/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,75 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
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) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(authorizedUser)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: time.Time{},
|
||||
To: time.Now(),
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
115
routes/compat/wakatime/v1/summaries.go
Normal file
115
routes/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,115 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
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)
|
||||
}
|
||||
|
||||
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
|
||||
// See https://wakatime.com/developers#summaries.
|
||||
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
|
||||
// Requires https://github.com/muety/wakapi/issues/108.
|
||||
|
||||
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err, status := h.loadUserSummaries(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
|
||||
filters.Project = projectQuery
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
|
||||
|
||||
var start, end time.Time
|
||||
if rangeParam != "" {
|
||||
// range param takes precedence
|
||||
if err, parsedFrom, parsedTo := utils.ResolveInterval(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 {
|
||||
// also accept start param to be a range param
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
// eventually, consider start and end params a date
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startParam, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endParam, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
summaries[i] = summary
|
||||
}
|
||||
|
||||
return summaries, nil, http.StatusOK
|
||||
}
|
8
routes/handler.go
Normal file
8
routes/handler.go
Normal file
@ -0,0 +1,8 @@
|
||||
package routes
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
type Handler interface {
|
||||
RegisterRoutes(router *mux.Router)
|
||||
RegisterAPIRoutes(router *mux.Router)
|
||||
}
|
@ -2,7 +2,8 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -14,10 +15,18 @@ 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 err := h.db.DB().Ping(); err == nil {
|
||||
dbStatus = 1
|
||||
if sqlDb, err := h.db.DB(); err == nil {
|
||||
if err := sqlDb.Ping(); err == nil {
|
||||
dbStatus = 1
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
@ -2,24 +2,27 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
config *models.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
config *conf.Config
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
|
||||
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
config: models.GetConfig(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
config: conf.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +30,12 @@ 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 *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
@ -46,18 +55,19 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
hb.Machine = machineName
|
||||
hb.User = user
|
||||
hb.UserID = user.ID
|
||||
hb.Augment(h.config.CustomLanguages)
|
||||
|
||||
if !hb.Valid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid heartbeat object."))
|
||||
return
|
||||
}
|
||||
|
||||
hb.Hashed()
|
||||
}
|
||||
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
os.Stderr.WriteString(err.Error())
|
||||
logbuch.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
72
routes/home.go
Normal file
72
routes/home.go
Normal file
@ -0,0 +1,72 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HomeHandler struct {
|
||||
config *conf.Config
|
||||
keyValueSrvc services.IKeyValueService
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
|
||||
return &HomeHandler{
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HomeHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) RegisterAPIRoutes(router *mux.Router) {}
|
||||
|
||||
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
var totalHours int
|
||||
var totalUsers int
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := time.ParseDuration(t.Value); err == nil {
|
||||
totalHours = int(d.Hours())
|
||||
}
|
||||
}
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := strconv.Atoi(t.Value); err == nil {
|
||||
totalUsers = d
|
||||
}
|
||||
}
|
||||
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalHours: totalHours,
|
||||
TotalUsers: totalUsers,
|
||||
}
|
||||
}
|
48
routes/imprint.go
Normal file
48
routes/imprint.go
Normal file
@ -0,0 +1,48 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ImprintHandler struct {
|
||||
config *conf.Config
|
||||
keyValueSrvc services.IKeyValueService
|
||||
}
|
||||
|
||||
func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
|
||||
return &ImprintHandler{
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/imprint").Methods(http.MethodGet).HandlerFunc(h.GetImprint)
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) RegisterAPIRoutes(router *mux.Router) {}
|
||||
|
||||
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates[conf.ImprintTemplate].Execute(w, h.buildViewModel(r).WithHtmlText(text))
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
||||
return &view.ImprintViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
169
routes/login.go
Normal file
169
routes/login.go
Normal file
@ -0,0 +1,169 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
}
|
||||
|
||||
func NewLoginHandler(userService services.IUserService) *LoginHandler {
|
||||
return &LoginHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
router.Path("/login").Methods(http.MethodPost).HandlerFunc(h.PostLogin)
|
||||
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
|
||||
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
|
||||
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) RegisterAPIRoutes(router *mux.Router) {}
|
||||
|
||||
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
user.LastLoggedInAt = models.CustomTime(time.Now())
|
||||
h.userSrvc.Update(user)
|
||||
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
return &view.LoginViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
179
routes/public.go
179
routes/public.go
@ -1,179 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IndexHandler struct {
|
||||
config *models.Config
|
||||
userSrvc *services.UserService
|
||||
keyValueSrvc *services.KeyValueService
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
|
||||
return &IndexHandler{
|
||||
config: models.GetConfig(),
|
||||
userSrvc: userService,
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "") {
|
||||
return
|
||||
}
|
||||
|
||||
templates["index.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates["imprint.tpl.html"].Execute(w, &struct {
|
||||
HtmlText string
|
||||
}{HtmlText: text})
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.PasswordSalt, h.userSrvc) {
|
||||
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user.LastLoggedInAt = models.CustomTime(time.Now())
|
||||
h.userSrvc.Update(user)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: models.AuthCookieKey,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Secure: !h.config.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !h.config.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "signup.tpl.html") {
|
||||
return
|
||||
}
|
||||
|
||||
templates["signup.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
}
|
@ -2,33 +2,42 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
func Init() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
func loadTemplates() {
|
||||
tplPath := "views"
|
||||
const tplPath = "/views"
|
||||
tpls := template.New("").Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"title": strings.Title,
|
||||
"capitalize": utils.Capitalize,
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"title": strings.Title,
|
||||
"join": strings.Join,
|
||||
"add": utils.Add,
|
||||
"capitalize": utils.Capitalize,
|
||||
"toRunes": utils.ToRunes,
|
||||
"entityTypes": models.SummaryTypes,
|
||||
"typeName": typeName,
|
||||
"getBasePath": func() string {
|
||||
return models.GetConfig().BasePath
|
||||
return config.Get().Server.BasePath
|
||||
},
|
||||
"getVersion": func() string {
|
||||
return models.GetConfig().Version
|
||||
return config.Get().Version
|
||||
},
|
||||
"getDbType": func() string {
|
||||
return strings.ToLower(config.Get().Db.Type)
|
||||
},
|
||||
"htmlSafe": func(html string) template.HTML {
|
||||
return template.HTML(html)
|
||||
@ -36,7 +45,12 @@ func loadTemplates() {
|
||||
})
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
files, err := ioutil.ReadDir(tplPath)
|
||||
dir, err := pkger.Open(tplPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer dir.Close()
|
||||
files, err := dir.Readdir(0)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -47,7 +61,18 @@ func loadTemplates() {
|
||||
continue
|
||||
}
|
||||
|
||||
tpl, err := tpls.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
|
||||
templateFile, err := pkger.Open(fmt.Sprintf("%s/%s", tplPath, tplName))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
templateData, err := ioutil.ReadAll(templateFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
templateFile.Close()
|
||||
|
||||
tpl, err := tpls.New(tplName).Parse(string(templateData))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -56,32 +81,21 @@ func loadTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
|
||||
w.WriteHeader(status)
|
||||
if tplName == "" {
|
||||
tplName = "index.tpl.html"
|
||||
func typeName(t uint8) string {
|
||||
if t == models.SummaryProject {
|
||||
return "project"
|
||||
}
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
Success string
|
||||
}{Error: error, Success: success})
|
||||
}
|
||||
|
||||
// TODO: do better
|
||||
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
|
||||
if err := r.URL.Query().Get("error"); err != "" {
|
||||
if err == "unauthorized" {
|
||||
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
|
||||
} else {
|
||||
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
respondAlert(w, "", success, tplName, http.StatusOK)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
if t == models.SummaryLanguage {
|
||||
return "language"
|
||||
}
|
||||
if t == models.SummaryEditor {
|
||||
return "editor"
|
||||
}
|
||||
if t == models.SummaryOS {
|
||||
return "operating system"
|
||||
}
|
||||
if t == models.SummaryMachine {
|
||||
return "machine"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
@ -1,38 +1,66 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *models.Config
|
||||
userSrvc *services.UserService
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
aliasSrvc services.IAliasService
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: models.GetConfig(),
|
||||
userSrvc: userService,
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
aliasSrvc: aliasService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
userSrvc: userService,
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) RegisterAPIRoutes(router *mux.Router) {}
|
||||
|
||||
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "settings.tpl.html") {
|
||||
return
|
||||
}
|
||||
templates["settings.tpl.html"].Execute(w, nil)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -44,32 +72,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
|
||||
var credentials models.CredentialsReset
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
|
||||
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
|
||||
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = credentials.PasswordNew
|
||||
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -77,23 +113,128 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
Username: user.ID,
|
||||
Password: user.Password,
|
||||
}
|
||||
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: models.AuthCookieKey,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Secure: !h.config.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("password was updated successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping not found"))
|
||||
return
|
||||
} else if mapping.UserID != user.ID {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("not allowed to delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
extension := r.PostFormValue("extension")
|
||||
language := r.PostFormValue("language")
|
||||
|
||||
if extension[0] == '.' {
|
||||
extension = extension[1:]
|
||||
}
|
||||
|
||||
mapping := &models.LanguageMapping{
|
||||
UserID: user.ID,
|
||||
Extension: extension,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
aliasKey := r.PostFormValue("key")
|
||||
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
|
||||
if err != nil {
|
||||
aliasType = 99 // nothing will be found later on
|
||||
}
|
||||
|
||||
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("aliases not found"))
|
||||
return
|
||||
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete aliases"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("aliases deleted successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
aliasKey := r.PostFormValue("key")
|
||||
aliasValue := r.PostFormValue("value")
|
||||
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
|
||||
if err != nil {
|
||||
aliasType = 99 // Alias.IsValid() will return false later on
|
||||
}
|
||||
|
||||
alias := &models.Alias{
|
||||
UserID: user.ID,
|
||||
Key: aliasKey,
|
||||
Value: aliasValue,
|
||||
Type: uint8(aliasType),
|
||||
}
|
||||
|
||||
if _, err := h.aliasSrvc.Create(alias); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
// TODO: distinguish between bad request, conflict and server error
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid input"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("alias added successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
@ -103,10 +244,137 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
apiKey := r.PostFormValue("api_key")
|
||||
|
||||
// Healthcheck, if a new API key is set, i.e. the feature is activated
|
||||
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to connect to WakaTime, API key invalid?"))
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("Wakatime API Key updated successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated – this may take a few seconds"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
|
||||
headers := http.Header{
|
||||
"Accept": []string{"application/json"},
|
||||
"Authorization": []string{
|
||||
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(apiKey))),
|
||||
},
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
conf.WakatimeApiUrl+conf.WakatimeApiUserEndpoint,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
request.Header = headers
|
||||
|
||||
response, err := h.httpClient.Do(request)
|
||||
if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
|
||||
aliasMap := make(map[string][]*models.Alias)
|
||||
for _, a := range aliases {
|
||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||
if _, ok := aliasMap[k]; !ok {
|
||||
aliasMap[k] = []*models.Alias{a}
|
||||
} else {
|
||||
aliasMap[k] = append(aliasMap[k], a)
|
||||
}
|
||||
}
|
||||
|
||||
combinedAliases := make([]*view.SettingsVMCombinedAlias, 0)
|
||||
for _, l := range aliasMap {
|
||||
ca := &view.SettingsVMCombinedAlias{
|
||||
Key: l[0].Key,
|
||||
Type: l[0].Type,
|
||||
Values: make([]string, len(l)),
|
||||
}
|
||||
for i, a := range l {
|
||||
ca.Values[i] = a.Value
|
||||
}
|
||||
combinedAliases = append(combinedAliases, ca)
|
||||
}
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
@ -1,38 +1,37 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalLastDay string = "day"
|
||||
IntervalLastWeek string = "week"
|
||||
IntervalLastMonth string = "month"
|
||||
IntervalLastYear string = "year"
|
||||
IntervalAny string = "any"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SummaryHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
|
||||
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
|
||||
return &SummaryHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
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 := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -53,63 +52,53 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
|
||||
w.WriteHeader(status)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil {
|
||||
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
vm := models.SummaryViewModel{
|
||||
Summary: summary,
|
||||
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
|
||||
LanguageColors: utils.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,
|
||||
}
|
||||
|
||||
templates["summary.tpl.html"].Execute(w, vm)
|
||||
templates[conf.SummaryTemplate].Execute(w, vm)
|
||||
}
|
||||
|
||||
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
interval := params.Get("interval")
|
||||
from, err := utils.ParseDate(params.Get("from"))
|
||||
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
switch interval {
|
||||
case IntervalToday:
|
||||
from = utils.StartOfDay()
|
||||
case IntervalLastDay:
|
||||
from = utils.StartOfDay().Add(-24 * time.Hour)
|
||||
case IntervalLastWeek:
|
||||
from = utils.StartOfWeek()
|
||||
case IntervalLastMonth:
|
||||
from = utils.StartOfMonth()
|
||||
case IntervalLastYear:
|
||||
from = utils.StartOfYear()
|
||||
case IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
|
||||
}
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
to := utils.StartOfDay()
|
||||
if live {
|
||||
to = time.Now()
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
var summary *models.Summary
|
||||
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
|
||||
return &view.SummaryViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
3
scripts/docker_mysql.sh
Normal file
3
scripts/docker_mysql.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5
|
3
scripts/docker_postgres.sh
Normal file
3
scripts/docker_postgres.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
@ -1,15 +1,18 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
N_PROJECTS = 5
|
||||
N_PAST_HOURS = 24
|
||||
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 vscode/1.42.1 vscode-wakatime/4.0.0'
|
||||
MACHINE = "devmachine"
|
||||
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0'
|
||||
LANGUAGES = {
|
||||
'Go': 'go',
|
||||
'Java': 'java',
|
||||
@ -36,23 +39,25 @@ class Heartbeat:
|
||||
self.is_write: bool = is_write
|
||||
self.branch: str = branch
|
||||
self.type: str = type
|
||||
self.category: str = None
|
||||
self.category: Union[str, None] = None
|
||||
|
||||
|
||||
def generate_data(n: int) -> List[Heartbeat]:
|
||||
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
|
||||
data: List[Heartbeat] = []
|
||||
now: datetime = datetime.today()
|
||||
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(5)]
|
||||
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
|
||||
languages: List[str] = list(LANGUAGES.keys())
|
||||
|
||||
for i in range(n):
|
||||
for _ in range(n):
|
||||
p: str = random.choice(projects)
|
||||
l: str = random.choice(languages)
|
||||
f: str = randomword(random.randint(2, 8))
|
||||
delta: timedelta = timedelta(
|
||||
hours=random.randint(0, N_PAST_HOURS - 1),
|
||||
hours=random.randint(0, n_past_hours - 1),
|
||||
minutes=random.randint(0, 59),
|
||||
seconds=random.randint(0, 59)
|
||||
seconds=random.randint(0, 59),
|
||||
milliseconds=random.randint(0, 999),
|
||||
microseconds=random.randint(0, 999)
|
||||
)
|
||||
|
||||
data.append(Heartbeat(
|
||||
@ -65,29 +70,44 @@ def generate_data(n: int) -> List[Heartbeat]:
|
||||
return data
|
||||
|
||||
|
||||
def post_data_sync(data: List[Heartbeat], url: str):
|
||||
for h in data:
|
||||
def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
|
||||
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
|
||||
|
||||
for h in tqdm(data):
|
||||
r = requests.post(url, json=[h.__dict__], headers={
|
||||
'User-Agent': UA
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}',
|
||||
'X-Machine-Name': MACHINE,
|
||||
})
|
||||
if r.status_code != 200:
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def randomword(length: int) -> str:
|
||||
letters = string.ascii_lowercase
|
||||
return ''.join(random.choice(letters) for i in range(length))
|
||||
return ''.join(random.choice(letters) for _ in range(length))
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description='Wakapi test data insertion script.')
|
||||
parser.add_argument('-n', type=int, default=20, help='total number of random heartbeats to generate and insert')
|
||||
parser.add_argument('-u', '--url', type=str, default='http://localhost:3000/api/heartbeat',
|
||||
help='url of your api\'s heartbeats endpoint')
|
||||
parser.add_argument('-k', '--apikey', type=str, required=True,
|
||||
help='your api key (to get one, go to the web interface, create a new user, log in and copy the key)')
|
||||
parser.add_argument('-p', '--projects', type=int, default=5, help='number of different fake projects to generate')
|
||||
parser.add_argument('-o', '--offset', type=int, default=24,
|
||||
help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for')
|
||||
parser.add_argument('-s', '--seed', type=int, default=2020,
|
||||
help='a seed for initializing the pseudo-random number generator')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
n: int = 10
|
||||
url: str = 'http://admin:admin@localhost:3000/api/heartbeat'
|
||||
args = parse_arguments()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
n = int(sys.argv[1])
|
||||
if len(sys.argv) > 2:
|
||||
url = sys.argv[2]
|
||||
random.seed(args.seed)
|
||||
|
||||
data: List[Heartbeat] = generate_data(n)
|
||||
post_data_sync(data, url)
|
||||
data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
|
||||
post_data_sync(data, args.url, args.apikey)
|
||||
|
@ -1,34 +1,32 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const (
|
||||
aggregateIntervalDays int = 1 // TODO: Make configurable
|
||||
aggregateIntervalDays int = 1
|
||||
)
|
||||
|
||||
type AggregationService struct {
|
||||
Config *models.Config
|
||||
Db *gorm.DB
|
||||
UserService *UserService
|
||||
SummaryService *SummaryService
|
||||
HeartbeatService *HeartbeatService
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
}
|
||||
|
||||
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
return &AggregationService{
|
||||
Config: models.GetConfig(),
|
||||
Db: db,
|
||||
UserService: userService,
|
||||
SummaryService: summaryService,
|
||||
HeartbeatService: heartbeatService,
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,34 +37,45 @@ type AggregationJob struct {
|
||||
}
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
// TODO: Make configurable
|
||||
func (srv *AggregationService) Schedule() {
|
||||
// Run once initially
|
||||
if err := srv.Run(nil); err != nil {
|
||||
logbuch.Fatal("failed to run AggregationJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
defer close(jobs)
|
||||
defer close(summaries)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.summaryWorker(jobs, summaries)
|
||||
}
|
||||
|
||||
for i := 0; i < int(srv.Config.DbMaxConn); i++ {
|
||||
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
|
||||
go srv.persistWorker(summaries)
|
||||
}
|
||||
|
||||
// Run once initially
|
||||
srv.trigger(jobs)
|
||||
// don't leak open channels
|
||||
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
|
||||
defer close(c1)
|
||||
defer close(c2)
|
||||
time.Sleep(1 * time.Hour)
|
||||
}(jobs, summaries)
|
||||
|
||||
gocron.Every(1).Day().At("02:15").Do(srv.trigger, jobs)
|
||||
<-gocron.Start()
|
||||
return srv.trigger(jobs, userIds)
|
||||
}
|
||||
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.SummaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
|
||||
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
|
||||
logbuch.Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
summaries <- summary
|
||||
}
|
||||
}
|
||||
@ -74,72 +83,83 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
||||
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
if err := srv.SummaryService.Insert(summary); err != nil {
|
||||
log.Printf("Failed to save summary (%v, %v, %s) – %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
logbuch.Error("failed to save summary (%v, %v, %s) – %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
|
||||
log.Println("Generating summaries.")
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
|
||||
logbuch.Info("generating summaries")
|
||||
|
||||
users, err := srv.UserService.GetAll()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
var users []*models.User
|
||||
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
latestSummaries, err := srv.SummaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
userSummaryTimes := make(map[string]time.Time)
|
||||
for _, s := range latestSummaries {
|
||||
userSummaryTimes[s.UserID] = s.ToTime
|
||||
}
|
||||
|
||||
missingUserIDs := make([]string, 0)
|
||||
for _, u := range users {
|
||||
if _, ok := userSummaryTimes[u.ID]; !ok {
|
||||
missingUserIDs = append(missingUserIDs, u.ID)
|
||||
} else if userIds != nil && len(userIds) > 0 {
|
||||
users = make([]*models.User, len(userIds))
|
||||
for i, u := range allUsers {
|
||||
if yes, ok := userIds[u.ID]; yes && ok {
|
||||
users[i] = u
|
||||
}
|
||||
}
|
||||
} else {
|
||||
users = allUsers
|
||||
}
|
||||
|
||||
firstHeartbeats, err := srv.HeartbeatService.GetFirstUserHeartbeats(missingUserIDs)
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
logbuch.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
for id, t := range userSummaryTimes {
|
||||
generateUserJobs(id, t, jobs)
|
||||
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||
if err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
for _, h := range firstHeartbeats {
|
||||
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
|
||||
// Build actual lookup table from it
|
||||
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
|
||||
for _, e := range firstUserHeartbeatTimes {
|
||||
firstUserHeartbeatLookup[e.User] = e.Time
|
||||
}
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if e.Time.Valid() {
|
||||
// Case 1: User has aggregated summaries already
|
||||
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||
generateUserJobs(e.User, e.Time.T(), jobs)
|
||||
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
|
||||
// Case 2: User has no aggregated summaries, yet, but has heartbeats
|
||||
// -> Spawn jobs to create summaries from their first heartbeat to now
|
||||
generateUserJobs(e.User, t.T(), jobs)
|
||||
}
|
||||
// Case 3: User doesn't have heartbeats at all
|
||||
// -> Nothing to do
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
|
||||
var from, to time.Time
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
var to time.Time
|
||||
|
||||
// Go to next day of either user's first heartbeat or latest aggregation
|
||||
from.Add(-1 * time.Second)
|
||||
from = time.Date(
|
||||
from.Year(),
|
||||
from.Month(),
|
||||
from.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
from.Location(),
|
||||
)
|
||||
|
||||
// Iteratively aggregate per-day summaries until end of yesterday is reached
|
||||
end := getStartOfToday().Add(-1 * time.Second)
|
||||
|
||||
if lastAggregation.Hour() == 0 {
|
||||
from = lastAggregation
|
||||
} else {
|
||||
from = time.Date(
|
||||
lastAggregation.Year(),
|
||||
lastAggregation.Month(),
|
||||
lastAggregation.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
lastAggregation.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
for from.Before(end) && to.Before(end) {
|
||||
to = time.Date(
|
||||
from.Year(),
|
||||
|
@ -2,53 +2,114 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type AliasService struct {
|
||||
Config *models.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository repositories.IAliasRepository
|
||||
}
|
||||
|
||||
func NewAliasService(db *gorm.DB) *AliasService {
|
||||
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
|
||||
return &AliasService{
|
||||
Config: models.GetConfig(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: aliasRepo,
|
||||
}
|
||||
}
|
||||
|
||||
var userAliases sync.Map
|
||||
|
||||
func (srv *AliasService) LoadUserAliases(userId string) error {
|
||||
var aliases []*models.Alias
|
||||
if err := srv.Db.
|
||||
Where(&models.Alias{UserID: userId}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userAliases.Store(userId, aliases)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
if ua, ok := userAliases.Load(userId); ok {
|
||||
for _, a := range ua.([]*models.Alias) {
|
||||
if a.Type == summaryType && a.Value == value {
|
||||
return a.Key, nil
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("user aliases not initialized")
|
||||
}
|
||||
|
||||
func (srv *AliasService) IsInitialized(userId string) bool {
|
||||
if _, ok := userAliases.Load(userId); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (srv *AliasService) InitializeUser(userId string) error {
|
||||
aliases, err := srv.repository.GetByUser(userId)
|
||||
if err == nil {
|
||||
userAliases.Store(userId, aliases)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
aliases, err := srv.repository.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
if err := srv.InitializeUser(userId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
aliases, _ := userAliases.Load(userId)
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.Type == summaryType && a.Value == value {
|
||||
return a.Key, nil
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
|
||||
result, err := srv.repository.Insert(alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go srv.reinitUser(alias.UserID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) Delete(alias *models.Alias) error {
|
||||
if alias.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(alias.ID)
|
||||
go srv.reinitUser(alias.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
|
||||
ids := make([]uint, len(aliases))
|
||||
affectedUsers := make(map[string]bool)
|
||||
for i, a := range aliases {
|
||||
if a.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
affectedUsers[a.UserID] = true
|
||||
ids[i] = a.ID
|
||||
}
|
||||
|
||||
err := srv.repository.DeleteBatch(ids)
|
||||
|
||||
for k := range affectedUsers {
|
||||
go srv.reinitUser(k)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) reinitUser(userId string) {
|
||||
if err := srv.InitializeUser(userId); err != nil {
|
||||
logbuch.Error("error initializing user aliases – %v", err)
|
||||
}
|
||||
}
|
||||
|
63
services/alias_test.go
Normal file
63
services/alias_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type AliasServiceTestSuite struct {
|
||||
suite.Suite
|
||||
TestUserId string
|
||||
AliasRepository *mocks.AliasRepositoryMock
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) SetupSuite() {
|
||||
suite.TestUserId = "johndoe@example.org"
|
||||
|
||||
aliases := []*models.Alias{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
UserID: suite.TestUserId,
|
||||
Key: "wakapi",
|
||||
Value: "wakapi-mobile",
|
||||
},
|
||||
}
|
||||
|
||||
aliasRepoMock := new(mocks.AliasRepositoryMock)
|
||||
aliasRepoMock.On("GetByUser", suite.TestUserId).Return(aliases, nil)
|
||||
aliasRepoMock.On("GetByUser", mock.AnythingOfType("string")).Return([]*models.Alias{}, assert.AnError)
|
||||
|
||||
suite.AliasRepository = aliasRepoMock
|
||||
}
|
||||
|
||||
func TestAliasServiceTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AliasServiceTestSuite))
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
|
||||
sut := NewAliasService(suite.AliasRepository)
|
||||
|
||||
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
|
||||
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
|
||||
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
|
||||
|
||||
assert.Equal(suite.T(), "wakapi", result1)
|
||||
assert.Nil(suite.T(), err1)
|
||||
assert.Equal(suite.T(), "wakapi", result2)
|
||||
assert.Nil(suite.T(), err2)
|
||||
assert.Equal(suite.T(), "anchr", result3)
|
||||
assert.Nil(suite.T(), err3)
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
|
||||
sut := NewAliasService(suite.AliasRepository)
|
||||
|
||||
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
|
||||
|
||||
assert.Empty(suite.T(), result)
|
||||
assert.Error(suite.T(), err)
|
||||
}
|
@ -1,94 +1,56 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
gormbulk "github.com/t-tiger/gorm-bulk-insert"
|
||||
)
|
||||
|
||||
const (
|
||||
TableHeartbeat = "heartbeat"
|
||||
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type HeartbeatService struct {
|
||||
Config *models.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository repositories.IHeartbeatRepository
|
||||
languageMappingSrvc ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
|
||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
Config: models.GetConfig(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: heartbeatRepo,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
var batch []interface{}
|
||||
for _, h := range heartbeats {
|
||||
batch = append(batch, *h)
|
||||
}
|
||||
|
||||
if err := gormbulk.BulkInsert(srv.Db, batch, 3000); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return srv.repository.InsertBatch(heartbeats)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := srv.Db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time <= ?", to).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := srv.Db.
|
||||
Table("heartbeats").
|
||||
Select("user_id, min(time) as time").
|
||||
Where("user_id IN (?)", userIds).
|
||||
Group("user_id").
|
||||
Scan(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetFirstByUsers()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
if err := srv.Db.
|
||||
Where("time <= ?", t).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfDay().Add(-cleanUpInterval)
|
||||
if err := srv.DeleteBefore(refTime); err != nil {
|
||||
log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err)
|
||||
return err
|
||||
for i := range heartbeats {
|
||||
heartbeats[i].Augment(languageMapping)
|
||||
}
|
||||
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) ScheduleCleanUp() {
|
||||
srv.CleanUp()
|
||||
|
||||
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
|
||||
<-gocron.Start()
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
@ -1,62 +1,31 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
)
|
||||
|
||||
type KeyValueService struct {
|
||||
Config *models.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository repositories.IKeyValueRepository
|
||||
}
|
||||
|
||||
func NewKeyValueService(db *gorm.DB) *KeyValueService {
|
||||
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
|
||||
return &KeyValueService{
|
||||
Config: models.GetConfig(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: keyValueRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := srv.Db.
|
||||
Where(&models.KeyStringValue{Key: key}).
|
||||
First(&kv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
return srv.repository.GetString(key)
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
|
||||
result := srv.Db.
|
||||
Where(&models.KeyStringValue{Key: kv.Key}).
|
||||
Assign(kv).
|
||||
FirstOrCreate(kv)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return nil
|
||||
return srv.repository.PutString(kv)
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) DeleteString(key string) error {
|
||||
result := srv.Db.
|
||||
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing deleted")
|
||||
}
|
||||
|
||||
return nil
|
||||
return srv.repository.DeleteString(key)
|
||||
}
|
||||
|
78
services/language_mapping.go
Normal file
78
services/language_mapping.go
Normal file
@ -0,0 +1,78 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LanguageMappingService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.ILanguageMappingRepository
|
||||
}
|
||||
|
||||
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
|
||||
return &LanguageMappingService{
|
||||
config: config.Get(),
|
||||
repository: languageMappingsRepo,
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
return srv.repository.GetById(id)
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||
if mappings, found := srv.cache.Get(userId); found {
|
||||
return mappings.([]*models.LanguageMapping), nil
|
||||
}
|
||||
|
||||
mappings, err := srv.repository.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv.cache.Set(userId, mappings, cache.DefaultExpiration)
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) ResolveByUser(userId string) (map[string]string, error) {
|
||||
mappings := srv.getServerMappings()
|
||||
userMappings, err := srv.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range userMappings {
|
||||
mappings[m.Extension] = m.Language
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) Create(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||
result, err := srv.repository.Insert(mapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Delete(result.UserID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error {
|
||||
if mapping.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(mapping.ID)
|
||||
srv.cache.Delete(mapping.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) getServerMappings() map[string]string {
|
||||
// https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it
|
||||
return srv.config.App.GetCustomLanguages()
|
||||
}
|
119
services/misc.go
Normal file
119
services/misc.go
Normal file
@ -0,0 +1,119 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"go.uber.org/atomic"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type MiscService struct {
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
keyValueService IKeyValueService
|
||||
jobCount atomic.Uint32
|
||||
}
|
||||
|
||||
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
|
||||
return &MiscService{
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
keyValueService: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
type CountTotalTimeJob struct {
|
||||
UserID string
|
||||
NumJobs int
|
||||
}
|
||||
|
||||
type CountTotalTimeResult struct {
|
||||
UserId string
|
||||
Total time.Duration
|
||||
}
|
||||
|
||||
func (srv *MiscService) ScheduleCountTotalTime() {
|
||||
// Run once initially
|
||||
if err := srv.runCountTotalTime(); err != nil {
|
||||
logbuch.Error("failed to run CountTotalTimeJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *MiscService) runCountTotalTime() error {
|
||||
jobs := make(chan *CountTotalTimeJob)
|
||||
results := make(chan *CountTotalTimeResult)
|
||||
|
||||
defer close(jobs)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.countTotalTimeWorker(jobs, results)
|
||||
}
|
||||
|
||||
go srv.persistTotalTimeWorker(results)
|
||||
|
||||
// generate the jobs
|
||||
if users, err := srv.userService.GetAll(); err == nil {
|
||||
for _, u := range users {
|
||||
jobs <- &CountTotalTimeJob{
|
||||
UserID: u.ID,
|
||||
NumJobs: len(users),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
|
||||
for job := range jobs {
|
||||
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve); err != nil {
|
||||
logbuch.Error("failed to count total for user %s: %v", job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully counted total for user %s", job.UserID)
|
||||
results <- &CountTotalTimeResult{
|
||||
UserId: job.UserID,
|
||||
Total: result.TotalTime(),
|
||||
}
|
||||
}
|
||||
if srv.jobCount.Inc() == uint32(job.NumJobs) {
|
||||
srv.jobCount.Store(0)
|
||||
close(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeResult) {
|
||||
var c int
|
||||
var total time.Duration
|
||||
for result := range results {
|
||||
total += result.Total
|
||||
c++
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalTime,
|
||||
Value: total.String(),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total time count: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalUsers,
|
||||
Value: strconv.Itoa(c),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total users count: %v", err)
|
||||
}
|
||||
}
|
68
services/services.go
Normal file
68
services/services.go
Normal file
@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAggregationService interface {
|
||||
Schedule()
|
||||
Run(map[string]bool) error
|
||||
}
|
||||
|
||||
type IMiscService interface {
|
||||
ScheduleCountTotalTime()
|
||||
}
|
||||
|
||||
type IAliasService interface {
|
||||
Create(*models.Alias) (*models.Alias, error)
|
||||
Delete(*models.Alias) error
|
||||
DeleteMulti([]*models.Alias) error
|
||||
IsInitialized(string) bool
|
||||
InitializeUser(string) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
}
|
||||
|
||||
type IHeartbeatService interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
type IKeyValueService interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingService interface {
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
ResolveByUser(string) (map[string]string, error)
|
||||
Create(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
Delete(mapping *models.LanguageMapping) error
|
||||
}
|
||||
|
||||
type ISummaryService interface {
|
||||
Aliased(time.Time, time.Time, *models.User, SummaryRetriever) (*models.Summary, error)
|
||||
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
|
||||
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
|
||||
GetLatestByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
Insert(*models.Summary) error
|
||||
}
|
||||
|
||||
type IUserService interface {
|
||||
GetUserById(string) (*models.User, error)
|
||||
GetUserByKey(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
CreateOrGet(*models.Signup) (*models.User, bool, error)
|
||||
Update(*models.User) (*models.User, error)
|
||||
ResetApiKey(*models.User) (*models.User, error)
|
||||
ToggleBadges(*models.User) (*models.User, error)
|
||||
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
}
|
@ -3,87 +3,129 @@ package services
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
|
||||
type SummaryService struct {
|
||||
Config *models.Config
|
||||
Cache *cache.Cache
|
||||
Db *gorm.DB
|
||||
HeartbeatService *HeartbeatService
|
||||
AliasService *AliasService
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
aliasService IAliasService
|
||||
}
|
||||
|
||||
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
Config: models.GetConfig(),
|
||||
Cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
Db: db,
|
||||
HeartbeatService: heartbeatService,
|
||||
AliasService: aliasService,
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
repository: summaryRepo,
|
||||
heartbeatService: heartbeatService,
|
||||
aliasService: aliasService,
|
||||
}
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
// Public summary generation methods
|
||||
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
|
||||
if recompute {
|
||||
existingSummaries = make([]*models.Summary, 0)
|
||||
} else {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
if result, ok := srv.Cache.Get(cacheKey); ok {
|
||||
return result.(*models.Summary), nil
|
||||
}
|
||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingSummaries = summaries
|
||||
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
missingIntervals := getMissingIntervals(from, to, existingSummaries)
|
||||
// Wrap alias resolution
|
||||
resolve := func(t uint8, k string) string {
|
||||
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
|
||||
return s
|
||||
}
|
||||
|
||||
heartbeats := make([]*models.Heartbeat, 0)
|
||||
// Initialize alias resolver service
|
||||
if err := srv.aliasService.InitializeUser(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get actual summary
|
||||
s, err := f(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
// Get all already existing, pre-generated summaries that fall into the requested interval
|
||||
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
|
||||
missingIntervals := srv.getMissingIntervals(from, to, summaries)
|
||||
for _, interval := range missingIntervals {
|
||||
hb, err := srv.HeartbeatService.GetAllWithin(interval.Start, interval.End, user)
|
||||
if err != nil {
|
||||
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
|
||||
summaries = append(summaries, s)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine}
|
||||
// Merge existing and newly generated summary snippets
|
||||
summary, err := srv.mergeSummaries(summaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache 'em
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Initialize and fetch data
|
||||
var heartbeats models.Heartbeats
|
||||
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
|
||||
heartbeats = rawHeartbeats
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types := models.SummaryTypes()
|
||||
|
||||
typedAggregations := make(chan models.SummaryItemContainer)
|
||||
defer close(typedAggregations)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, typedAggregations)
|
||||
}
|
||||
|
||||
// Aggregate raw heartbeats by types in parallel and collect them
|
||||
var projectItems []*models.SummaryItem
|
||||
var languageItems []*models.SummaryItem
|
||||
var editorItems []*models.SummaryItem
|
||||
var osItems []*models.SummaryItem
|
||||
var machineItems []*models.SummaryItem
|
||||
|
||||
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := make(chan models.SummaryItemContainer)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, user, c)
|
||||
}
|
||||
|
||||
for i := 0; i < len(types); i++ {
|
||||
item := <-c
|
||||
item := <-typedAggregations
|
||||
switch item.Type {
|
||||
case models.SummaryProject:
|
||||
projectItems = item.Items
|
||||
@ -97,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
machineItems = item.Items
|
||||
}
|
||||
}
|
||||
close(c)
|
||||
|
||||
realFrom, realTo := from, to
|
||||
if len(existingSummaries) > 0 {
|
||||
realFrom = existingSummaries[0].FromTime
|
||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime
|
||||
|
||||
for _, summary := range existingSummaries {
|
||||
summary.FillUnknown()
|
||||
}
|
||||
}
|
||||
if len(heartbeats) > 0 {
|
||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
||||
realFrom = t1
|
||||
}
|
||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
||||
realTo = t2
|
||||
}
|
||||
if heartbeats.Len() > 0 {
|
||||
from = time.Time(heartbeats.First().Time)
|
||||
to = time.Time(heartbeats.Last().Time)
|
||||
}
|
||||
|
||||
aggregatedSummary := &models.Summary{
|
||||
summary := &models.Summary{
|
||||
UserID: user.ID,
|
||||
FromTime: realFrom,
|
||||
ToTime: realTo,
|
||||
FromTime: models.CustomTime(from),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: projectItems,
|
||||
Languages: languageItems,
|
||||
Editors: editorItems,
|
||||
@ -129,84 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
allSummaries = append(allSummaries, existingSummaries...)
|
||||
//summary.FillUnknown()
|
||||
|
||||
summary, err := mergeSummaries(allSummaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
if cacheKey != "" {
|
||||
srv.Cache.SetDefault(cacheKey, summary)
|
||||
}
|
||||
// CRUD methods
|
||||
|
||||
return summary, nil
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetLastByUser()
|
||||
}
|
||||
|
||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
return srv.repository.DeleteByUser(userId)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||
if err := srv.Db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return srv.repository.Insert(summary)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := srv.Db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
// Private summary generation and utility methods
|
||||
|
||||
// Will return *models.Index objects with only user_id and to_time filled
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := srv.Db.
|
||||
Table("summaries").
|
||||
Select("user_id, max(to_time) as to_time").
|
||||
Group("user_id").
|
||||
Scan(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
|
||||
durations := make(map[string]time.Duration)
|
||||
|
||||
for i, h := range heartbeats {
|
||||
var key string
|
||||
switch summaryType {
|
||||
case models.SummaryProject:
|
||||
key = h.Project
|
||||
case models.SummaryEditor:
|
||||
key = h.Editor
|
||||
case models.SummaryLanguage:
|
||||
key = h.Language
|
||||
case models.SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case models.SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = models.UnknownSummaryKey
|
||||
}
|
||||
|
||||
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
||||
key = aliasedKey
|
||||
}
|
||||
key := h.GetKey(summaryType)
|
||||
|
||||
if _, ok := durations[key]; !ok {
|
||||
durations[key] = time.Duration(0)
|
||||
@ -216,9 +191,16 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
continue
|
||||
}
|
||||
|
||||
timePassed := h.Time.Time().Sub(heartbeats[i-1].Time.Time())
|
||||
timeThresholded := math.Min(float64(timePassed), float64(time.Duration(2)*time.Minute))
|
||||
durations[key] += time.Duration(int64(timeThresholded))
|
||||
t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
|
||||
// This is a hack. The time difference between two heartbeats from two subsequent day (e.g. 23:59:59 and 00:00:01) are ignored.
|
||||
// This is to prevent a discrepancy between summaries computed solely from heartbeats and summaries involving pre-aggregated per-day summaries.
|
||||
// For the latter, a duration is already pre-computed and information about individual heartbeats is lost, so there can be no cross-day overflow.
|
||||
// Essentially, we simply ignore such edge-case heartbeats here, which makes the eventual total duration potentially a bit shorter.
|
||||
if t1.Day() == t2.Day() {
|
||||
timePassed := t1.Sub(t2)
|
||||
tdiff = time.Duration(int64(math.Min(float64(timePassed), float64(HeartbeatDiffThreshold))))
|
||||
}
|
||||
durations[key] += tdiff
|
||||
}
|
||||
|
||||
items := make([]*models.SummaryItem, 0)
|
||||
@ -237,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||
}
|
||||
|
||||
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
|
||||
if len(existingSummaries) == 0 {
|
||||
return []*Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(existingSummaries[0].FromTime) {
|
||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) {
|
||||
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
if len(summaries) < 1 {
|
||||
return nil, errors.New("no summaries given")
|
||||
}
|
||||
@ -295,28 +241,28 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
return nil, errors.New("users don't match")
|
||||
}
|
||||
|
||||
if s.FromTime.Before(minTime) {
|
||||
minTime = s.FromTime
|
||||
if s.FromTime.T().Before(minTime) {
|
||||
minTime = s.FromTime.T()
|
||||
}
|
||||
|
||||
if s.ToTime.After(maxTime) {
|
||||
maxTime = s.ToTime
|
||||
if s.ToTime.T().After(maxTime) {
|
||||
maxTime = s.ToTime.T()
|
||||
}
|
||||
|
||||
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
}
|
||||
|
||||
finalSummary.FromTime = minTime
|
||||
finalSummary.ToTime = maxTime
|
||||
finalSummary.FromTime = models.CustomTime(minTime)
|
||||
finalSummary.ToTime = models.CustomTime(maxTime)
|
||||
|
||||
return finalSummary, nil
|
||||
}
|
||||
|
||||
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
items := make(map[string]*models.SummaryItem)
|
||||
|
||||
// Build map from existing
|
||||
@ -346,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
||||
return itemList
|
||||
}
|
||||
|
||||
func getHash(times []time.Time, user *models.User) string {
|
||||
digest := md5.New()
|
||||
for _, t := range times {
|
||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
||||
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||
if len(summaries) == 0 {
|
||||
return []*models.Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*models.Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(summaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(summaries)-1; i++ {
|
||||
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(summaries[len(summaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func (srv *SummaryService) getHash(args ...string) string {
|
||||
digest := md5.New()
|
||||
for _, a := range args {
|
||||
digest.Write([]byte(a))
|
||||
}
|
||||
digest.Write([]byte(user.ID))
|
||||
return string(digest.Sum(nil))
|
||||
}
|
||||
|
290
services/summary_test.go
Normal file
290
services/summary_test.go
Normal file
@ -0,0 +1,290 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TestUserId = "muety"
|
||||
TestProject1 = "test-project-1"
|
||||
TestProject2 = "test-project-2"
|
||||
TestLanguageGo = "Go"
|
||||
TestLanguageJava = "Java"
|
||||
TestLanguagePython = "Python"
|
||||
TestEditorGoland = "GoLand"
|
||||
TestEditorIntellij = "idea"
|
||||
TestEditorVscode = "vscode"
|
||||
TestOsLinux = "Linux"
|
||||
TestOsWin = "Windows"
|
||||
TestMachine1 = "muety-desktop"
|
||||
TestMachine2 = "muety-work"
|
||||
MinUnixTime1 = 1601510400000 * 1e6
|
||||
)
|
||||
|
||||
type SummaryServiceTestSuite struct {
|
||||
suite.Suite
|
||||
TestUser *models.User
|
||||
TestStartTime time.Time
|
||||
TestHeartbeats []*models.Heartbeat
|
||||
SummaryRepository *mocks.SummaryRepositoryMock
|
||||
HeartbeatService *mocks.HeartbeatServiceMock
|
||||
AliasService *mocks.AliasServiceMock
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||
suite.TestUser = &models.User{ID: TestUserId}
|
||||
|
||||
suite.TestStartTime = time.Unix(0, MinUnixTime1)
|
||||
suite.TestHeartbeats = []*models.Heartbeat{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorGoland,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime),
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorGoland,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorVscode,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||
suite.AliasService = new(mocks.AliasServiceMock)
|
||||
}
|
||||
|
||||
func TestSummaryServiceTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(SummaryServiceTestSuite))
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
/* TEST 1 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), from, result.FromTime.T())
|
||||
assert.Equal(suite.T(), to, result.ToTime.T())
|
||||
assert.Zero(suite.T(), result.TotalTime())
|
||||
assert.Empty(suite.T(), result.Projects)
|
||||
|
||||
/* TEST 2 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T())
|
||||
assert.Zero(suite.T(), result.TotalTime())
|
||||
assertNumAllItems(suite.T(), 1, result, "")
|
||||
|
||||
/* TEST 3 */
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T())
|
||||
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
|
||||
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
||||
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
|
||||
assert.Len(suite.T(), result.Editors, 2)
|
||||
assertNumAllItems(suite.T(), 1, result, "e")
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
/* TEST 1 */
|
||||
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
|
||||
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 1)
|
||||
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
|
||||
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
|
||||
|
||||
/* TEST 2 */
|
||||
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from.Add(20 * time.Minute)),
|
||||
ToTime: models.CustomTime(to.Add(-6 * time.Hour)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(to.Add(-6 * time.Hour)),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject2,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 2)
|
||||
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
|
||||
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
|
||||
|
||||
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
}
|
||||
|
||||
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
|
||||
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
|
||||
for _, h := range heartbeats {
|
||||
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func assertNumAllItems(t *testing.T, expected int, summary *models.Summary, except string) {
|
||||
if !strings.Contains(except, "p") {
|
||||
assert.Len(t, summary.Projects, expected)
|
||||
}
|
||||
if !strings.Contains(except, "e") {
|
||||
assert.Len(t, summary.Editors, expected)
|
||||
}
|
||||
if !strings.Contains(except, "l") {
|
||||
assert.Len(t, summary.Languages, expected)
|
||||
}
|
||||
if !strings.Contains(except, "o") {
|
||||
assert.Len(t, summary.OperatingSystems, expected)
|
||||
}
|
||||
if !strings.Contains(except, "m") {
|
||||
assert.Len(t, summary.Machines, expected)
|
||||
}
|
||||
}
|
111
services/user.go
111
services/user.go
@ -1,49 +1,59 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
Config *models.Config
|
||||
Db *gorm.DB
|
||||
Config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.IUserRepository
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
func NewUserService(userRepo repositories.IUserRepository) *UserService {
|
||||
return &UserService{
|
||||
Config: models.GetConfig(),
|
||||
Db: db,
|
||||
Config: config.Get(),
|
||||
repository: userRepo,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
if u, ok := srv.cache.Get(userId); ok {
|
||||
return u.(*models.User), nil
|
||||
}
|
||||
|
||||
u, err := srv.repository.GetById(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := srv.Db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
if u, ok := srv.cache.Get(key); ok {
|
||||
return u.(*models.User), nil
|
||||
}
|
||||
|
||||
u, err := srv.repository.GetByApiKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := srv.Db.
|
||||
Table("users").
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
return srv.repository.GetAll()
|
||||
}
|
||||
|
||||
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
|
||||
@ -53,52 +63,43 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
|
||||
Password: signup.Password,
|
||||
}
|
||||
|
||||
if err := utils.HashPassword(u, srv.Config.PasswordSalt); err != nil {
|
||||
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {
|
||||
return nil, false, err
|
||||
} else {
|
||||
u.Password = hash
|
||||
}
|
||||
|
||||
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
|
||||
if err := result.Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if result.RowsAffected == 1 {
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
return u, false, nil
|
||||
return srv.repository.InsertOrGet(u)
|
||||
}
|
||||
|
||||
func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
||||
result := srv.Db.Model(&models.User{}).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
srv.cache.Flush()
|
||||
return srv.repository.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
srv.cache.Flush()
|
||||
user.ApiKey = uuid.NewV4().String()
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
user.Password = login.Password
|
||||
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := srv.Db.Model(user).Update("password", user.Password)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
} else if result.RowsAffected < 1 {
|
||||
return nil, errors.New("nothing changes")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
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)
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
srv.cache.Flush()
|
||||
user.Password = login.Password
|
||||
if hash, err := utils.HashBcrypt(user.Password, srv.Config.Security.PasswordSalt); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
return srv.repository.UpdateField(user, "password", user.Password)
|
||||
}
|
||||
|
3
sonar-project.properties
Normal file
3
sonar-project.properties
Normal file
@ -0,0 +1,3 @@
|
||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**
|
||||
sonar.tests=.
|
||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -1,5 +1,4 @@
|
||||
const SHOW_TOP_N = 10
|
||||
const CHART_TARGET_SIZE = 170
|
||||
const CHART_TARGET_SIZE = 200
|
||||
|
||||
const projectsCanvas = document.getElementById('chart-projects')
|
||||
const osCanvas = document.getElementById('chart-os')
|
||||
@ -7,7 +6,26 @@ const editorsCanvas = document.getElementById('chart-editor')
|
||||
const languagesCanvas = document.getElementById('chart-language')
|
||||
const machinesCanvas = document.getElementById('chart-machine')
|
||||
|
||||
const projectContainer = document.getElementById('project-container')
|
||||
const osContainer = document.getElementById('os-container')
|
||||
const editorContainer = document.getElementById('editor-container')
|
||||
const languageContainer = document.getElementById('language-container')
|
||||
const machineContainer = document.getElementById('machine-container')
|
||||
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
|
||||
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||
topNPickers.forEach(e => {
|
||||
const idx = parseInt(e.attributes['data-entity'].value)
|
||||
e.max = Math.min(data[idx].length, 10)
|
||||
e.value = e.max
|
||||
})
|
||||
|
||||
let charts = []
|
||||
let showTopN = []
|
||||
let resizeCount = 0
|
||||
|
||||
String.prototype.toHHMMSS = function () {
|
||||
@ -28,12 +46,7 @@ String.prototype.toHHMMSS = function () {
|
||||
return hours + ':' + minutes + ':' + seconds
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function draw(subselection) {
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
@ -42,136 +55,171 @@ function draw() {
|
||||
let idx = type === 'pie' ? item.index : item.datasetIndex
|
||||
let d = wakapiData[key][idx]
|
||||
return `${d.key}: ${d.total.toString().toHHMMSS()}`
|
||||
}
|
||||
},
|
||||
title: () => 'Total Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
function shouldUpdate(index) {
|
||||
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
|
||||
}
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total) / 60],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
charts
|
||||
.filter((c, i) => shouldUpdate(i))
|
||||
.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
|
||||
? new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total) / 60],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Minutes'
|
||||
}
|
||||
}]
|
||||
options: {
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Minutes'
|
||||
}
|
||||
}]
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
|
||||
? new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
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))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
options: {
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
|
||||
? new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
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))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
|
||||
? new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
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))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
|
||||
let machineChart = new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('machines', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
|
||||
? new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
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))
|
||||
}],
|
||||
labels: wakapiData.machines
|
||||
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('machines', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible'
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart]
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
if (!subselection) {
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
}
|
||||
}
|
||||
|
||||
function parseTopN() {
|
||||
showTopN = topNPickers.map(e => parseInt(e.value))
|
||||
}
|
||||
|
||||
function togglePlaceholders(mask) {
|
||||
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
if (!mask[i]) {
|
||||
canvases[i].classList.add('hidden')
|
||||
placeholderElements[i].classList.remove('hidden')
|
||||
} else {
|
||||
canvases[i].classList.remove('hidden')
|
||||
placeholderElements[i].classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
@ -210,8 +258,8 @@ function equalizeHeights() {
|
||||
})
|
||||
}
|
||||
|
||||
function getTotal(data) {
|
||||
let total = data.reduce((acc, d) => acc + d.total, 0)
|
||||
function getTotal(items) {
|
||||
let total = items.reduce((acc, d) => acc + d.total, 0)
|
||||
document.getElementById('total-span').innerText = total.toString().toHHMMSS()
|
||||
}
|
||||
|
||||
@ -257,7 +305,7 @@ if (favicon) {
|
||||
}
|
||||
|
||||
// Click outside
|
||||
window.addEventListener('click', function(event) {
|
||||
window.addEventListener('click', function (event) {
|
||||
if (event.target.classList.contains('popup')) {
|
||||
return
|
||||
}
|
||||
@ -268,5 +316,13 @@ window.addEventListener('click', function(event) {
|
||||
})
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
topNPickers.forEach(e => e.addEventListener('change', () => {
|
||||
parseTopN()
|
||||
draw([parseInt(e.attributes['data-entity'].value)])
|
||||
}))
|
||||
|
||||
parseTopN()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
||||
})
|
||||
|
||||
|
BIN
static/assets/images/android-chrome-192x192.png
Normal file
BIN
static/assets/images/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
BIN
static/assets/images/android-chrome-512x512.png
Normal file
BIN
static/assets/images/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
static/assets/images/apple-touch-icon.png
Normal file
BIN
static/assets/images/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
static/assets/images/favicon-16x16.png
Normal file
BIN
static/assets/images/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 457 B |
BIN
static/assets/images/favicon-32x32.png
Normal file
BIN
static/assets/images/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 710 B |
BIN
static/assets/images/favicon.ico
Normal file
BIN
static/assets/images/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user