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

Compare commits

...

49 Commits

Author SHA1 Message Date
aef0c929df fix: wakatime relay 2021-02-05 14:50:00 +01:00
9cb9747e2e fix: missing summary api route 2021-02-03 21:40:01 +01:00
68a17950ef chore: test coverage report 2021-02-03 21:29:12 +01:00
a2368ff76a refactor: significant changes related to routing and general code cleanup 2021-02-03 21:28:02 +01:00
4838300086 refactor: settings routes and actions 2021-02-03 20:53:27 +01:00
a60c725d38 Merge pull request #111 from muety/docker-release
ci(docker): Publish Docker Image on Release
2021-02-03 20:03:15 +01:00
8ceef42ad4 ci(docker): Publish Docker Image on Release 2021-02-03 22:22:37 +11:00
8bed266110 feat: account deletion (#99) 2021-02-02 22:54:22 +01:00
a7afd73e62 chore: require at least one database connection 2021-02-02 22:52:13 +01:00
1dc5be4784 fix: selective summary generation 2021-02-02 22:49:29 +01:00
b6812ddc3a refactor: migrations structure
fix: cascade for alias user foreign key constraint
2021-02-02 21:50:43 +01:00
4f7cc3c57e fix: make logging middleware respect proxy headers 2021-01-31 19:00:42 +01:00
c6139e5366 fix: really fix it now 🤦‍♂️ 2021-01-31 18:56:34 +01:00
28269aa329 fix: start and end parameter parsing for wakatime summary route 2021-01-31 18:41:48 +01:00
b7ae15496d fix: attempt to directly hash struct again 2021-01-31 18:29:50 +01:00
f483488dd5 chore: stop gorm from logging queries 2021-01-31 18:29:24 +01:00
0c3f3b37b0 fix: attempt to quickfix hash collisions 2021-01-31 18:06:20 +01:00
dc1a0c7983 chore: introduce hashes for heartbeats 2021-01-31 17:46:50 +01:00
e4b38d3f51 fix: tests 2021-01-31 16:58:59 +01:00
665ffe8bd1 chore: log request durations (resolve #102) 2021-01-31 16:46:39 +01:00
3e5a51c272 feat: add missing query params to wakatime endpoints (resolve #109) 2021-01-31 16:25:48 +01:00
979549448c chore: remove legacy support for md5 hashed passwords
chore: remove password from encoded cookie content as not used anyway
2021-01-31 14:34:54 +01:00
105f96ff72 chore: get rid of cdn and serve all static assets locally 2021-01-31 14:10:17 +01:00
31013ad986 docs: update readme
docs: mention tinyproxy in advanced setup instructions
2021-01-31 13:59:28 +01:00
db4cb92c26 Merge pull request #107 from YC/legacy-ini
chore: remove legacy config.ini and .env
2021-01-31 13:47:07 +01:00
779108ad88 chore: remove legacy config.ini and .env 2021-01-31 10:51:56 +11:00
61f8a22cff docs: fix readme links and remove duplicated badge 2021-01-30 12:53:51 +01:00
179a107c2a docs: update readme 2021-01-30 12:51:12 +01:00
ef0c76e2ff docs: beautify readme 2021-01-30 12:46:13 +01:00
617d9ad7e4 refactor: include logging framework (resolve #92) 2021-01-30 11:17:37 +01:00
fd239e4f21 chore: add check to validate wakatime api key before accepting it 2021-01-30 10:54:54 +01:00
417d4789ab chore: move route registration into the handler classes themselves (resolve #57) 2021-01-30 10:34:52 +01:00
a6aff07b21 chore: use wakatime colors for editors and os (resolve #100) 2021-01-30 09:51:36 +01:00
b732eea9b7 docs: minor corrections in readme 2021-01-25 08:43:20 +01:00
71d1b2177b fix: missing ca certificates in docker container (resolve #98)
fix: server crash in unsuccessful relaying of heartbeat to wakatime
2021-01-24 21:39:35 +01:00
b2a3579be9 Merge pull request #97 from muety/actions
fix(ci): actions release, build on push/pr
2021-01-24 12:23:05 +01:00
42a6e9d923 fix(ci): actions release, build on push/pr 2021-01-24 22:06:04 +11:00
1f44ccadba docs: update readme with new build instructions 2021-01-24 09:54:54 +01:00
6ea72c6d02 chore: increment patch version number 2021-01-24 09:50:04 +01:00
d93348842a fix: delay defer templateFile.Close() 2021-01-24 10:19:20 +11:00
fb92747129 fix: embed of static, views 2021-01-24 10:13:37 +11:00
4e6e665e19 feat: embed assets into binary
Resolves #26
2021-01-23 10:00:15 +11:00
a3d8c4d464 chore: docs and typos 2021-01-22 00:02:56 +01:00
e9eaa9da53 chore: update version 2021-01-21 23:50:27 +01:00
5adb795f59 chore: include integrity hashes (resolve #93) 2021-01-21 23:50:14 +01:00
a552073d18 feat: ui for configuring wakatime integration 2021-01-21 23:26:50 +01:00
de0401d4bb fix: move caching out of authentication middleware 2021-01-21 23:19:17 +01:00
c39538db13 chore: include machine name in sample data script 2021-01-21 22:17:46 +01:00
189a09d91f feat: relay heartbeats to wakatime (resolve #28) 2021-01-21 22:17:32 +01:00
79 changed files with 2416 additions and 2766 deletions

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

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

View File

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

View File

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

3
.gitignore vendored
View File

@ -1,6 +1,5 @@
launch.json
.vscode
.env
wakapi
.idea
build
@ -8,4 +7,4 @@ build
*.db
config*.yml
!config.default.yml
config.ini
pkged.go

View File

@ -4,17 +4,15 @@ FROM golang:1.15 AS build-env
WORKDIR /src
ADD ./go.mod .
RUN go mod download
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
ADD . .
RUN go build -o wakapi
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/version.txt . && \
cp -r /src/static /src/data /src/migrations /src/views . && \
cp /src/wait-for-it.sh .
# Run Stage
@ -26,6 +24,9 @@ RUN cp /src/wakapi . && \
FROM debian
WORKDIR /app
RUN apt update && \
apt install -y ca-certificates
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''

276
README.md
View File

@ -1,70 +1,148 @@
# 📈 wakapi
<h1 align="center">📊 Wakapi</h1>
![](https://badges.fw-web.space/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
![GitHub issues](https://img.shields.io/github/issues/muety/wakapi)
![GitHub last commit](https://img.shields.io/github/last-commit/muety/wakapi)
[![Say thanks](https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg)](https://saythanks.io/to/n1try)
[![](https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay)](https://liberapay.com/muety/)
![GitHub go.mod Go version](https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc)](https://sonarcloud.io/dashboard?id=muety_wakapi)
---
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
</p>
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
<p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
<h3 align="center">A minimalist, self-hosted WakaTime-compatible backend for coding statistics.</h3>
<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>
## 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)
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you like, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
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!
## 👀 Demo
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
## 🚀 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
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
## ⌨️ 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.
## ⚙️ Prerequisites
**On the server side:**
### ☁️ 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
## ⌨️ Server Setup
### Run from source
1. Clone the project
1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
1. Build executable: `GO111MODULE=on go build`
1. Run server: `./wakapi`
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
### Run with Docker
#### Compile & Run
```bash
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)" --name wakapi n1try/wakapi
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
# Install packaging tool
$ export GO111MODULE=on
$ go get github.com/markbates/pkger/cmd/pkger
# Build the executable
$ go generate
$ go build -o wakapi
# Run it
$ ./wakapi
```
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies = true` in `config.yml`.
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
### 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. **Editing your local `~/.wakatime.cfg`** file as follows
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
```
## 🔧 Configuration
Optionally, you can set up a [client-side proxy](docs/advanced_setup.md) in addition.
## 🔧 Configuration Options
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key | Environment Variable | Default | Description |
@ -97,22 +175,8 @@ Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of differ
* [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_)
## 💻 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
```
api_url = https://your.server:someport/api/heartbeat`
api_key = the_api_key_printed_to_the_console_after_starting_the_server`
```
You can view your API Key after logging in to the web interface.
### Optional: Client-side proxy
See the [advanced setup instructions](docs/advanced_setup.md).
### Client-side proxy (`optional`)
See the [advanced setup instructions](docs/advanced_setup.md).
## 🔧 API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
@ -133,18 +197,94 @@ It is a standalone webserver that connects to your Wakapi instance and exposes t
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.
## 🏷 Badges
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 🤓 Developer Notes
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ⚠️ 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!**
## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
<details>
<summary><b>What data is sent to Wakapi?</b></summary>
<ul>
<li>File names</li>
<li>Project names</li>
<li>Editor names</li>
<li>You computer's host name</li>
<li>Timestamps for every action you take in your editor</li>
<li>...</li>
</ul>
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
</details>
<details>
<summary><b>What happens if I'm offline?</b></summary>
All data is cached locally on your machine and sent in batches once you're online again.
</details>
<details>
<summary><b>How did Wakapi come about?</b></summary>
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing)">9 $ a month</a> back then. Luckily, most parts of WakaTime are open source!
</details>
<details>
<summary><b>How does Wakapi compare to WakaTime?</b></summary>
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi, include:
<ul>
<li>Leaderboards</li>
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
<li>Personal Goals</li>
<li>Team- / Organization Support</li>
<li>Integrations (with GitLab, etc.)</li>
<li>Richer API</li>
</ul>
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
</details>
<details>
<summary><b>How are durations calculated?</b></summary>
Inferring a measure for your coding time from heartbeats works a bit different than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat, that occurs after a longer pause, with 2 extra minutes.
Here is an example (circles are heartbeats):
```
|---o---o--------------o---o---|
| |10s| 3m |10s| |
```
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen find a solution, but not actually typing code.
<ul>
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
<li><b>WakaTime</b> (with 2 min timeout): 20 sec
<li><b>Wakapi:</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
</ul>
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
</details>
## 🙏 Thanks
I highly appreciate the efforts of [@alanhamlett](https://github.com/alanhamlett) and the WakaTime team and am thankful for their software being open source.
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -4,8 +4,10 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/markbates/pkger"
"github.com/muety/wakapi/models"
migrate "github.com/rubenv/sql-migrate"
"gorm.io/driver/mysql"
@ -13,16 +15,13 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
)
const (
defaultConfigPath = "config.yml"
defaultConfigPathLegacy = "config.ini"
defaultEnvConfigPathLegacy = ".env"
defaultConfigPath = "config.yml"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
@ -32,14 +31,20 @@ const (
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"`
LanguageColors map[string]string `yaml:"-"`
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 {
@ -126,8 +131,8 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/common/fixtures",
migrations := &migrate.HttpFileSystemMigrationSource{
FileSystem: pkger.Dir("/migrations"),
}
migrate.SetIgnoreUnknown(true)
@ -137,7 +142,7 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return err
}
log.Printf("applied %d fixtures\n", n)
logbuch.Info("applied %d fixtures", n)
return nil
}
}
@ -192,11 +197,19 @@ func sqliteConnectionString(config *dbConfig) string {
}
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages)
return cloneStringMap(c.CustomLanguages, false)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.LanguageColors)
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 {
@ -204,40 +217,43 @@ func IsDev(env string) bool {
}
func readVersion() string {
file, err := os.Open("version.txt")
file, err := pkger.Open("/version.txt")
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
return string(bytes)
return strings.TrimSpace(string(bytes))
}
func readLanguageColors() map[string]string {
func readColors() map[string]map[string]string {
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
// Source:
// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// https://wakatime.com/colors/operating_systems
// - https://wakatime.com/colors/editors
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
// $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
// $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
var colors = make(map[string]map[string]string)
data, err := ioutil.ReadFile("data/colors.json")
file, err := pkger.Open("/data/colors.json")
if err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
if err := json.Unmarshal(bytes, &colors); err != nil {
logbuch.Fatal(err.Error())
}
return colors
@ -245,9 +261,8 @@ func readLanguageColors() map[string]string {
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
}
return *cFlag
}
@ -271,14 +286,12 @@ func Load() *Config {
flag.Parse()
maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
log.Fatalf("failed to read config: %v\n", err)
logbuch.Fatal("failed to read config: %v", err)
}
config.Version = readVersion()
config.App.LanguageColors = readLanguageColors()
config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64),
@ -296,7 +309,11 @@ func Load() *Config {
}
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
log.Fatalln("either of listen_ipv4 or listen_ipv6 must be set")
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
Set(config)

View File

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

View File

@ -1,8 +1,13 @@
package config
func cloneStringMap(m map[string]string) map[string]string {
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

View File

@ -1,4 +1,9 @@
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
@ -6,107 +11,42 @@ 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.49,33.21 1 0
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 0
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 0
github.com/muety/wakapi/models/filters.go:35.8,35.23 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 0
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 0
github.com/muety/wakapi/models/filters.go:39.8,39.27 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 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/filters.go:47.42,50.21 2 1
github.com/muety/wakapi/models/filters.go:53.2,53.20 1 1
github.com/muety/wakapi/models/filters.go:56.2,56.22 1 1
github.com/muety/wakapi/models/filters.go:59.2,59.21 1 1
github.com/muety/wakapi/models/filters.go:62.2,62.16 1 1
github.com/muety/wakapi/models/filters.go:66.2,66.12 1 1
github.com/muety/wakapi/models/filters.go:50.21,52.3 1 1
github.com/muety/wakapi/models/filters.go:53.20,55.3 1 0
github.com/muety/wakapi/models/filters.go:56.22,58.3 1 1
github.com/muety/wakapi/models/filters.go:59.21,61.3 1 0
github.com/muety/wakapi/models/filters.go:62.16,64.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/summary.go:29.27,33.2 1 0
github.com/muety/wakapi/models/summary.go:83.29,85.2 1 1
github.com/muety/wakapi/models/summary.go:87.37,94.2 6 1
github.com/muety/wakapi/models/summary.go:96.35,98.2 1 1
github.com/muety/wakapi/models/summary.go:100.57,108.2 1 1
github.com/muety/wakapi/models/summary.go:121.33,126.26 4 1
github.com/muety/wakapi/models/summary.go:133.2,133.37 1 1
github.com/muety/wakapi/models/summary.go:137.2,140.33 2 1
github.com/muety/wakapi/models/summary.go:126.26,127.30 1 1
github.com/muety/wakapi/models/summary.go:127.30,129.4 1 1
github.com/muety/wakapi/models/summary.go:133.37,135.3 1 0
github.com/muety/wakapi/models/summary.go:140.33,146.3 1 1
github.com/muety/wakapi/models/summary.go:149.45,154.30 3 1
github.com/muety/wakapi/models/summary.go:163.2,163.30 1 1
github.com/muety/wakapi/models/summary.go:154.30,155.47 1 1
github.com/muety/wakapi/models/summary.go:155.47,156.32 1 1
github.com/muety/wakapi/models/summary.go:159.4,159.9 1 1
github.com/muety/wakapi/models/summary.go:156.32,158.5 1 1
github.com/muety/wakapi/models/summary.go:166.73,168.55 2 1
github.com/muety/wakapi/models/summary.go:173.2,173.16 1 1
github.com/muety/wakapi/models/summary.go:168.55,169.31 1 1
github.com/muety/wakapi/models/summary.go:169.31,171.4 1 1
github.com/muety/wakapi/models/summary.go:176.88,178.55 2 1
github.com/muety/wakapi/models/summary.go:186.2,186.16 1 1
github.com/muety/wakapi/models/summary.go:178.55,179.31 1 1
github.com/muety/wakapi/models/summary.go:179.31,180.23 1 1
github.com/muety/wakapi/models/summary.go:183.4,183.46 1 1
github.com/muety/wakapi/models/summary.go:180.23,181.13 1 1
github.com/muety/wakapi/models/summary.go:189.79,190.33 1 1
github.com/muety/wakapi/models/summary.go:193.2,193.16 1 1
github.com/muety/wakapi/models/summary.go:190.33,192.3 1 1
github.com/muety/wakapi/models/summary.go:196.71,197.63 1 1
github.com/muety/wakapi/models/summary.go:237.2,243.10 6 1
github.com/muety/wakapi/models/summary.go:197.63,200.45 2 1
github.com/muety/wakapi/models/summary.go:209.3,209.31 1 1
github.com/muety/wakapi/models/summary.go:216.3,216.31 1 1
github.com/muety/wakapi/models/summary.go:233.3,233.16 1 1
github.com/muety/wakapi/models/summary.go:200.45,201.32 1 1
github.com/muety/wakapi/models/summary.go:206.4,206.14 1 1
github.com/muety/wakapi/models/summary.go:201.32,202.24 1 1
github.com/muety/wakapi/models/summary.go:202.24,204.6 1 1
github.com/muety/wakapi/models/summary.go:209.31,211.60 1 1
github.com/muety/wakapi/models/summary.go:211.60,213.5 1 1
github.com/muety/wakapi/models/summary.go:216.31,218.60 1 1
github.com/muety/wakapi/models/summary.go:218.60,219.55 1 1
github.com/muety/wakapi/models/summary.go:219.55,221.6 1 1
github.com/muety/wakapi/models/summary.go:221.11,229.6 1 1
github.com/muety/wakapi/models/summary.go:246.33,248.2 1 1
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 1
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 1
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/heartbeat.go:26.34,28.2 1 1
github.com/muety/wakapi/models/heartbeat.go:30.65,31.28 1 1
github.com/muety/wakapi/models/heartbeat.go:34.2,35.45 2 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.44 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,42.42 1 1
github.com/muety/wakapi/models/heartbeat.go:31.28,33.3 1 1
github.com/muety/wakapi/models/heartbeat.go:35.45,37.3 1 0
github.com/muety/wakapi/models/heartbeat.go:39.44,41.3 1 0
github.com/muety/wakapi/models/heartbeat.go:45.50,46.11 1 1
github.com/muety/wakapi/models/heartbeat.go:59.2,59.15 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.12 1 1
github.com/muety/wakapi/models/heartbeat.go:47.22,48.18 1 1
github.com/muety/wakapi/models/heartbeat.go:49.21,50.17 1 1
github.com/muety/wakapi/models/heartbeat.go:51.23,52.19 1 1
github.com/muety/wakapi/models/heartbeat.go:53.17,54.26 1 1
github.com/muety/wakapi/models/heartbeat.go:55.22,56.18 1 1
github.com/muety/wakapi/models/heartbeat.go:59.15,61.3 1 1
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:70.37,86.2 1 0
github.com/muety/wakapi/models/heartbeat.go:94.41,96.16 2 0
github.com/muety/wakapi/models/heartbeat.go:99.2,100.10 2 0
github.com/muety/wakapi/models/heartbeat.go:96.16,98.3 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/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
@ -117,6 +57,9 @@ github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
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
@ -128,218 +71,189 @@ 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/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:18.79,20.54 2 0
github.com/muety/wakapi/utils/auth.go:24.2,26.16 3 0
github.com/muety/wakapi/utils/auth.go:30.2,32.45 3 0
github.com/muety/wakapi/utils/auth.go:35.2,36.32 2 0
github.com/muety/wakapi/utils/auth.go:20.54,22.3 1 0
github.com/muety/wakapi/utils/auth.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/auth.go:32.45,34.3 1 0
github.com/muety/wakapi/utils/auth.go:39.65,41.54 2 0
github.com/muety/wakapi/utils/auth.go:45.2,46.30 2 0
github.com/muety/wakapi/utils/auth.go:41.54,43.3 1 0
github.com/muety/wakapi/utils/auth.go:49.97,51.16 2 0
github.com/muety/wakapi/utils/auth.go:55.2,55.104 1 0
github.com/muety/wakapi/utils/auth.go:59.2,59.19 1 0
github.com/muety/wakapi/utils/auth.go:51.16,53.3 1 0
github.com/muety/wakapi/utils/auth.go:55.104,57.3 1 0
github.com/muety/wakapi/utils/auth.go:62.30,64.2 1 0
github.com/muety/wakapi/utils/auth.go:66.56,70.2 3 0
github.com/muety/wakapi/utils/auth.go:73.53,75.2 1 0
github.com/muety/wakapi/utils/auth.go:77.55,80.16 3 0
github.com/muety/wakapi/utils/auth.go:83.2,83.16 1 0
github.com/muety/wakapi/utils/auth.go:80.16,82.3 1 0
github.com/muety/wakapi/utils/auth.go:86.43,91.2 4 0
github.com/muety/wakapi/utils/color.go:8.93,10.41 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.41,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/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:37.2,37.22 1 0
github.com/muety/wakapi/utils/summary.go:14.28,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.32,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.31,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.32,22.24 1 0
github.com/muety/wakapi/utils/summary.go:23.31,24.23 1 0
github.com/muety/wakapi/utils/summary.go:25.32,26.42 1 0
github.com/muety/wakapi/utils/summary.go:27.33,28.43 1 0
github.com/muety/wakapi/utils/summary.go:29.35,30.43 1 0
github.com/muety/wakapi/utils/summary.go:31.26,32.21 1 0
github.com/muety/wakapi/utils/summary.go:33.10,34.39 1 0
github.com/muety/wakapi/utils/summary.go:40.73,47.56 5 0
github.com/muety/wakapi/utils/summary.go:61.2,68.8 2 0
github.com/muety/wakapi/utils/summary.go:47.56,49.3 1 0
github.com/muety/wakapi/utils/summary.go:49.8,51.17 2 0
github.com/muety/wakapi/utils/summary.go:55.3,56.17 2 0
github.com/muety/wakapi/utils/summary.go:51.17,53.4 1 0
github.com/muety/wakapi/utils/summary.go:56.17,58.4 1 0
github.com/muety/wakapi/config/config.go:83.70,85.2 1 0
github.com/muety/wakapi/config/config.go:87.65,89.2 1 0
github.com/muety/wakapi/config/config.go:91.82,101.2 1 0
github.com/muety/wakapi/config/config.go:103.31,105.2 1 0
github.com/muety/wakapi/config/config.go:107.32,109.2 1 0
github.com/muety/wakapi/config/config.go:111.74,112.19 1 0
github.com/muety/wakapi/config/config.go:113.10,114.34 1 0
github.com/muety/wakapi/config/config.go:114.34,123.4 8 0
github.com/muety/wakapi/config/config.go:127.73,128.33 1 0
github.com/muety/wakapi/config/config.go:128.33,136.17 5 0
github.com/muety/wakapi/config/config.go:140.3,141.13 2 0
github.com/muety/wakapi/config/config.go:136.17,138.4 1 0
github.com/muety/wakapi/config/config.go:145.50,146.19 1 0
github.com/muety/wakapi/config/config.go:159.2,159.12 1 0
github.com/muety/wakapi/config/config.go:147.23,151.5 1 0
github.com/muety/wakapi/config/config.go:152.26,155.5 1 0
github.com/muety/wakapi/config/config.go:156.24,157.48 1 0
github.com/muety/wakapi/config/config.go:162.53,172.2 1 1
github.com/muety/wakapi/config/config.go:174.56,176.16 2 1
github.com/muety/wakapi/config/config.go:180.2,187.3 1 1
github.com/muety/wakapi/config/config.go:176.16,178.3 1 0
github.com/muety/wakapi/config/config.go:190.54,192.2 1 1
github.com/muety/wakapi/config/config.go:194.60,196.2 1 0
github.com/muety/wakapi/config/config.go:198.59,200.2 1 0
github.com/muety/wakapi/config/config.go:202.29,204.2 1 1
github.com/muety/wakapi/config/config.go:206.27,208.16 2 0
github.com/muety/wakapi/config/config.go:211.2,214.16 3 0
github.com/muety/wakapi/config/config.go:218.2,218.22 1 0
github.com/muety/wakapi/config/config.go:208.16,210.3 1 0
github.com/muety/wakapi/config/config.go:214.16,216.3 1 0
github.com/muety/wakapi/config/config.go:221.45,231.16 4 0
github.com/muety/wakapi/config/config.go:235.2,235.57 1 0
github.com/muety/wakapi/config/config.go:239.2,239.30 1 0
github.com/muety/wakapi/config/config.go:243.2,243.15 1 0
github.com/muety/wakapi/config/config.go:231.16,233.3 1 0
github.com/muety/wakapi/config/config.go:235.57,237.3 1 0
github.com/muety/wakapi/config/config.go:239.30,241.3 1 0
github.com/muety/wakapi/config/config.go:246.38,247.43 1 0
github.com/muety/wakapi/config/config.go:251.2,251.15 1 0
github.com/muety/wakapi/config/config.go:247.43,249.3 1 0
github.com/muety/wakapi/config/config.go:254.45,255.27 1 0
github.com/muety/wakapi/config/config.go:258.2,258.15 1 0
github.com/muety/wakapi/config/config.go:255.27,257.3 1 0
github.com/muety/wakapi/config/config.go:261.26,263.2 1 0
github.com/muety/wakapi/config/config.go:265.20,267.2 1 0
github.com/muety/wakapi/config/config.go:269.21,276.96 4 0
github.com/muety/wakapi/config/config.go:280.2,288.52 5 0
github.com/muety/wakapi/config/config.go:292.2,292.47 1 0
github.com/muety/wakapi/config/config.go:298.2,298.70 1 0
github.com/muety/wakapi/config/config.go:302.2,303.14 2 0
github.com/muety/wakapi/config/config.go:276.96,278.3 1 0
github.com/muety/wakapi/config/config.go:288.52,290.3 1 0
github.com/muety/wakapi/config/config.go:292.47,293.14 1 0
github.com/muety/wakapi/config/config.go:293.14,295.4 1 0
github.com/muety/wakapi/config/config.go:298.70,300.3 1 0
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
github.com/muety/wakapi/config/utils.go:3.60,5.22 2 0
github.com/muety/wakapi/config/utils.go:8.2,8.11 1 0
github.com/muety/wakapi/config/utils.go:5.22,7.3 1 0
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
github.com/muety/wakapi/models/shared.go:74.45,76.2 1 0
github.com/muety/wakapi/models/shared.go:78.51,81.2 2 0
github.com/muety/wakapi/models/shared.go:83.37,86.2 2 0
github.com/muety/wakapi/models/shared.go:88.35,90.2 1 0
github.com/muety/wakapi/models/shared.go:92.34,94.2 1 0
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/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:46.41,48.14 2 0
github.com/muety/wakapi/middlewares/logging.go:51.2,51.14 1 0
github.com/muety/wakapi/middlewares/logging.go:54.2,54.11 1 0
github.com/muety/wakapi/middlewares/logging.go:48.14,50.3 1 0
github.com/muety/wakapi/middlewares/logging.go:51.14,53.3 1 0
github.com/muety/wakapi/middlewares/logging.go:85.52,87.2 1 0
github.com/muety/wakapi/middlewares/logging.go:99.45,100.20 1 0
github.com/muety/wakapi/middlewares/logging.go:100.20,104.3 3 0
github.com/muety/wakapi/middlewares/logging.go:106.54,109.18 3 0
github.com/muety/wakapi/middlewares/logging.go:116.2,117.15 2 0
github.com/muety/wakapi/middlewares/logging.go:109.18,112.17 2 0
github.com/muety/wakapi/middlewares/logging.go:112.17,114.4 1 0
github.com/muety/wakapi/middlewares/logging.go:119.42,120.20 1 0
github.com/muety/wakapi/middlewares/logging.go:120.20,122.3 1 0
github.com/muety/wakapi/middlewares/logging.go:124.36,126.2 1 0
github.com/muety/wakapi/middlewares/logging.go:127.42,129.2 1 0
github.com/muety/wakapi/middlewares/logging.go:130.40,132.2 1 0
github.com/muety/wakapi/middlewares/logging.go:133.52,135.2 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,315.28 1 0
github.com/muety/wakapi/config/config.go:319.2,320.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/config/config.go:315.28,317.3 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0
@ -452,21 +366,6 @@ github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:16.73,21.2 1 0
github.com/muety/wakapi/services/user.go:23.74,25.2 1 0
github.com/muety/wakapi/services/user.go:27.72,29.2 1 0
github.com/muety/wakapi/services/user.go:31.58,33.2 1 0
github.com/muety/wakapi/services/user.go:35.88,42.93 2 0
github.com/muety/wakapi/services/user.go:48.2,48.38 1 0
github.com/muety/wakapi/services/user.go:42.93,44.3 1 0
github.com/muety/wakapi/services/user.go:44.8,46.3 1 0
github.com/muety/wakapi/services/user.go:51.73,53.2 1 0
github.com/muety/wakapi/services/user.go:55.78,58.2 2 0
github.com/muety/wakapi/services/user.go:60.79,62.2 1 0
github.com/muety/wakapi/services/user.go:64.106,66.96 2 0
github.com/muety/wakapi/services/user.go:71.2,71.68 1 0
github.com/muety/wakapi/services/user.go:66.96,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 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
@ -502,29 +401,22 @@ 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/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
@ -565,15 +457,112 @@ 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/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/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.88,66.93 2 0
github.com/muety/wakapi/services/user.go:72.2,72.38 1 0
github.com/muety/wakapi/services/user.go:66.93,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/user.go:75.73,78.2 2 0
github.com/muety/wakapi/services/user.go:80.78,84.2 3 0
github.com/muety/wakapi/services/user.go:86.79,89.2 2 0
github.com/muety/wakapi/services/user.go:91.99,94.2 2 0
github.com/muety/wakapi/services/user.go:96.106,99.96 3 0
github.com/muety/wakapi/services/user.go:104.2,104.68 1 0
github.com/muety/wakapi/services/user.go:99.96,101.3 1 0
github.com/muety/wakapi/services/user.go:101.8,103.3 1 0
github.com/muety/wakapi/services/user.go:107.57,110.2 2 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/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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
# 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.
@ -6,9 +8,24 @@ While this is convenient for plugin developers, as they do not have to deal with
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.
In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up webserver / reverse proxy.
### 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
1. [Install Caddy](https://caddyserver.com/)
### 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
```
@ -26,4 +43,5 @@ In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up w
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
1. Done
* All Wakapi requests are passed through Caddy now, which keeps a TCP connection with the server open for some time

7
go.mod
View File

@ -3,15 +3,17 @@ 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/jinzhu/configor v1.2.0
github.com/joho/godotenv v1.3.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
@ -20,8 +22,7 @@ require (
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0
gopkg.in/yaml.v2 v2.2.8
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

17
go.sum
View File

@ -56,6 +56,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@ -81,6 +83,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
@ -94,7 +98,6 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -243,6 +246,8 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -268,6 +273,8 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@ -387,7 +394,6 @@ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -437,6 +443,7 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@ -510,6 +517,7 @@ golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-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=
@ -548,8 +556,6 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
@ -557,6 +563,7 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
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/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=
@ -569,8 +576,6 @@ gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

144
main.go
View File

@ -1,13 +1,19 @@
package main
//go:generate $GOPATH/bin/pkger
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
"github.com/markbates/pkger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"gorm.io/gorm/logger"
"log"
"net/http"
"os"
"strconv"
"time"
@ -53,14 +59,26 @@ var (
func main() {
config = conf.Load()
// Enable line numbers in logging
// Set log level
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
logbuch.SetLevel(logbuch.LevelDebug)
} else {
logbuch.SetLevel(logbuch.LevelInfo)
}
// Set up GORM
gormLogger := logger.New(
log.New(os.Stdout, "", log.LstdFlags),
logger.Config{
SlowThreshold: time.Minute,
Colorful: false,
LogLevel: logger.Silent,
},
)
// Connect to database
var err error
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
}
@ -72,15 +90,15 @@ func main() {
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
log.Println(err)
log.Fatal("could not connect to database")
logbuch.Error(err.Error())
logbuch.Fatal("could not connect to database")
}
defer sqlDb.Close()
// Migrate database schema
common.RunCustomPreMigrations(db, config)
migrations.RunPreMigrations(db, config)
runDatabaseMigrations()
common.RunCustomPostMigrations(db, config)
migrations.RunCustomPostMigrations(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
@ -104,84 +122,60 @@ func main() {
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
// TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// API Handlers
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(summaryService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// Setup Routers
router := mux.NewRouter()
publicRouter := router.PathPrefix("/").Subrouter()
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
compatApiRouter := apiRouter.PathPrefix("/compat").Subrouter()
// Middlewares
// Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
loggingMiddleware := middlewares.NewLoggingMiddleware(log.New(os.Stdout, "", log.LstdFlags))
corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService,
[]string{"/api/health", "/api/compat/shields/v1"},
).Handler
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(userService, []string{"/api/health", "/api/compat/shields/v1"}).Handler
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware)
settingsRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Route registrations
homeHandler.RegisterRoutes(rootRouter)
loginHandler.RegisterRoutes(rootRouter)
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
// API route registrations
summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/aliases").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostAlias)
settingsRouter.Path("/aliases/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteAlias)
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Wakatime compat V1 API Routes
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
// Shields.io compat API Routes
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
// Compat route registrations
wakatimeV1AllHandler.RegisterRoutes(compatApiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(compatApiRouter)
shieldV1BadgeHandler.RegisterRoutes(compatApiRouter)
// Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
router.PathPrefix("/assets").Handler(http.FileServer(pkger.Dir("/static")))
// Listen HTTP
listen(router)
@ -214,35 +208,35 @@ func listen(handler http.Handler) {
if config.UseTLS() {
if s4 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s4.Addr)
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
logbuch.Fatal(err.Error())
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s6.Addr)
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
logbuch.Fatal(err.Error())
}
}()
}
} else {
if s4 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s4.Addr)
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
log.Fatalln(err)
logbuch.Fatal(err.Error())
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s6.Addr)
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
log.Fatalln(err)
logbuch.Fatal(err.Error())
}
}()
}
@ -253,6 +247,6 @@ func listen(handler http.Handler) {
func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
log.Fatal(err)
logbuch.Fatal(err.Error())
}
}

View File

@ -2,24 +2,17 @@ package middlewares
import (
"context"
"errors"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"strings"
"time"
"github.com/patrickmn/go-cache"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
type AuthenticateMiddleware struct {
config *conf.Config
cache *cache.Cache
userSrvc services.IUserService
whitelistPaths []string
}
@ -28,7 +21,6 @@ func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
}
}
@ -64,8 +56,6 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx))
}
@ -78,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.Security.PasswordSalt, &m.userSrvc) {
return nil, errors.New("invalid password")
}
// no need to check password here, as securecookie decoding will fail anyway,
// if cookie is not properly signed
return user, nil
}
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.IUserService) bool {
if utils.IsMd5(user.Password) {
if utils.CompareMd5(user.Password, login.Password, "") {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
(*userServiceRef).MigrateMd5Password(user, login)
return true
}
return false
}
return utils.CompareBcrypt(user.Password, login.Password, salt)
}

View File

@ -6,7 +6,6 @@ import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"testing"
)
@ -33,30 +32,6 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_GetFromCache(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{})
sut.cache.SetDefault(testApiKey, testUser)
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
userServiceMock.AssertNotCalled(t, "GetUserByKey", mock.Anything)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))

View File

@ -0,0 +1,94 @@
package relay
import (
"bytes"
"encoding/base64"
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"io"
"io/ioutil"
"net/http"
"time"
)
/* Middleware to conditionally relay heartbeats to Wakatime */
type WakatimeRelayMiddleware struct {
httpClient *http.Client
}
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
return &WakatimeRelayMiddleware{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
})
}
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
defer next(w, r)
if r.Method != http.MethodPost {
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil || user.WakatimeApiKey == "" {
return
}
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
headers := http.Header{
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
"Content-Type": r.Header.Values("Content-Type"),
"Accept": r.Header.Values("Accept"),
"User-Agent": r.Header.Values("User-Agent"),
"X-Origin": []string{
fmt.Sprintf("wakapi v%s", config.Get().Version),
},
"Authorization": []string{
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
},
}
go m.send(
http.MethodPost,
config.WakatimeApiUrl+config.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)
}
}

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,108 +0,0 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
var customPreMigrations []migrationFunc
func init() {
customPreMigrations = []migrationFunc{
{
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
oldTableName, newTableName := "custom_rules", "language_mappings"
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
if migrator.HasTable(oldTableName) {
log.Printf("renaming '%s' table to '%s'\n", oldTableName, newTableName)
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
return err
}
log.Printf("renaming '%s' index to '%s'\n", 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
log.Println("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
log.Println("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 {
log.Println("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) {
log.Printf("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 {
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}
}
}

View File

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

67
migrations/migrations.go Normal file
View File

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

View File

@ -34,6 +34,11 @@ func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) Delete(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
@ -44,6 +49,11 @@ func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error)
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)

View File

@ -2,8 +2,8 @@ package models
type Alias struct {
ID uint `gorm:"primary_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
User *User `json:"-" gorm:"not null"`
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `gorm:"not null; index:idx_alias_user"`
Key string `gorm:"not null; index:idx_alias_type_key"`
Value string `gorm:"not null"`

View File

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

View File

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

View File

@ -1,26 +1,30 @@
package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"regexp"
"time"
)
type Heartbeat struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
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 {
@ -62,3 +66,36 @@ func (h *Heartbeat) GetKey(t uint8) (key string) {
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
}

View File

@ -71,6 +71,10 @@ func (j *CustomTime) Scan(value interface{}) error {
return nil
}
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) Value() (driver.Value, error) {
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil

View File

@ -24,6 +24,18 @@ const (
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 {
@ -66,6 +78,8 @@ 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
@ -186,11 +200,12 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur
return timeSum
}
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
for _, f := range filter.All() {
timeSum += s.TotalTimeByKey(f.Type, f.Key)
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
do, typeId, key := filters.One()
if do {
return s.TotalTimeByKey(typeId, key)
}
return timeSum
return 0
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {

View File

@ -94,12 +94,19 @@ func TestSummary_TotalTimeByFilters(t *testing.T) {
},
}
// 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{Project: "wakapi", Language: "Go"} // filters have OR logic
filters2 := &Filters{Language: "Go"}
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration1+testDuration3, sut.TotalTimeByFilters(filters2))
assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
}

View File

@ -7,6 +7,7 @@ type User struct {
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 {

View File

@ -2,10 +2,10 @@ package repositories
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"log"
)
type KeyValueRepository struct {
@ -41,7 +41,7 @@ func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
}
if result.RowsAffected != 1 {
log.Printf("warning: did not insert key '%s', maybe just updated?\n", kv.Key)
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
}
return nil

View File

@ -49,4 +49,5 @@ type IUserRepository interface {
InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error)
Delete(*models.User) error
}

View File

@ -33,7 +33,7 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Where(&models.User{}).
Find(&users).Error; err != nil {
return nil, err
}
@ -78,3 +78,7 @@ func (r *UserRepository) UpdateField(user *models.User, key string, value interf
return user, nil
}
func (r *UserRepository) Delete(user *models.User) error {
return r.db.Delete(user).Error
}

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

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

View File

@ -1,25 +1,26 @@
package routes
package api
import (
"encoding/json"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"net/http"
"os"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"github.com/muety/wakapi/models"
)
type HeartbeatHandler struct {
type HeartbeatApiHandler struct {
config *conf.Config
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{
func NewHeartbeatApiHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatApiHandler {
return &HeartbeatApiHandler{
config: conf.Get(),
heartbeatSrvc: heartbeatService,
languageMappingSrvc: languageMappingService,
@ -30,7 +31,15 @@ type heartbeatResponseVm struct {
Responses [][]interface{} `json:"responses"`
}
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter()
r.Use(
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
r.Methods(http.MethodPost).HandlerFunc(h.Post)
}
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
@ -55,11 +64,13 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
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
}

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

@ -0,0 +1,38 @@
package api
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type SummaryApiHandler struct {
config *conf.Config
summarySrvc services.ISummaryService
}
func NewSummaryApiHandler(summaryService services.ISummaryService) *SummaryApiHandler {
return &SummaryApiHandler{
summarySrvc: summaryService,
config: conf.Get(),
}
}
func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
utils.RespondJSON(w, http.StatusOK, summary)
}

View File

@ -31,7 +31,12 @@ func NewBadgeHandler(summaryService services.ISummaryService, userService servic
}
}
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/shields/v1/{user}").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)

View File

@ -24,7 +24,11 @@ func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler
}
}
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
router.Path("/wakatime/v1/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)

View File

@ -25,13 +25,16 @@ func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHand
}
}
/*
TODO: support parameters: project, branches, timeout, writes_only, timezone
https://wakatime.com/developers#summaries
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
*/
func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
router.Path("/wakatime/v1/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
// See https://wakatime.com/developers#summaries.
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108.
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
@ -48,28 +51,41 @@ func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
filters := &models.Filters{}
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
filters.Project = projectQuery
}
vm := v1.NewSummariesFrom(summaries, filters)
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
var start, end time.Time
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
start = utils.StartOfToday()
end = time.Now()
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.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(startKey, " ", "+", 1))
start, err = time.Parse(time.RFC3339, strings.Replace(startParam, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
end, err = time.Parse(time.RFC3339, strings.Replace(endParam, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}

7
routes/handler.go Normal file
View File

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

View File

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

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -27,6 +28,10 @@ func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
}
}
func (h *HomeHandler) RegisterRoutes(router *mux.Router) {
router.Path("/").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()

View File

@ -1,6 +1,7 @@
package routes
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
@ -20,6 +21,10 @@ func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandle
}
}
func (h *ImprintHandler) RegisterRoutes(router *mux.Router) {
router.Path("/imprint").Methods(http.MethodGet).HandlerFunc(h.GetImprint)
}
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()

View File

@ -2,11 +2,12 @@ package routes
import (
"fmt"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
@ -23,6 +24,14 @@ func NewLoginHandler(userService services.IUserService) *LoginHandler {
}
}
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
router.Path("/login").Methods(http.MethodPost).HandlerFunc(h.PostLogin)
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
@ -65,14 +74,13 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, &h.userSrvc) {
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)
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"))

View File

@ -2,11 +2,13 @@ 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"
)
@ -15,10 +17,12 @@ func Init() {
loadTemplates()
}
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template
func loadTemplates() {
tplPath := "views"
const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
@ -44,7 +48,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)
}
@ -55,7 +64,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)
}

View File

@ -1,16 +1,20 @@
package routes
import (
"encoding/base64"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"strconv"
"time"
)
type SettingsHandler struct {
@ -20,6 +24,7 @@ type SettingsHandler struct {
aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
httpClient *http.Client
}
var credentialsDecoder = schema.NewDecoder()
@ -32,9 +37,19 @@ func NewSettingsHandler(userService services.IUserService, summaryService servic
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
userSrvc: userService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc, []string{}).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
}
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
@ -43,7 +58,73 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing form values"))
return
}
action := r.PostForm.Get("action")
r.PostForm.Del("action")
actionFunc := h.dispatchAction(action)
if actionFunc == nil {
logbuch.Warn("failed to dispatch action '%s'", action)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("unknown action requests"))
return
}
status, successMsg, errorMsg := actionFunc(w, r)
// action responded itself
if status == -1 {
return
}
if errorMsg != "" {
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
return
}
if successMsg != "" {
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) dispatchAction(action string) action {
switch action {
case "change_password":
return h.actionChangePassword
case "reset_apikey":
return h.actionResetApiKey
case "delete_alias":
return h.actionDeleteAlias
case "add_alias":
return h.actionAddAlias
case "delete_mapping":
return h.actionDeleteLanguageMapping
case "add_mapping":
return h.actionAddLanguageMapping
case "toggle_badges":
return h.actionToggleBadges
case "toggle_wakatime":
return h.actionSetWakatimeApiKey
case "regenerate_summaries":
return h.actionRegenerateSummaries
case "delete_account":
return h.actionDeleteUser
}
return nil
}
func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -52,118 +133,59 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
return http.StatusBadRequest, "", "missing parameters"
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
return http.StatusBadRequest, "", "missing parameters"
}
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
return http.StatusUnauthorized, "", "invalid credentials"
}
if !credentials.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
return http.StatusBadRequest, "", "invalid parameters"
}
user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", "internal server error"
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", "internal server error"
}
login := &models.Login{
Username: user.ID,
Password: user.Password,
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", "internal server error"
}
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
return http.StatusOK, "password was updated successfully", ""
}
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
return http.StatusInternalServerError, "", "internal server error"
}
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"))
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
return http.StatusOK, msg, ""
}
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
w.WriteHeader(http.StatusConflict)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
}
func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -176,19 +198,15 @@ func (h *SettingsHandler) DeleteAlias(w http.ResponseWriter, r *http.Request) {
}
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("aliases not found"))
return
return http.StatusNotFound, "", "aliases not found"
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete aliases"))
return
return http.StatusInternalServerError, "", "could not delete aliases"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("aliases deleted successfully"))
return http.StatusOK, "aliases deleted successfully", ""
}
func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -208,69 +226,163 @@ func (h *SettingsHandler) PostAlias(w http.ResponseWriter, r *http.Request) {
}
if _, err := h.aliasSrvc.Create(alias); err != nil {
w.WriteHeader(http.StatusBadRequest)
// TODO: distinguish between bad request, conflict and server error
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid input"))
return
return http.StatusBadRequest, "", "invalid input"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("alias added successfully"))
return http.StatusOK, "alias added successfully", ""
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
return http.StatusNotFound, "", "mapping not found"
} else if mapping.UserID != user.ID {
return http.StatusForbidden, "", "not allowed to delete mapping"
}
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
return http.StatusInternalServerError, "", "could not delete mapping"
}
return http.StatusOK, "mapping deleted successfully", ""
}
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
return http.StatusConflict, "", "mapping already exists"
}
return http.StatusOK, "mapping added successfully", ""
}
func (h *SettingsHandler) actionToggleBadges(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
return http.StatusInternalServerError, "", "internal server error"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
return http.StatusOK, "", ""
}
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
apiKey := r.PostFormValue("api_key")
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
}
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
return http.StatusInternalServerError, "", "internal server error"
}
return http.StatusOK, "Wakatime API Key updated successfully", ""
}
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
log.Printf("clearing summaries for user '%s'\n", user.ID)
logbuch.Info("clearing summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
log.Printf("failed to clear summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
return
logbuch.Error("failed to clear summaries: %v", err)
return http.StatusInternalServerError, "", "failed to delete old summaries"
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
log.Printf("failed to regenerate summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
return
logbuch.Error("failed to regenerate summaries: %v", err)
return http.StatusInternalServerError, "", "failed to generate aggregations"
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated this may take a few seconds"))
return http.StatusOK, "summaries are being regenerated this may take a few seconds", ""
}
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
go func(user *models.User) {
logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute)
if err := h.userSrvc.Delete(user); err != nil {
logbuch.Error("failed to delete user '%s' %v", user.ID, err)
} else {
logbuch.Info("successfully deleted user '%s'", user.ID)
}
}(user)
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
return -1, "", ""
}
func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
headers := http.Header{
"Accept": []string{"application/json"},
"Authorization": []string{
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(apiKey))),
},
}
request, err := http.NewRequest(
http.MethodGet,
conf.WakatimeApiUrl+conf.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 {

View File

@ -1,9 +1,12 @@
package routes
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -11,25 +14,24 @@ import (
type SummaryHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
}
}
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
utils.RespondJSON(w, http.StatusOK, summary)
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc, []string{}).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
@ -43,7 +45,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode()
}
summary, err, status := h.loadUserSummary(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
@ -59,32 +61,15 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.App.GetLanguageColors(), 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[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
}
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),

View File

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

View File

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

View File

@ -1,8 +1,8 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"log"
"runtime"
"time"
@ -40,7 +40,7 @@ type AggregationJob struct {
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
log.Fatalf("failed to run AggregationJob: %v\n", err)
logbuch.Fatal("failed to run AggregationJob: %v", err)
}
s := gocron.NewScheduler(time.Local)
@ -73,9 +73,9 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
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
}
}
@ -84,23 +84,23 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil {
log.Printf("Failed to save summary (%v, %v, %s) %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
logbuch.Error("failed to save summary (%v, %v, %s) %v", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
log.Println("Generating summaries.")
logbuch.Info("generating summaries")
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
log.Println(err)
logbuch.Error(err.Error())
return err
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, len(userIds))
for i, u := range allUsers {
users = make([]*models.User, 0)
for _, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users[i] = u
users = append(users, u)
}
}
} else {
@ -110,14 +110,14 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
// 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
}
// 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 {
log.Println(err)
logbuch.Error(err.Error())
return err
}

View File

@ -2,10 +2,10 @@ package services
import (
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"log"
"sync"
)
@ -110,6 +110,6 @@ func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
func (srv *AliasService) reinitUser(userId string) {
if err := srv.InitializeUser(userId); err != nil {
log.Printf("error initializing user aliases %v\n", err)
logbuch.Error("error initializing user aliases %v", err)
}
}

View File

@ -1,9 +1,9 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"go.uber.org/atomic"
"log"
"runtime"
"strconv"
"time"
@ -42,7 +42,7 @@ type CountTotalTimeResult struct {
func (srv *MiscService) ScheduleCountTotalTime() {
// Run once initially
if err := srv.runCountTotalTime(); err != nil {
log.Fatalf("failed to run CountTotalTimeJob: %v\n", err)
logbuch.Error("failed to run CountTotalTimeJob: %v", err)
}
s := gocron.NewScheduler(time.Local)
@ -80,9 +80,9 @@ func (srv *MiscService) runCountTotalTime() error {
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 {
log.Printf("Failed to count total for user %s: %v.\n", job.UserID, err)
logbuch.Error("failed to count total for user %s: %v", job.UserID, err)
} else {
log.Printf("Successfully counted total for user %s.\n", job.UserID)
logbuch.Info("successfully counted total for user %s", job.UserID)
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
@ -107,13 +107,13 @@ func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeRes
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
log.Printf("Failed to save total time count: %v\n", err)
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 {
log.Printf("Failed to save total users count: %v\n", err)
logbuch.Error("failed to save total users count: %v", err)
}
}

View File

@ -61,7 +61,9 @@ type IUserService interface {
GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
Delete(*models.User) error
ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
SetWakatimeApiKey(*models.User, string) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
}

View File

@ -5,11 +5,14 @@ import (
"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 *config.Config
cache *cache.Cache
repository repositories.IUserRepository
}
@ -17,15 +20,36 @@ func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{
Config: config.Get(),
repository: userRepo,
cache: cache.New(1*time.Hour, 2*time.Hour),
}
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
return srv.repository.GetById(userId)
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) {
return srv.repository.GetByApiKey(key)
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) {
@ -49,19 +73,28 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
}
func (srv *UserService) Update(user *models.User) (*models.User, error) {
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) 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
@ -70,3 +103,8 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi
}
return srv.repository.UpdateField(user, "password", user.Password)
}
func (srv *UserService) Delete(user *models.User) error {
srv.cache.Flush()
return srv.repository.Delete(user)
}

View File

@ -110,7 +110,7 @@ function draw(subselection) {
data: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
backgroundColor: wakapiData.operatingSystems.map(p => osColors[p.key.toLowerCase()] || getRandomColor(p.key))
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
@ -132,7 +132,7 @@ function draw(subselection) {
data: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
backgroundColor: wakapiData.editors.map(p => editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
}],
labels: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))

File diff suppressed because one or more lines are too long

Binary file not shown.

BIN
static/assets/vendor/roboto-latin.woff2 vendored Normal file

Binary file not shown.

18
static/assets/vendor/roboto.css vendored Normal file
View File

@ -0,0 +1,18 @@
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(roboto-latin-ext.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(roboto-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@ -0,0 +1 @@
!function(f,a,c){var s,l=256,p="random",d=c.pow(l,6),g=c.pow(2,52),y=2*g,h=l-1;function n(n,t,r){function e(){for(var n=u.g(6),t=d,r=0;n<g;)n=(n+r)*l,t*=l,r=u.g(1);for(;y<=n;)n/=2,t/=2,r>>>=1;return(n+r)/t}var o=[],i=j(function n(t,r){var e,o=[],i=typeof t;if(r&&"object"==i)for(e in t)try{o.push(n(t[e],r-1))}catch(n){}return o.length?o:"string"==i?t:t+"\0"}((t=1==t?{entropy:!0}:t||{}).entropy?[n,S(a)]:null==n?function(){try{var n;return s&&(n=s.randomBytes)?n=n(l):(n=new Uint8Array(l),(f.crypto||f.msCrypto).getRandomValues(n)),S(n)}catch(n){var t=f.navigator,r=t&&t.plugins;return[+new Date,f,r,f.screen,S(a)]}}():n,3),o),u=new m(o);return e.int32=function(){return 0|u.g(4)},e.quick=function(){return u.g(4)/4294967296},e.double=e,j(S(u.S),a),(t.pass||r||function(n,t,r,e){return e&&(e.S&&v(e,u),n.state=function(){return v(u,{})}),r?(c[p]=n,t):n})(e,i,"global"in t?t.global:this==c,t.state)}function m(n){var t,r=n.length,u=this,e=0,o=u.i=u.j=0,i=u.S=[];for(r||(n=[r++]);e<l;)i[e]=e++;for(e=0;e<l;e++)i[e]=i[o=h&o+n[e%r]+(t=i[e])],i[o]=t;(u.g=function(n){for(var t,r=0,e=u.i,o=u.j,i=u.S;n--;)t=i[e=h&e+1],r=r*l+i[h&(i[e]=i[o=h&o+t])+(i[o]=t)];return u.i=e,u.j=o,r})(l)}function v(n,t){return t.i=n.i,t.j=n.j,t.S=n.S.slice(),t}function j(n,t){for(var r,e=n+"",o=0;o<e.length;)t[h&o]=h&(r^=19*t[h&o])+e.charCodeAt(o++);return S(t)}function S(n){return String.fromCharCode.apply(0,n)}if(j(c.random(),a),"object"==typeof module&&module.exports){module.exports=n;try{s=require("crypto")}catch(n){}}else"function"==typeof define&&define.amd?define(function(){return n}):c["seed"+p]=n}("undefined"!=typeof self?self:this,[],Math);

1
static/assets/vendor/tailwind.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,7 @@
package utils
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -46,21 +44,17 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) {
return string(keyBytes), err
}
func ExtractCookieAuth(r *http.Request, config *config.Config) (login *models.Login, err error) {
func ExtractCookieAuth(r *http.Request, config *config.Config) (username *string, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil {
return nil, errors.New("invalid parameters")
if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &username); err != nil {
return nil, errors.New("cookie is invalid")
}
return login, nil
}
func IsMd5(hash string) bool {
return md5Regex.Match([]byte(hash))
return username, nil
}
func CompareBcrypt(wanted, actual, pepper string) bool {
@ -69,11 +63,6 @@ func CompareBcrypt(wanted, actual, pepper string) bool {
return err == nil
}
// deprecated, only here for backwards compatibility
func CompareMd5(wanted, actual, pepper string) bool {
return HashMd5(actual, pepper) == wanted
}
func HashBcrypt(plain, pepper string) (string, error) {
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
bytes, err := bcrypt.GenerateFromPassword(plainPepperedPassword, bcrypt.DefaultCost)
@ -82,10 +71,3 @@ func HashBcrypt(plain, pepper string) (string, error) {
}
return "", err
}
func HashMd5(plain, pepper string) string {
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
hash := md5.Sum(plainPepperedPassword)
hashStr := hex.EncodeToString(hash[:])
return hashStr
}

View File

@ -5,9 +5,9 @@ import (
"strings"
)
func FilterLanguageColors(all map[string]string, summary *models.Summary) map[string]string {
func FilterColors(all map[string]string, haystack models.SummaryItems) map[string]string {
subset := make(map[string]string)
for _, item := range summary.Languages {
for _, item := range haystack {
if c, ok := all[strings.ToLower(item.Key)]; ok {
subset[strings.ToLower(item.Key)] = c
}

View File

@ -2,7 +2,7 @@ package utils
import (
"encoding/json"
"log"
"github.com/emvi/logbuch"
"net/http"
)
@ -10,6 +10,6 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(object); err != nil {
log.Printf("error while writing json response: %v", err)
logbuch.Error("error while writing json response: %v", err)
}
}

View File

@ -11,21 +11,33 @@ func ResolveInterval(interval string) (err error, from, to time.Time) {
to = time.Now()
switch interval {
case models.IntervalToday:
case models.IntervalToday, models.IntervalWakatimeToday:
from = StartOfToday()
case models.IntervalYesterday:
case models.IntervalYesterday, models.IntervalWakatimeYesterday:
from = StartOfToday().Add(-24 * time.Hour)
to = StartOfToday()
case models.IntervalThisWeek:
case models.IntervalThisWeek, models.IntervalWakatimeThisWeek:
from = StartOfWeek()
case models.IntervalThisMonth:
case models.IntervalWakatimeLastWeek:
from = StartOfWeek().AddDate(0, 0, -7)
to = StartOfWeek()
case models.IntervalThisMonth, models.IntervalWakatimeThisMonth:
from = StartOfMonth()
case models.IntervalWakatimeLastMonth:
from = StartOfMonth().AddDate(0, -1, 0)
to = StartOfMonth()
case models.IntervalThisYear:
from = StartOfYear()
case models.IntervalPast7Days:
case models.IntervalPast7Days, models.IntervalWakatimeLast7Days:
from = StartOfToday().AddDate(0, 0, -7)
case models.IntervalPast30Days:
case models.IntervalWakatimeLast7DaysYesterday:
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday().AddDate(0, 0, -1)
case models.IntervalWakatimeLast14Days:
from = StartOfToday().AddDate(0, 0, -14)
case models.IntervalPast30Days, models.IntervalWakatimeLast30Days:
from = StartOfToday().AddDate(0, 0, -30)
from = StartOfToday().AddDate(0, -6, 0)
case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0)
case models.IntervalAny:

View File

@ -1 +1 @@
1.19.0
1.22.2

View File

@ -1,2 +1,2 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>
<script src="assets/vendor/seedrandom.min.js" integrity="sha384-bFS5CG904xYIgxBcrDF4KFNXuM7KeSGsSvS/QTaDqMTEdbaaxjg2Y2TSU3Ygs7wG" crossorigin="anonymous"></script>
<script src="assets/vendor/Chart.bundle.min.js" integrity="sha384-mZ3q69BYmd4GxHp59G3RrsaFdWDxVSoqd7oVYuWRm2qiXrduT63lQtlhdD9lKbm3" crossorigin="anonymous"></script>

View File

@ -6,7 +6,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
<link rel="manifest" href="assets/site.webmanifest">
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet">
<link href="assets/vendor/roboto.css" rel="stylesheet">
<link href="assets/vendor/tailwind.min.css" rel="stylesheet">
<link href="assets/app.css" rel="stylesheet">
</head>

View File

@ -42,7 +42,7 @@
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!
</button>
</a>
<a href="https://github.com/muety/wakapi#%EF%B8%8F-server-setup" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/muety/wakapi#%EF%B8%8F-how-to-use" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it
</button>
</a>

View File

@ -9,9 +9,11 @@
.inline-bullet-list li a {
text-decoration: underline;
}
.inline-bullet-list li:after {
content: "•";
}
.inline-bullet-list li:last-child:after {
content: "";
}
@ -32,7 +34,7 @@
<main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-xl mt-8">
<div class="text-gray-500 text-xs mb-8">
<ul class="flex justify-center flex-wrap space-x-1 inline-bullet-list">
<ul class="flex justify-center flex-wrap space-x-1 inline-bullet-list px-12">
<li class="hover:text-gray-400 mb-1">
<a href="settings#password">Change Password</a>
</li>
@ -48,6 +50,9 @@
<li class="hover:text-gray-400 mb-1">
<a href="settings#badges">Badges</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#integrations">Integrations</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#danger">Danger Zone</a>
</li>
@ -55,11 +60,12 @@
</div>
<div class="w-full my-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
Change Password
</div>
</h2>
<form class="mt-10" action="settings/credentials" method="post">
<form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="change_password">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
@ -87,11 +93,12 @@
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="apikey">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="apikey">
Reset API Key
</div>
</h2>
<form class="mt-6" action="settings/reset" method="post">
<form class="mt-6" action="" method="post">
<input type="hidden" name="action" value="reset_apikey">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span
class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime
@ -107,9 +114,9 @@
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700" id="aliases">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases
</div>
</h2>
<div class="text-gray-300 text-sm mb-4 mt-6">
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
@ -119,7 +126,7 @@
</div>
{{ if .Aliases }}
<h3 class="text-md font-semibold text-white">Rules</h3>
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
{{ range $i, $alias := .Aliases }}
<div class="flex items-center">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
@ -136,10 +143,12 @@
are mapped to <span class="underline">{{ $alias.Type | typeName }}</span> <span
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{ $alias.Key }}</span>.
</div>
<form class="float-right" action="settings/aliases/delete" method="post">
<form class="float-right" action="" method="post">
<input type="hidden" name="action" value="delete_alias">
<input type="hidden" id="delete_alias_key" name="key" required value="{{ $alias.Key }}">
<input type="hidden" id="delete_alias_type" name="type" required value="{{ $alias.Type }}">
<button type="submit" class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
<button type="submit"
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
</button>
</form>
@ -148,8 +157,9 @@
<div class="mb-8"></div>
{{end}}
<h3 class="text-md font-semibold text-white">Add Rule</h3>
<form action="settings/aliases" method="post">
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Rule</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_alias">
<div class="flex items-center mt-2 w-full text-gray-500 text-sm">
<span class="mr-2">Map</span>
<select name="type" id="select-type"
@ -188,7 +198,7 @@
</div>
{{ if .LanguageMappings }}
<h3 class="text-md font-semibold text-white">Rules</h3>
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
{{ range $i, $mapping := .LanguageMappings }}
<div class="flex items-center">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm">
@ -197,9 +207,11 @@
then change the <span class="underline">language</span> to <span
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Language }}</span>
</div>
<form class="float-right" action="settings/language_mappings/delete" method="post">
<form class="float-right" action="" method="post">
<input type="hidden" name="action" value="delete_mapping">
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
<button type="submit" class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
<button type="submit"
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
</button>
</form>
@ -208,8 +220,9 @@
<div class="mb-8"></div>
{{end}}
<h3 class="text-md font-semibold text-white">Add Rule</h3>
<form action="settings/language_mappings" method="post">
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Add Rule</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_mapping">
<div class="flex items-center w-full text-gray-500 text-sm">
<span class="mr-2">When filename ends in</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
@ -234,7 +247,8 @@
Badges
</div>
<form class="mt-6" action="settings/badges" method="post">
<form class="mt-6" action="" method="post">
<input type="hidden" name="action" value="toggle_badges">
<div class="text-gray-300 text-sm mb-4">
{{ if .User.BadgesEnabled }}
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API
@ -295,6 +309,69 @@
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="integrations">
Integrations
</h2>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
WakaTime
</h3>
<div class="flex space-x-4">
<img alt="WakaTime Logo"
width="55px"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQwIiBoZWlnaHQ9IjM0MCIgdmlld0JveD0iMCAwIDM0MCAzNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcwIDIwQzg3LjE1NiAyMCAyMCA4Ny4xNTYgMjAgMTcwQzIwIDI1Mi44NDQgODcuMTU2IDMyMCAxNzAgMzIwQzI1Mi44NDQgMzIwIDMyMCAyNTIuODQ0IDMyMCAxNzBDMzIwIDg3LjE1NiAyNTIuODQ0IDIwIDE3MCAyMFYyMFYyMFoiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNDAiLz4KPHBhdGggZD0iTTE5MC4xODMgMjEzLjU0MUMxODguNzQgMjE1LjQ0MyAxODYuNTc2IDIxNi42NjcgMTg0LjE1MSAyMTYuNjY3QzE4My45MTMgMjE2LjY2NyAxODMuNjc3IDIxNi42NTEgMTgzLjQ0MyAyMTYuNjI3QzE4My4wNDIgMjE2LjU3OSAxODIuODIzIDIxNi41NDUgMTgyLjYwNiAyMTYuNDk3QzE4Mi4zMzcgMjE2LjQzNCAxODIuMTM3IDIxNi4zNzUgMTgxLjk0IDIxNi4zMDhDMTgxLjU2MSAyMTYuMTc2IDE4MS4zOTIgMjE2LjEwOSAxODEuMjI4IDIxNi4wMzVDMTgwLjg0MyAyMTUuODQ5IDE4MC43MDcgMjE1Ljc3OCAxODAuNTcyIDIxNS43MDFDMTgwLjIwNSAyMTUuNDc4IDE4MC4xMDkgMjE1LjQxMiAxODAuMDE0IDIxNS4zNDVDMTc5Ljg1NiAyMTUuMjMzIDE3OS42OTggMjE1LjExNyAxNzkuNTQ3IDIxNC45OTJDMTc5LjI1MSAyMTQuNzQ2IDE3OS4xNDcgMjE0LjY1IDE3OS4wNDQgMjE0LjU1MkMxNzguNzMxIDIxNC4yNDEgMTc4LjUzMSAyMTQuMDE4IDE3OC4zNDEgMjEzLjc4NUMxNzcuOTgyIDIxMy4zMzEgMTc3LjY5IDIxMi44ODggMTc3LjQzOCAyMTIuNDE1TDE2OC42IDE5OC4yMTRMMTU5Ljc2NiAyMTIuNDE1QzE1OC4zOCAyMTQuOTM5IDE1NS44NzQgMjE2LjY2NyAxNTIuOTk1IDIxNi42NjdDMTUwLjEwNiAyMTYuNjY3IDE0Ny41ODggMjE0LjkyNiAxNDYuMjQzIDIxMi4zNDZMMTA3LjYwNyAxNTYuMDYxQzEwNi4zMzcgMTU0LjUyOSAxMDUuNTU2IDE1Mi40OTkgMTA1LjU1NiAxNTAuMjU4QzEwNS41NTYgMTQ1LjUxNCAxMDkuMDQzIDE0MS42NjUgMTEzLjM0NCAxNDEuNjY1QzExNi4xMjcgMTQxLjY2NSAxMTguNTY0IDE0My4yODIgMTE5Ljk0MiAxNDUuNzA4TDE1Mi41NTUgMTkzLjlMMTYxLjczNSAxNzguOTUyQzE2My4wNTggMTc2LjI4OCAxNjUuNjI2IDE3NC40NzggMTY4LjU3NSAxNzQuNDc4QzE3MS4yNzMgMTc0LjQ3OCAxNzMuNjUyIDE3NS45OTYgMTc1LjA0OSAxNzguMjk4TDE4NC41MTcgMTkzLjgzOUwyMzUuNjg0IDEyMC41ODNDMjM3LjA3NSAxMTguMjI2IDIzOS40NzUgMTE2LjY2NyAyNDIuMjEzIDExNi42NjdDMjQ2LjUxNCAxMTYuNjY3IDI1MCAxMjAuNTE0IDI1MCAxMjUuMjU4QzI1MCAxMjcuMzMyIDI0OS4zMzcgMTI5LjIzMiAyNDguMjMgMTMwLjcxNUwxOTAuMTgzIDIxMy41NDFWMjEzLjU0MVoiIGZpbGw9IndoaXRlIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEwIi8+Cjwvc3ZnPgo=">
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
href="https://wakatime.com"
rel="noopener noreferrer"
target="_blank">WakaTime</a> in a way
that all heartbeats sent to Wakapi are relayed. This way, you can use both services
at
the same time. To get started, <a class="underline"
href="https://wakatime.com/developers#authentication"
rel="noopener noreferrer"
target="_blank">get your API key</a> and paste it here.</p>
</div>
<form action="" method="post">
<input type="hidden" name="action" value="toggle_wakatime">
{{ $placeholderText := "Paste your WakaTime API key here ..." }}
{{ if .User.WakatimeApiKey }}
{{ $placeholderText = "********" }}
{{ end }}
<div class="flex items-center mt-8 space-x-2">
<label class="text-gray-500 font-semibold">API Key:</label>
<input type="password" name="api_key" id="wakatime_api_key"
class="flex-grow shadow appearance-nonshadow appearance-none bg-gray-800 text-gray-300 border-green-700 border rounded py-1 px-3 {{ if not .User.WakatimeApiKey }}focus:bg-gray-700 focus:border-gray-500{{ end }}"
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
<div class="flex-grow flex justify-end">
{{ if not .User.WakatimeApiKey }}
<button type="submit"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Connect
</button>
{{ else }}
<button type="submit"
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">Disconnect
</button>
{{ end }}
</div>
</div>
</form>
<p class="mt-6">
<span class="font-semibold">👉 Please note:</span>
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.</span>
</p>
</div>
</div>
<div class="w-full mt-4 mb-8 pb-8">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
⚠️ Danger Zone
@ -320,14 +397,34 @@
case heartbeats were deleted after the respective summaries had been generated.
</p>
</div>
<div class="mt-10 flex justify-center">
<form action="settings/regenerate" method="post" id="form-regenerate-summaries">
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-regenerate-summaries">
<input type="hidden" name="action" value="regenerate_summaries">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-regenerate-summaries">
Clear & Regenerate
</button>
</form>
</div>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Delete Account
</h3>
<p>
Deleting your account will cause all data, including all your heartbeats, to be erased from the
server immediately. This action is irreversible. <strong>Be careful!</strong>
</p>
</div>
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-delete-user">
<input type="hidden" name="action" value="delete_account">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-confirm-delete-user">
Delete my Account
</button>
</form>
</div>
</div>
</div>
</main>
@ -350,6 +447,14 @@
formRegenerate.submit()
}
})
const btnDelete = document.querySelector('#btn-confirm-delete-user')
const formDelete = document.querySelector('#form-delete-user')
btnDelete.addEventListener('click', () => {
if (confirm('Are you sure? This can not be undone!')) {
formDelete.submit()
}
})
</script>
{{ template "footer.tpl.html" . }}

View File

@ -158,6 +158,8 @@
<script>
const languageColors = {{ .LanguageColors | json }}
const editorColors = {{ .EditorColors | json }}
const osColors = {{ .OSColors | json }}
const wakapiData = {}
wakapiData.projects = {{ .Projects | json }}