mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
60 Commits
Author | SHA1 | Date | |
---|---|---|---|
9cb9747e2e | |||
68a17950ef | |||
a2368ff76a | |||
4838300086 | |||
a60c725d38 | |||
8ceef42ad4 | |||
8bed266110 | |||
a7afd73e62 | |||
1dc5be4784 | |||
b6812ddc3a | |||
4f7cc3c57e | |||
c6139e5366 | |||
28269aa329 | |||
b7ae15496d | |||
f483488dd5 | |||
0c3f3b37b0 | |||
dc1a0c7983 | |||
e4b38d3f51 | |||
665ffe8bd1 | |||
3e5a51c272 | |||
979549448c | |||
105f96ff72 | |||
31013ad986 | |||
db4cb92c26 | |||
779108ad88 | |||
61f8a22cff | |||
179a107c2a | |||
ef0c76e2ff | |||
617d9ad7e4 | |||
fd239e4f21 | |||
417d4789ab | |||
a6aff07b21 | |||
b732eea9b7 | |||
71d1b2177b | |||
b2a3579be9 | |||
42a6e9d923 | |||
1f44ccadba | |||
6ea72c6d02 | |||
d93348842a | |||
fb92747129 | |||
4e6e665e19 | |||
a3d8c4d464 | |||
e9eaa9da53 | |||
5adb795f59 | |||
a552073d18 | |||
de0401d4bb | |||
c39538db13 | |||
189a09d91f | |||
d57c02af7c | |||
16b683fcbd | |||
acda62488d | |||
1aecfc4ca3 | |||
cd97976ed5 | |||
3a4504d56a | |||
a018f70c3f | |||
a03e49e7f0 | |||
ec81d9fe5d | |||
b7a1e2d795 | |||
98b62b33c8 | |||
262bee9022 |
@ -1 +1,10 @@
|
||||
.env
|
||||
.env
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
*.db
|
||||
*.exe
|
||||
wakapi
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.git*
|
||||
|
42
.github/workflows/docker.yml
vendored
Normal file
42
.github/workflows/docker.yml
vendored
Normal 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
|
26
.github/workflows/linux-build-on-release.yml
vendored
26
.github/workflows/linux-build-on-release.yml
vendored
@ -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 }}
|
||||
|
23
.github/workflows/win-build-on-release.yml
vendored
23
.github/workflows/win-build-on-release.yml
vendored
@ -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
3
.gitignore
vendored
@ -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
|
||||
|
43
Dockerfile
43
Dockerfile
@ -1,30 +1,32 @@
|
||||
# Build Stage
|
||||
|
||||
FROM golang:1.13 AS build-env
|
||||
FROM golang:1.15 AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
|
||||
|
||||
ADD . .
|
||||
RUN go build -o wakapi
|
||||
RUN go generate && go build -o wakapi
|
||||
|
||||
# Final Stage
|
||||
WORKDIR /app
|
||||
RUN cp /src/wakapi . && \
|
||||
cp /src/config.default.yml config.yml && \
|
||||
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
|
||||
cp /src/wait-for-it.sh .
|
||||
|
||||
# Run Stage
|
||||
|
||||
# When running the application using `docker run`, you can pass environment variables
|
||||
# to override config values using `-e` syntax.
|
||||
# Available options are:
|
||||
# – WAKAPI_DB_TYPE
|
||||
# – WAKAPI_DB_USER
|
||||
# – WAKAPI_DB_PASSWORD
|
||||
# – WAKAPI_DB_HOST
|
||||
# – WAKAPI_DB_PORT
|
||||
# – WAKAPI_DB_NAME
|
||||
# – WAKAPI_PASSWORD_SALT
|
||||
# – WAKAPI_BASE_PATH
|
||||
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
||||
|
||||
FROM debian
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y ca-certificates
|
||||
|
||||
ENV ENVIRONMENT prod
|
||||
ENV WAKAPI_DB_TYPE sqlite3
|
||||
ENV WAKAPI_DB_USER ''
|
||||
@ -32,19 +34,10 @@ ENV WAKAPI_DB_PASSWORD ''
|
||||
ENV WAKAPI_DB_HOST ''
|
||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||
ENV WAKAPI_PASSWORD_SALT ''
|
||||
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
|
||||
ENV WAKAPI_INSECURE_COOKIES 'true'
|
||||
|
||||
COPY --from=build-env /src/wakapi /app/
|
||||
COPY --from=build-env /src/config.default.yml /app/config.yml
|
||||
COPY --from=build-env /src/version.txt /app/
|
||||
|
||||
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml
|
||||
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
|
||||
|
||||
ADD static /app/static
|
||||
ADD data /app/data
|
||||
ADD migrations /app/migrations
|
||||
ADD views /app/views
|
||||
ADD wait-for-it.sh .
|
||||
COPY --from=build-env /app .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
|
305
README.md
305
README.md
@ -1,70 +1,148 @@
|
||||
# 📈 wakapi
|
||||
<h1 align="center">📊 Wakapi</h1>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://saythanks.io/to/n1try)
|
||||
[](https://liberapay.com/muety/)
|
||||

|
||||
[](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||

|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](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>
|
||||
|
||||

|
||||
<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 --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 |
|
||||
@ -73,7 +151,7 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
||||
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||
@ -85,43 +163,20 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
||||
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of sqlite3, mysql, postgres) |
|
||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
||||
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
|
||||
## 💻 Client Setup
|
||||
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
|
||||
### Supported databases
|
||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||
* [SQLite](https://sqlite.org/) (_default, easy setup_)
|
||||
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
|
||||
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
|
||||
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
|
||||
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
|
||||
|
||||
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
|
||||
2. Make your local WakaTime client talk to Wakapi by **editing your local `~/.wakatime.cfg`** file as follows
|
||||
|
||||
```
|
||||
api_url = https://your.server:someport/api/heartbeat`
|
||||
api_key = the_api_key_printed_to_the_console_after_starting_the_server`
|
||||
```
|
||||
|
||||
You can view your API Key after logging in to the web interface.
|
||||
|
||||
### Optional: Client-side proxy
|
||||
|
||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
||||
|
||||
## 🔵 Customization
|
||||
|
||||
### Aliases
|
||||
There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects – `myapp-frontend` and `myapp-backend` – two a common project name – `myapp-web` – in your statistics, you can add project aliases.
|
||||
|
||||
At the moment, this can only be done via raw database queries. For the above example, you would need to add two aliases, like this:
|
||||
|
||||
```sql
|
||||
INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_username', 'myapp-web', 'myapp-frontend');
|
||||
```
|
||||
|
||||
#### Types
|
||||
* Project ~ type **0**
|
||||
* Language ~ type **1**
|
||||
* Editor ~ type **2**
|
||||
* OS ~ type **3**
|
||||
* Machine ~ type **4**
|
||||
### Client-side proxy (`optional`)
|
||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
||||
|
||||
## 🔧 API Endpoints
|
||||
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
|
||||
@ -142,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)
|
||||
|
@ -10,6 +10,7 @@ server:
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
@ -21,7 +22,8 @@ db:
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
max_conn: 2
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
|
121
config/config.go
121
config/config.go
@ -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,29 +15,36 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "config.yml"
|
||||
defaultConfigPathLegacy = "config.ini"
|
||||
defaultEnvConfigPathLegacy = ".env"
|
||||
defaultConfigPath = "config.yml"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
)
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@ -52,8 +61,10 @@ type dbConfig struct {
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||
Dialect string `default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
Dialect string `yaml:"-"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
@ -120,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)
|
||||
@ -131,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
|
||||
}
|
||||
}
|
||||
@ -166,12 +177,18 @@ func mysqlConnectionString(config *dbConfig) string {
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
sslmode,
|
||||
)
|
||||
}
|
||||
|
||||
@ -179,45 +196,64 @@ func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
||||
|
||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetLanguageColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["languages"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetEditorColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["editors"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetOSColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
file, err := os.Open("version.txt")
|
||||
file, err := pkger.Open("/version.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return string(bytes)
|
||||
return strings.TrimSpace(string(bytes))
|
||||
}
|
||||
|
||||
func readLanguageColors() map[string]string {
|
||||
func readColors() map[string]map[string]string {
|
||||
// Read language colors
|
||||
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
var colors = make(map[string]string)
|
||||
var rawColors map[string]struct {
|
||||
Color string `json:"color"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
// Source:
|
||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// – https://wakatime.com/colors/operating_systems
|
||||
// - https://wakatime.com/colors/editors
|
||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
var colors = make(map[string]map[string]string)
|
||||
|
||||
data, err := ioutil.ReadFile("data/colors.json")
|
||||
file, err := pkger.Open("/data/colors.json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &rawColors); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for k, v := range rawColors {
|
||||
colors[strings.ToLower(k)] = v.Color
|
||||
if err := json.Unmarshal(bytes, &colors); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return colors
|
||||
@ -225,12 +261,18 @@ func readLanguageColors() map[string]string {
|
||||
|
||||
func mustReadConfigLocation() string {
|
||||
if _, err := os.Stat(*cFlag); err != nil {
|
||||
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
|
||||
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
|
||||
}
|
||||
|
||||
return *cFlag
|
||||
}
|
||||
|
||||
func resolveDbDialect(dbType string) string {
|
||||
if dbType == "cockroach" {
|
||||
return "postgres"
|
||||
}
|
||||
return dbType
|
||||
}
|
||||
|
||||
func Set(config *Config) {
|
||||
cfg = config
|
||||
}
|
||||
@ -244,14 +286,13 @@ 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),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
@ -268,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)
|
||||
|
124
config/legacy.go
124
config/legacy.go
@ -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
|
||||
}
|
14
config/utils.go
Normal file
14
config/utils.go
Normal file
@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
func cloneStringMap(m map[string]string, keysToLower bool) map[string]string {
|
||||
m2 := make(map[string]string)
|
||||
for k, v := range m {
|
||||
if keysToLower {
|
||||
k = strings.ToLower(k)
|
||||
}
|
||||
m2[k] = v
|
||||
}
|
||||
return m2
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
mode: set
|
||||
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/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
|
||||
@ -10,45 +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/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
|
||||
@ -59,10 +57,10 @@ 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/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/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
|
||||
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
|
||||
@ -73,280 +71,221 @@ github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
|
||||
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
|
||||
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
|
||||
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
|
||||
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
|
||||
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
|
||||
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
|
||||
github.com/muety/wakapi/models/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/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/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/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.56,24.45 3 1
|
||||
github.com/muety/wakapi/utils/common.go:27.2,27.40 1 1
|
||||
github.com/muety/wakapi/utils/common.go:24.45,26.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/config/config.go:77.70,79.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:81.65,83.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:85.82,95.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:97.31,99.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:101.32,103.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:105.74,106.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:107.10,108.34 1 0
|
||||
github.com/muety/wakapi/config/config.go:108.34,117.4 8 0
|
||||
github.com/muety/wakapi/config/config.go:121.73,122.33 1 0
|
||||
github.com/muety/wakapi/config/config.go:122.33,130.17 5 0
|
||||
github.com/muety/wakapi/config/config.go:134.3,135.13 2 0
|
||||
github.com/muety/wakapi/config/config.go:130.17,132.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:139.50,140.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:153.2,153.12 1 0
|
||||
github.com/muety/wakapi/config/config.go:141.23,145.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:146.26,149.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:150.24,151.48 1 0
|
||||
github.com/muety/wakapi/config/config.go:156.53,166.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:168.56,176.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:178.54,180.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:182.29,184.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:186.27,188.16 2 0
|
||||
github.com/muety/wakapi/config/config.go:191.2,194.16 3 0
|
||||
github.com/muety/wakapi/config/config.go:198.2,198.22 1 0
|
||||
github.com/muety/wakapi/config/config.go:188.16,190.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:194.16,196.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:201.45,211.16 4 0
|
||||
github.com/muety/wakapi/config/config.go:215.2,215.57 1 0
|
||||
github.com/muety/wakapi/config/config.go:219.2,219.30 1 0
|
||||
github.com/muety/wakapi/config/config.go:223.2,223.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:211.16,213.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:215.57,217.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:219.30,221.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:226.38,227.43 1 0
|
||||
github.com/muety/wakapi/config/config.go:231.2,231.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:227.43,229.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:234.26,236.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:238.20,240.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:242.21,249.96 4 0
|
||||
github.com/muety/wakapi/config/config.go:253.2,260.52 4 0
|
||||
github.com/muety/wakapi/config/config.go:264.2,264.47 1 0
|
||||
github.com/muety/wakapi/config/config.go:270.2,270.70 1 0
|
||||
github.com/muety/wakapi/config/config.go:274.2,275.14 2 0
|
||||
github.com/muety/wakapi/config/config.go:249.96,251.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:260.52,262.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:264.47,265.14 1 0
|
||||
github.com/muety/wakapi/config/config.go:265.14,267.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:270.70,272.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/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/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/services/language_mapping.go:17.118,23.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:25.86,27.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:29.96,30.53 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:34.2,35.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:38.2,39.22 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:30.53,32.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:35.16,37.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:42.92,45.16 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:49.2,49.33 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:52.2,52.22 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:45.16,47.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:49.33,51.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:55.109,57.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:61.2,62.20 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:57.16,59.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:65.82,69.2 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:71.73,73.2 1 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
|
||||
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0
|
||||
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0
|
||||
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0
|
||||
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0
|
||||
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0
|
||||
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0
|
||||
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
|
||||
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
|
||||
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
|
||||
github.com/muety/wakapi/services/misc.go:81.24,82.144 1 0
|
||||
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
|
||||
github.com/muety/wakapi/services/misc.go:82.144,84.4 1 0
|
||||
github.com/muety/wakapi/services/misc.go:84.9,90.4 2 0
|
||||
github.com/muety/wakapi/services/misc.go:91.48,94.4 2 0
|
||||
github.com/muety/wakapi/services/misc.go:98.86,101.30 3 0
|
||||
github.com/muety/wakapi/services/misc.go:106.2,109.17 1 0
|
||||
github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
|
||||
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
|
||||
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
|
||||
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
|
||||
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
|
||||
github.com/muety/wakapi/services/summary.go:53.2,53.66 1 1
|
||||
github.com/muety/wakapi/services/summary.go:53.2,53.65 1 1
|
||||
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
|
||||
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:53.66,55.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:53.65,55.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
|
||||
@ -427,21 +366,57 @@ 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
|
||||
github.com/muety/wakapi/services/alias.go:27.43,29.3 1 1
|
||||
github.com/muety/wakapi/services/alias.go:33.62,35.16 2 1
|
||||
github.com/muety/wakapi/services/alias.go:38.2,38.12 1 1
|
||||
github.com/muety/wakapi/services/alias.go:35.16,37.3 1 1
|
||||
github.com/muety/wakapi/services/alias.go:41.76,43.16 2 0
|
||||
github.com/muety/wakapi/services/alias.go:46.2,46.21 1 0
|
||||
github.com/muety/wakapi/services/alias.go:43.16,45.3 1 0
|
||||
github.com/muety/wakapi/services/alias.go:49.113,51.16 2 0
|
||||
github.com/muety/wakapi/services/alias.go:54.2,54.21 1 0
|
||||
github.com/muety/wakapi/services/alias.go:51.16,53.3 1 0
|
||||
github.com/muety/wakapi/services/alias.go:57.108,58.32 1 1
|
||||
github.com/muety/wakapi/services/alias.go:64.2,65.46 2 1
|
||||
github.com/muety/wakapi/services/alias.go:70.2,70.19 1 1
|
||||
github.com/muety/wakapi/services/alias.go:58.32,59.52 1 1
|
||||
github.com/muety/wakapi/services/alias.go:59.52,61.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:65.46,66.48 1 1
|
||||
github.com/muety/wakapi/services/alias.go:66.48,68.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:73.77,75.16 2 0
|
||||
github.com/muety/wakapi/services/alias.go:78.2,79.20 2 0
|
||||
github.com/muety/wakapi/services/alias.go:75.16,77.3 1 0
|
||||
github.com/muety/wakapi/services/alias.go:82.60,83.24 1 0
|
||||
github.com/muety/wakapi/services/alias.go:86.2,88.12 3 0
|
||||
github.com/muety/wakapi/services/alias.go:83.24,85.3 1 0
|
||||
github.com/muety/wakapi/services/alias.go:91.69,94.28 3 0
|
||||
github.com/muety/wakapi/services/alias.go:102.2,104.31 2 0
|
||||
github.com/muety/wakapi/services/alias.go:108.2,108.12 1 0
|
||||
github.com/muety/wakapi/services/alias.go:94.28,95.21 1 0
|
||||
github.com/muety/wakapi/services/alias.go:98.3,99.16 2 0
|
||||
github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0
|
||||
github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0
|
||||
github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0
|
||||
github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0
|
||||
github.com/muety/wakapi/services/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/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
|
||||
@ -482,33 +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/alias.go:16.77,21.2 1 1
|
||||
github.com/muety/wakapi/services/alias.go:25.63,27.16 2 1
|
||||
github.com/muety/wakapi/services/alias.go:30.2,30.12 1 1
|
||||
github.com/muety/wakapi/services/alias.go:27.16,29.3 1 1
|
||||
github.com/muety/wakapi/services/alias.go:33.108,34.32 1 1
|
||||
github.com/muety/wakapi/services/alias.go:40.2,41.46 2 1
|
||||
github.com/muety/wakapi/services/alias.go:46.2,46.19 1 1
|
||||
github.com/muety/wakapi/services/alias.go:34.32,35.53 1 1
|
||||
github.com/muety/wakapi/services/alias.go:35.53,37.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:41.46,42.48 1 1
|
||||
github.com/muety/wakapi/services/alias.go:42.48,44.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:49.60,50.43 1 1
|
||||
github.com/muety/wakapi/services/alias.go:53.2,53.14 1 1
|
||||
github.com/muety/wakapi/services/alias.go:50.43,52.3 1 1
|
||||
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
|
||||
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
|
||||
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
|
||||
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
|
||||
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
|
||||
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
|
||||
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:59.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
|
||||
|
1803
data/colors.json
1803
data/colors.json
File diff suppressed because it is too large
Load Diff
@ -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
|
10
go.mod
10
go.mod
@ -3,26 +3,28 @@ module github.com/muety/wakapi
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/emvi/logbuch v1.1.1
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
github.com/gorilla/handlers v1.4.2
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/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
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
go.uber.org/atomic v1.6.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/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
|
||||
gorm.io/gorm v1.20.5
|
||||
gorm.io/gorm v1.20.11
|
||||
)
|
||||
|
20
go.sum
20
go.sum
@ -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=
|
||||
@ -408,6 +414,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
@ -436,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=
|
||||
@ -509,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=
|
||||
@ -547,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=
|
||||
@ -556,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=
|
||||
@ -568,8 +576,8 @@ gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
|
||||
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
|
||||
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
147
main.go
147
main.go
@ -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"
|
||||
|
||||
@ -45,6 +51,7 @@ var (
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
keyValueService services.IKeyValueService
|
||||
miscService services.IMiscService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
@ -52,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;")
|
||||
}
|
||||
@ -71,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)
|
||||
@ -97,86 +116,66 @@ func main() {
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
// Schedule background tasks
|
||||
go aggregationService.Schedule()
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
go miscService.ScheduleCountTotalTime()
|
||||
|
||||
routes.Init()
|
||||
|
||||
// Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
|
||||
homeHandler := routes.NewHomeHandler()
|
||||
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("/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)
|
||||
@ -209,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())
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -248,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())
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
|
94
middlewares/custom/wakatime.go
Normal file
94
middlewares/custom/wakatime.go
Normal file
@ -0,0 +1,94 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* Middleware to conditionally relay heartbeats to Wakatime */
|
||||
type WakatimeRelayMiddleware struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
||||
return &WakatimeRelayMiddleware{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.ServeHTTP(w, r, h.ServeHTTP)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
defer next(w, r)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil || user.WakatimeApiKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
headers := http.Header{
|
||||
"X-Machine-Name": r.Header.Values("X-Machine-Name"),
|
||||
"Content-Type": r.Header.Values("Content-Type"),
|
||||
"Accept": r.Header.Values("Accept"),
|
||||
"User-Agent": r.Header.Values("User-Agent"),
|
||||
"X-Origin": []string{
|
||||
fmt.Sprintf("wakapi v%s", config.Get().Version),
|
||||
},
|
||||
"Authorization": []string{
|
||||
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
|
||||
},
|
||||
}
|
||||
|
||||
go m.send(
|
||||
http.MethodPost,
|
||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsEndpoint,
|
||||
bytes.NewReader(body),
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header) {
|
||||
request, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
logbuch.Warn("error constructing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
for _, h := range v {
|
||||
request.Header.Set(k, h)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
logbuch.Warn("error executing relayed request – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
logbuch.Warn("failed to relay request, got status %d", response.StatusCode)
|
||||
}
|
||||
}
|
@ -1,17 +1,135 @@
|
||||
package middlewares
|
||||
|
||||
// Borrowed from https://gist.github.com/elithrar/887d162dfd0c539b700ab4049c76e22b
|
||||
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoggingMiddleware struct{}
|
||||
|
||||
func NewLoggingMiddleware() *LoggingMiddleware {
|
||||
return &LoggingMiddleware{}
|
||||
type LoggingMiddleware struct {
|
||||
handler http.Handler
|
||||
output *log.Logger
|
||||
}
|
||||
|
||||
func (m *LoggingMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return handlers.LoggingHandler(os.Stdout, h)
|
||||
func NewLoggingMiddleware(output *log.Logger) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &LoggingMiddleware{
|
||||
handler: h,
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ww := wrapWriter(w)
|
||||
|
||||
start := time.Now()
|
||||
lg.handler.ServeHTTP(ww, r)
|
||||
end := time.Now()
|
||||
duration := end.Sub(start)
|
||||
|
||||
lg.output.Printf(
|
||||
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n",
|
||||
time.Now().Format(time.RFC3339Nano),
|
||||
ww.Status(),
|
||||
r.Method,
|
||||
r.URL.String(),
|
||||
duration,
|
||||
ww.BytesWritten(),
|
||||
readUserIP(r),
|
||||
)
|
||||
}
|
||||
|
||||
func readUserIP(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
if ip == "" {
|
||||
ip = r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// The below writer-wrapping code has been lifted from
|
||||
// https://github.com/zenazn/goji/blob/master/web/middleware/logger.go - because
|
||||
// it does exactly what is needed, and it's unlikely to change in any
|
||||
// significant way that makes copying worse-off than importing. MIT licensed
|
||||
// and (c) Carl Jackson.
|
||||
|
||||
// writerProxy is a proxy around an http.ResponseWriter that allows you to hook
|
||||
// into various parts of the response process.
|
||||
type writerProxy interface {
|
||||
http.ResponseWriter
|
||||
// Status returns the HTTP status of the request, or 0 if one has not
|
||||
// yet been sent.
|
||||
Status() int
|
||||
// BytesWritten returns the total number of bytes sent to the client.
|
||||
BytesWritten() int
|
||||
// Tee causes the response body to be written to the given io.Writer in
|
||||
// addition to proxying the writes through. Only one io.Writer can be
|
||||
// tee'd to at once: setting a second one will overwrite the first.
|
||||
// Writes will be sent to the proxy before being written to this
|
||||
// io.Writer. It is illegal for the tee'd writer to be modified
|
||||
// concurrently with writes.
|
||||
Tee(io.Writer)
|
||||
// Unwrap returns the original proxied target.
|
||||
Unwrap() http.ResponseWriter
|
||||
}
|
||||
|
||||
// wrapWriter wraps an http.ResponseWriter, returning a proxy that allows you to
|
||||
// hook into various parts of the response process.
|
||||
func wrapWriter(w http.ResponseWriter) writerProxy {
|
||||
return &basicWriter{ResponseWriter: w}
|
||||
}
|
||||
|
||||
// basicWriter wraps a http.ResponseWriter that implements the minimal
|
||||
// http.ResponseWriter interface.
|
||||
type basicWriter struct {
|
||||
http.ResponseWriter
|
||||
wroteHeader bool
|
||||
code int
|
||||
bytes int
|
||||
tee io.Writer
|
||||
}
|
||||
|
||||
func (b *basicWriter) WriteHeader(code int) {
|
||||
if !b.wroteHeader {
|
||||
b.code = code
|
||||
b.wroteHeader = true
|
||||
b.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Write(buf []byte) (int, error) {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
n, err := b.ResponseWriter.Write(buf)
|
||||
if b.tee != nil {
|
||||
_, err2 := b.tee.Write(buf[:n])
|
||||
// Prefer errors generated by the proxied writer.
|
||||
if err == nil {
|
||||
err = err2
|
||||
}
|
||||
}
|
||||
b.bytes += n
|
||||
return n, err
|
||||
}
|
||||
func (b *basicWriter) maybeWriteHeader() {
|
||||
if !b.wroteHeader {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
func (b *basicWriter) Status() int {
|
||||
return b.code
|
||||
}
|
||||
func (b *basicWriter) BytesWritten() int {
|
||||
return b.bytes
|
||||
}
|
||||
func (b *basicWriter) Tee(w io.Writer) {
|
||||
b.tee = w
|
||||
}
|
||||
func (b *basicWriter) Unwrap() http.ResponseWriter {
|
||||
return b.ResponseWriter
|
||||
}
|
||||
|
17
migrations/00000000_apply_fixtures.go
Normal file
17
migrations/00000000_apply_fixtures.go
Normal 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)
|
||||
}
|
32
migrations/20201103_rename_language_mappings_table.go
Normal file
32
migrations/20201103_rename_language_mappings_table.go
Normal 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)
|
||||
}
|
79
migrations/20201106_migration_cascade_constraints.go
Normal file
79
migrations/20201106_migration_cascade_constraints.go
Normal 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)
|
||||
}
|
58
migrations/20210202_fix_cascade_for_alias_user_constraint.go
Normal file
58
migrations/20210202_fix_cascade_for_alias_user_constraint.go
Normal 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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
67
migrations/migrations.go
Normal 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]
|
||||
}
|
@ -13,3 +13,33 @@ func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndKey(s string, s2 string) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUserAndTypeAndValue(s string, u uint8, s2 string) (*models.Alias, error) {
|
||||
args := m.Called(s, u, s2)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) Insert(s *models.Alias) (*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) Delete(u uint) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) DeleteBatch(u []uint) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
@ -8,7 +9,12 @@ type AliasServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) LoadUserAliases(s string) error {
|
||||
func (m *AliasServiceMock) IsInitialized(s string) bool {
|
||||
args := m.Called(s)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) InitializeUser(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
@ -18,7 +24,27 @@ func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (stri
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) IsInitialized(s string) bool {
|
||||
func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Bool(0)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) Create(a *models.Alias) (*models.Alias, error) {
|
||||
args := m.Called(a)
|
||||
return args.Get(0).(*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) Delete(s *models.Alias) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) DeleteMulti(a []*models.Alias) error {
|
||||
args := m.Called(a)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -2,9 +2,22 @@ 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"`
|
||||
}
|
||||
|
||||
func (a *Alias) IsValid() bool {
|
||||
return a.Key != "" && a.Value != "" && a.validateType()
|
||||
}
|
||||
|
||||
func (a *Alias) validateType() bool {
|
||||
for _, t := range SummaryTypes() {
|
||||
if a.Type == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
@ -43,7 +44,7 @@ func (s *Signup) IsValid() bool {
|
||||
}
|
||||
|
||||
func validateUsername(username string) bool {
|
||||
return len(username) >= 3 && username != "current"
|
||||
return len(username) >= 1 && username != "current"
|
||||
}
|
||||
|
||||
func validatePassword(password string) bool {
|
||||
|
@ -1,8 +1,10 @@
|
||||
package view
|
||||
|
||||
type HomeViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
Success string
|
||||
Error string
|
||||
TotalHours int
|
||||
TotalUsers int
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
|
@ -5,10 +5,17 @@ import "github.com/muety/wakapi/models"
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
type SettingsVMCombinedAlias struct {
|
||||
Key string
|
||||
Type uint8
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
|
@ -1,6 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -22,3 +23,67 @@ func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Key: key,
|
||||
}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Key: key,
|
||||
Type: summaryType,
|
||||
}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
||||
alias := &models.Alias{}
|
||||
if err := r.db.
|
||||
Where(&models.Alias{
|
||||
UserID: userId,
|
||||
Type: summaryType,
|
||||
Value: value,
|
||||
}).
|
||||
First(alias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alias, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) Insert(alias *models.Alias) (*models.Alias, error) {
|
||||
if !alias.IsValid() {
|
||||
return nil, errors.New("invalid alias")
|
||||
}
|
||||
result := r.db.Create(alias)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alias, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) Delete(id uint) error {
|
||||
return r.db.
|
||||
Where("id = ?", id).
|
||||
Delete(models.Alias{}).Error
|
||||
}
|
||||
|
||||
func (r *AliasRepository) DeleteBatch(ids []uint) error {
|
||||
return r.db.
|
||||
Where("id IN ?", ids).
|
||||
Delete(models.Alias{}).Error
|
||||
}
|
||||
|
@ -2,8 +2,10 @@ package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type KeyValueRepository struct {
|
||||
@ -27,16 +29,19 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
|
||||
|
||||
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||
result := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
UpdateAll: true,
|
||||
}).
|
||||
Where(&models.KeyStringValue{Key: kv.Key}).
|
||||
Assign(kv).
|
||||
FirstOrCreate(kv)
|
||||
Create(kv)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing updated")
|
||||
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -6,7 +6,13 @@ import (
|
||||
)
|
||||
|
||||
type IAliasRepository interface {
|
||||
Insert(*models.Alias) (*models.Alias, error)
|
||||
Delete(uint) error
|
||||
DeleteBatch([]uint) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKey(string, string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetByUserAndTypeAndValue(string, uint8, string) (*models.Alias, error)
|
||||
}
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
@ -43,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
|
||||
}
|
||||
|
@ -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
33
routes/api/health.go
Normal 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)))
|
||||
}
|
@ -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,
|
||||
)
|
||||
router.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
38
routes/api/summary.go
Normal 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)
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
7
routes/handler.go
Normal file
@ -0,0 +1,7 @@
|
||||
package routes
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
type Handler interface {
|
||||
RegisterRoutes(router *mux.Router)
|
||||
}
|
@ -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)))
|
||||
}
|
@ -2,26 +2,36 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HomeHandler struct {
|
||||
config *conf.Config
|
||||
config *conf.Config
|
||||
keyValueSrvc services.IKeyValueService
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewHomeHandler() *HomeHandler {
|
||||
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
|
||||
return &HomeHandler{
|
||||
config: conf.Get(),
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HomeHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
@ -36,8 +46,25 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
var totalHours int
|
||||
var totalUsers int
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := time.ParseDuration(t.Value); err == nil {
|
||||
totalHours = int(d.Hours())
|
||||
}
|
||||
}
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := strconv.Atoi(t.Value); err == nil {
|
||||
totalUsers = d
|
||||
}
|
||||
}
|
||||
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalHours: totalHours,
|
||||
TotalUsers: totalUsers,
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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"))
|
||||
|
@ -2,10 +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"
|
||||
)
|
||||
@ -14,15 +17,22 @@ func Init() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
func loadTemplates() {
|
||||
tplPath := "views"
|
||||
const tplPath = "/views"
|
||||
tpls := template.New("").Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"title": strings.Title,
|
||||
"capitalize": utils.Capitalize,
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"title": strings.Title,
|
||||
"join": strings.Join,
|
||||
"add": utils.Add,
|
||||
"capitalize": utils.Capitalize,
|
||||
"toRunes": utils.ToRunes,
|
||||
"entityTypes": models.SummaryTypes,
|
||||
"typeName": typeName,
|
||||
"getBasePath": func() string {
|
||||
return config.Get().Server.BasePath
|
||||
},
|
||||
@ -30,7 +40,7 @@ func loadTemplates() {
|
||||
return config.Get().Version
|
||||
},
|
||||
"getDbType": func() string {
|
||||
return strings.ToLower(config.Get().Db.Dialect)
|
||||
return strings.ToLower(config.Get().Db.Type)
|
||||
},
|
||||
"htmlSafe": func(html string) template.HTML {
|
||||
return template.HTML(html)
|
||||
@ -38,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)
|
||||
}
|
||||
@ -49,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)
|
||||
}
|
||||
@ -57,3 +83,22 @@ func loadTemplates() {
|
||||
templates[tplName] = tpl
|
||||
}
|
||||
}
|
||||
|
||||
func typeName(t uint8) string {
|
||||
if t == models.SummaryProject {
|
||||
return "project"
|
||||
}
|
||||
if t == models.SummaryLanguage {
|
||||
return "language"
|
||||
}
|
||||
if t == models.SummaryEditor {
|
||||
return "editor"
|
||||
}
|
||||
if t == models.SummaryOS {
|
||||
return "operating system"
|
||||
}
|
||||
if t == models.SummaryMachine {
|
||||
return "machine"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
@ -1,38 +1,55 @@
|
||||
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 {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
aliasSrvc services.IAliasService
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
aliasSrvc: aliasService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
userSrvc: userService,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
|
||||
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()
|
||||
@ -41,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()
|
||||
}
|
||||
@ -50,59 +133,107 @@ 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)
|
||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||
return http.StatusInternalServerError, "", "internal server error"
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
|
||||
return http.StatusOK, msg, ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
aliasKey := r.PostFormValue("key")
|
||||
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
|
||||
if err != nil {
|
||||
aliasType = 99 // nothing will be found later on
|
||||
}
|
||||
|
||||
if aliases, err := h.aliasSrvc.GetByUserAndKeyAndType(user.ID, aliasKey, uint8(aliasType)); err != nil {
|
||||
return http.StatusNotFound, "", "aliases not found"
|
||||
} else if err := h.aliasSrvc.DeleteMulti(aliases); err != nil {
|
||||
return http.StatusInternalServerError, "", "could not delete aliases"
|
||||
}
|
||||
|
||||
return http.StatusOK, "aliases deleted successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
aliasKey := r.PostFormValue("key")
|
||||
aliasValue := r.PostFormValue("value")
|
||||
aliasType, err := strconv.Atoi(r.PostFormValue("type"))
|
||||
if err != nil {
|
||||
aliasType = 99 // Alias.IsValid() will return false later on
|
||||
}
|
||||
|
||||
alias := &models.Alias{
|
||||
UserID: user.ID,
|
||||
Key: aliasKey,
|
||||
Value: aliasValue,
|
||||
Type: uint8(aliasType),
|
||||
}
|
||||
|
||||
if _, err := h.aliasSrvc.Create(alias); err != nil {
|
||||
// TODO: distinguish between bad request, conflict and server error
|
||||
return http.StatusBadRequest, "", "invalid input"
|
||||
}
|
||||
|
||||
return http.StatusOK, "alias added successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -110,27 +241,23 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
return http.StatusInternalServerError, "", "could not delete mapping"
|
||||
}
|
||||
|
||||
mapping := &models.LanguageMapping{
|
||||
ID: uint(id),
|
||||
UserID: user.ID,
|
||||
if mapping, err := h.languageMappingSrvc.GetById(uint(id)); err != nil || mapping == nil {
|
||||
return http.StatusNotFound, "", "mapping not found"
|
||||
} else if mapping.UserID != user.ID {
|
||||
return http.StatusForbidden, "", "not allowed to delete mapping"
|
||||
}
|
||||
|
||||
err = h.languageMappingSrvc.Delete(mapping)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
if err := h.languageMappingSrvc.Delete(&models.LanguageMapping{ID: uint(id)}); err != nil {
|
||||
return http.StatusInternalServerError, "", "could not delete mapping"
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
|
||||
return http.StatusOK, "mapping deleted successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -149,76 +276,146 @@ func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
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
|
||||
return http.StatusConflict, "", "mapping already exists"
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
|
||||
return http.StatusOK, "mapping added successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
|
||||
aliasMap := make(map[string][]*models.Alias)
|
||||
for _, a := range aliases {
|
||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||
if _, ok := aliasMap[k]; !ok {
|
||||
aliasMap[k] = []*models.Alias{a}
|
||||
} else {
|
||||
aliasMap[k] = append(aliasMap[k], a)
|
||||
}
|
||||
}
|
||||
|
||||
combinedAliases := make([]*view.SettingsVMCombinedAlias, 0)
|
||||
for _, l := range aliasMap {
|
||||
ca := &view.SettingsVMCombinedAlias{
|
||||
Key: l[0].Key,
|
||||
Type: l[0].Type,
|
||||
Values: make([]string, len(l)),
|
||||
}
|
||||
for i, a := range l {
|
||||
ca.Values[i] = a.Value
|
||||
}
|
||||
combinedAliases = append(combinedAliases, ca)
|
||||
}
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
|
@ -1,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.LanguageColors, summary),
|
||||
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
||||
ApiKey: user.ApiKey,
|
||||
}
|
||||
|
||||
templates[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"),
|
||||
|
27
routes/utils/summary_utils.go
Normal file
27
routes/utils/summary_utils.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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 aggregation jobs: %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
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"sync"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type AliasService struct {
|
||||
@ -22,7 +23,14 @@ func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
|
||||
|
||||
var userAliases sync.Map
|
||||
|
||||
func (srv *AliasService) LoadUserAliases(userId string) error {
|
||||
func (srv *AliasService) IsInitialized(userId string) bool {
|
||||
if _, ok := userAliases.Load(userId); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (srv *AliasService) InitializeUser(userId string) error {
|
||||
aliases, err := srv.repository.GetByUser(userId)
|
||||
if err == nil {
|
||||
userAliases.Store(userId, aliases)
|
||||
@ -30,9 +38,25 @@ func (srv *AliasService) LoadUserAliases(userId string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
aliases, err := srv.repository.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
if err := srv.LoadUserAliases(userId); err != nil {
|
||||
if err := srv.InitializeUser(userId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@ -46,9 +70,46 @@ func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, val
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) IsInitialized(userId string) bool {
|
||||
if _, ok := userAliases.Load(userId); ok {
|
||||
return true
|
||||
func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
|
||||
result, err := srv.repository.Insert(alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go srv.reinitUser(alias.UserID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) Delete(alias *models.Alias) error {
|
||||
if alias.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(alias.ID)
|
||||
go srv.reinitUser(alias.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
|
||||
ids := make([]uint, len(aliases))
|
||||
affectedUsers := make(map[string]bool)
|
||||
for i, a := range aliases {
|
||||
if a.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
affectedUsers[a.UserID] = true
|
||||
ids[i] = a.ID
|
||||
}
|
||||
|
||||
err := srv.repository.DeleteBatch(ids)
|
||||
|
||||
for k := range affectedUsers {
|
||||
go srv.reinitUser(k)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) reinitUser(userId string) {
|
||||
if err := srv.InitializeUser(userId); err != nil {
|
||||
logbuch.Error("error initializing user aliases – %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
@ -18,7 +19,7 @@ func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappin
|
||||
return &LanguageMappingService{
|
||||
config: config.Get(),
|
||||
repository: languageMappingsRepo,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,11 +64,15 @@ func (srv *LanguageMappingService) Create(mapping *models.LanguageMapping) (*mod
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error {
|
||||
if mapping.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(mapping.ID)
|
||||
srv.cache.Delete(mapping.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv LanguageMappingService) getServerMappings() map[string]string {
|
||||
return srv.config.App.CustomLanguages
|
||||
func (srv *LanguageMappingService) getServerMappings() map[string]string {
|
||||
// https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it
|
||||
return srv.config.App.GetCustomLanguages()
|
||||
}
|
||||
|
119
services/misc.go
Normal file
119
services/misc.go
Normal file
@ -0,0 +1,119 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"go.uber.org/atomic"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type MiscService struct {
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
keyValueService IKeyValueService
|
||||
jobCount atomic.Uint32
|
||||
}
|
||||
|
||||
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
|
||||
return &MiscService{
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
keyValueService: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
type CountTotalTimeJob struct {
|
||||
UserID string
|
||||
NumJobs int
|
||||
}
|
||||
|
||||
type CountTotalTimeResult struct {
|
||||
UserId string
|
||||
Total time.Duration
|
||||
}
|
||||
|
||||
func (srv *MiscService) ScheduleCountTotalTime() {
|
||||
// Run once initially
|
||||
if err := srv.runCountTotalTime(); err != nil {
|
||||
logbuch.Error("failed to run CountTotalTimeJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *MiscService) runCountTotalTime() error {
|
||||
jobs := make(chan *CountTotalTimeJob)
|
||||
results := make(chan *CountTotalTimeResult)
|
||||
|
||||
defer close(jobs)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.countTotalTimeWorker(jobs, results)
|
||||
}
|
||||
|
||||
go srv.persistTotalTimeWorker(results)
|
||||
|
||||
// generate the jobs
|
||||
if users, err := srv.userService.GetAll(); err == nil {
|
||||
for _, u := range users {
|
||||
jobs <- &CountTotalTimeJob{
|
||||
UserID: u.ID,
|
||||
NumJobs: len(users),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
|
||||
for job := range jobs {
|
||||
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve); err != nil {
|
||||
logbuch.Error("failed to count total for user %s: %v", job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully counted total for user %s", job.UserID)
|
||||
results <- &CountTotalTimeResult{
|
||||
UserId: job.UserID,
|
||||
Total: result.TotalTime(),
|
||||
}
|
||||
}
|
||||
if srv.jobCount.Inc() == uint32(job.NumJobs) {
|
||||
srv.jobCount.Store(0)
|
||||
close(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeResult) {
|
||||
var c int
|
||||
var total time.Duration
|
||||
for result := range results {
|
||||
total += result.Total
|
||||
c++
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalTime,
|
||||
Value: total.String(),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total time count: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalUsers,
|
||||
Value: strconv.Itoa(c),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total users count: %v", err)
|
||||
}
|
||||
}
|
@ -10,10 +10,19 @@ type IAggregationService interface {
|
||||
Run(map[string]bool) error
|
||||
}
|
||||
|
||||
type IMiscService interface {
|
||||
ScheduleCountTotalTime()
|
||||
}
|
||||
|
||||
type IAliasService interface {
|
||||
LoadUserAliases(string) error
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
Create(*models.Alias) (*models.Alias, error)
|
||||
Delete(*models.Alias) error
|
||||
DeleteMulti([]*models.Alias) error
|
||||
IsInitialized(string) bool
|
||||
InitializeUser(string) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
}
|
||||
|
||||
type IHeartbeatService interface {
|
||||
@ -52,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)
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
||||
}
|
||||
|
||||
// Initialize alias resolver service
|
||||
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
||||
if err := srv.aliasService.InitializeUser(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
||||
|
||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
@ -249,7 +249,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.AliasService.On("LoadUserAliases", TestUserId).Return(nil)
|
||||
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
|
7
static/assets/vendor/Chart.bundle.min.js
vendored
Normal file
7
static/assets/vendor/Chart.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/assets/vendor/roboto-latin-ext.woff2
vendored
Normal file
BIN
static/assets/vendor/roboto-latin-ext.woff2
vendored
Normal file
Binary file not shown.
BIN
static/assets/vendor/roboto-latin.woff2
vendored
Normal file
BIN
static/assets/vendor/roboto-latin.woff2
vendored
Normal file
Binary file not shown.
18
static/assets/vendor/roboto.css
vendored
Normal file
18
static/assets/vendor/roboto.css
vendored
Normal 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;
|
||||
}
|
1
static/assets/vendor/seedrandom.min.js
vendored
Normal file
1
static/assets/vendor/seedrandom.min.js
vendored
Normal 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
1
static/assets/vendor/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -18,6 +18,10 @@ func FormatDateHuman(date time.Time) string {
|
||||
return date.Format("Mon, 02 Jan 2006 15:04")
|
||||
}
|
||||
|
||||
func Add(i, j int) int {
|
||||
return i + j
|
||||
}
|
||||
|
||||
func ParseUserAgent(ua string) (string, string, error) {
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
groups := re.FindAllStringSubmatch(ua, -1)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -12,3 +12,10 @@ func Json(data interface{}) template.JS {
|
||||
}
|
||||
return template.JS(d)
|
||||
}
|
||||
|
||||
func ToRunes(s string) (r []string) {
|
||||
for _, c := range []rune(s) {
|
||||
r = append(r, string(c))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
1.18.0
|
||||
1.22.1
|
@ -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>
|
@ -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>
|
@ -11,21 +11,40 @@
|
||||
|
||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||
<div class="mx-1">
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑 Login️</a>
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑
|
||||
Login️</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex flex-col text-white">
|
||||
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span class="text-green-700">Your</span> Coding Time 🕓</h1>
|
||||
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks any anyone else.</p>
|
||||
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span
|
||||
class="text-green-700">Your</span> Coding Time 🕓</h1>
|
||||
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the
|
||||
time you have spent coding on different projects in different programming languages and more. Ideal for
|
||||
statistics freaks any anyone else.</p>
|
||||
|
||||
<p class="text-center text-gray-500 text-xl my-4">
|
||||
<span class="mr-1">💡 The system has tracked a total of </span>
|
||||
{{ range $d := .TotalHours | printf "%d" | toRunes }}
|
||||
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated once a day)">{{ $d }}</span>
|
||||
{{ end }}
|
||||
<span class="mx-1">hours of coding from</span>
|
||||
{{ range $d := .TotalUsers | printf "%d" | toRunes }}
|
||||
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated once a day)">{{ $d }}</span>
|
||||
{{ end }}
|
||||
<span class="ml-1">users.</span>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center mt-4 mb-8 space-x-2">
|
||||
<a href="login">
|
||||
<button type="button" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!</button>
|
||||
<button type="button"
|
||||
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">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it</button>
|
||||
<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>
|
||||
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
|
||||
@ -47,17 +66,24 @@
|
||||
<li>✅ Fancy statistics and plots</li>
|
||||
<li>✅ Cool badges for readmes</li>
|
||||
<li>✅ Intuitive REST API</li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer"
|
||||
class="underline">Prometheus</a> metrics via <a
|
||||
href="https://github.com/MacroPower/wakatime_exporter" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">exporter</a></li>
|
||||
<li>✅ Self-hosted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center space-x-2 mt-12">
|
||||
<img alt="License badge" src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Go version badge" src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Wakapi coding time badge" src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
|
||||
<img alt="License badge"
|
||||
src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Go version badge"
|
||||
src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Wakapi coding time badge"
|
||||
src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -20,7 +20,7 @@
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</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"
|
||||
type="text" id="username"
|
||||
name="username" placeholder="Enter your username" minlength="3" required autofocus>
|
||||
name="username" placeholder="Enter your username" minlength="1" required autofocus>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||
|
@ -5,6 +5,20 @@
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
|
||||
<style>
|
||||
.inline-bullet-list li a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.inline-bullet-list li:after {
|
||||
content: "•";
|
||||
}
|
||||
|
||||
.inline-bullet-list li:last-child:after {
|
||||
content: "";
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
@ -19,12 +33,39 @@
|
||||
|
||||
<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="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">
|
||||
Change Password
|
||||
</div>
|
||||
<div class="text-gray-500 text-xs mb-8">
|
||||
<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>
|
||||
<li class="hover:text-gray-400 mb-1">
|
||||
<a href="settings#apikey">Reset API Key</a>
|
||||
</li>
|
||||
<li class="hover:text-gray-400 mb-1">
|
||||
<a href="settings#aliases">Aliases</a>
|
||||
</li>
|
||||
<li class="hover:text-gray-400 mb-1">
|
||||
<a href="settings#languages">Languages & File Extensions</a>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form class="mt-10" action="settings/credentials" method="post">
|
||||
<div class="w-full my-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="password">
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
<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"
|
||||
@ -52,13 +93,16 @@
|
||||
</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">
|
||||
<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 client send heartbeats again.
|
||||
<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
|
||||
client send heartbeats again.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between float-right">
|
||||
@ -69,112 +113,267 @@
|
||||
</form>
|
||||
</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">
|
||||
Language Mappings
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700" id="aliases">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Aliases
|
||||
</h2>
|
||||
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.)
|
||||
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
|
||||
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
|
||||
class="inline-block mb-1 text-gray-500 italic">myapp-backend</span> are combined under a
|
||||
project called <span class="inline-block mb-1 text-gray-500 italic">myapp</span>.
|
||||
</div>
|
||||
|
||||
{{ if .LanguageMappings }}
|
||||
{{ range $i, $mapping := .LanguageMappings }}
|
||||
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1 text-align">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" >When filename ends in:</label>
|
||||
{{ $mapping.Extension }}
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" >Change the language to:</label>
|
||||
{{ $mapping.Language }}
|
||||
|
||||
<form class="float-right" action="settings/language_mappings/delete" method="post">
|
||||
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
|
||||
Remove
|
||||
{{ if .Aliases }}
|
||||
<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"
|
||||
style="line-height: 1.8">
|
||||
▸ All <span class="underline">{{ $alias.Type | typeName }}s</span> named
|
||||
{{ range $j, $value := $alias.Values }}
|
||||
<span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{- $value -}}</span>
|
||||
{{ if lt $j (add (len $alias.Values) -2) }}
|
||||
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
||||
{{ else if lt $j (add (len $alias.Values) -1) }}
|
||||
<span>{{- "or" -}}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
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="" 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>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1">
|
||||
No rules.
|
||||
</div>
|
||||
<div class="mb-8"></div>
|
||||
{{end}}
|
||||
|
||||
<form action="settings/language_mappings" method="post">
|
||||
<div class="inline-block justify-around mt-4 w-full">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="extension">When filename ends in:</label>
|
||||
<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 w-full py-1 px-3"
|
||||
type="text" id="extension"
|
||||
name="extension" placeholder=".py" minlength="1" required>
|
||||
</div>
|
||||
<div class="inline-block justify-around mt-4 w-full">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="language">Change the language to:</label>
|
||||
<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 w-full py-1 px-3"
|
||||
type="text" id="language"
|
||||
name="language" placeholder="Python" minlength="1" required>
|
||||
</div>
|
||||
<div class="flex justify-between float-right">
|
||||
<button type="submit" class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||
Add
|
||||
</button>
|
||||
<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"
|
||||
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 cursor-pointer">
|
||||
{{ range $i, $t := entityTypes }}
|
||||
<option value="{{ $t }}">{{ $t | typeName | capitalize }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<span class="mx-2">named</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"
|
||||
type="text" id="alias-value" style="width: 130px;"
|
||||
name="value" placeholder="myapp-frontend" minlength="1" required>
|
||||
<span class="mx-2">to</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"
|
||||
type="text" id="alias-key" style="width: 100px"
|
||||
name="key" placeholder="myapp" minlength="1" required>
|
||||
<div class="flex-grow flex justify-end">
|
||||
<button type="submit"
|
||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</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">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="languages">
|
||||
Languages & File Extensions
|
||||
</div>
|
||||
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can specify custom mapping from file extensions to programming languages, for instance a <span
|
||||
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
|
||||
class="inline-block mb-1 text-gray-500 italic">React</span> language.
|
||||
</div>
|
||||
|
||||
{{ if .LanguageMappings }}
|
||||
<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">
|
||||
▸ When filename ends in <span
|
||||
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Extension }}</span>
|
||||
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="" 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>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-8"></div>
|
||||
{{end}}
|
||||
|
||||
<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"
|
||||
type="text" id="extension" style="width: 70px"
|
||||
name="extension" placeholder=".py" minlength="1" required>
|
||||
<span class="mx-2">change language to</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"
|
||||
type="text" id="language" style="width: 100px"
|
||||
name="language" placeholder="Python" minlength="1" required>
|
||||
<div class="flex-grow flex justify-end">
|
||||
<button type="submit"
|
||||
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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="badges">
|
||||
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 endpoint.</p>
|
||||
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API
|
||||
endpoint.</p>
|
||||
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
|
||||
Status: public
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit"
|
||||
class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs"
|
||||
title="Disable support for badges to secure endpoint">
|
||||
Status: public
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" alt="Shields.io badge"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src"
|
||||
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"
|
||||
alt="Shields.io badge"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto"
|
||||
style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src"
|
||||
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"
|
||||
alt="Shields.io badge"/>
|
||||
</div>
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d" alt="Shields.io badge"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto"
|
||||
style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
|
||||
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span>
|
||||
to the URL to filter by project.</p>
|
||||
{{ else }}
|
||||
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800" rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
|
||||
Status: protected
|
||||
</button>
|
||||
</div>
|
||||
<p>You have the ability to create badges from your coding statistics using <a
|
||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||
access to the respective endpoint.</p>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit"
|
||||
class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs"
|
||||
title="Make endpoint public to enable badges">
|
||||
Status: protected
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</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">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
||||
⚠️ Danger Zone
|
||||
</div>
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
@ -182,23 +381,50 @@
|
||||
Regenerate summaries
|
||||
</h3>
|
||||
<p>
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to summaries on a per-day basis.
|
||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the database in a static fashion afterwards, unless you pass <span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span> with your request.
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
|
||||
summaries on a per-day basis.
|
||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the
|
||||
database in a static fashion afterwards, unless you pass <span
|
||||
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
|
||||
with your request.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you
|
||||
modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is case heartbeats were deleted after the respective summaries had been generated.
|
||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is
|
||||
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">
|
||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm" id="btn-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>
|
||||
@ -214,13 +440,21 @@
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const btnRegenerate = document.querySelector("#btn-regenerate-summaries")
|
||||
const btnRegenerate = document.querySelector('#btn-regenerate-summaries')
|
||||
const formRegenerate = document.querySelector('#form-regenerate-summaries')
|
||||
btnRegenerate.addEventListener('click', () => {
|
||||
if (confirm('Are you sure?')) {
|
||||
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" . }}
|
||||
|
@ -23,7 +23,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="border-b border-green-700">WakaTime</a>
|
||||
client tools.
|
||||
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank"
|
||||
Please refer to <a href="https://github.com/muety/wakapi#-client-setup" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-b border-green-700">this readme section</a> for instructions.
|
||||
You will be able to view you <strong>API Key</strong> once you log in.
|
||||
@ -35,7 +35,7 @@
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</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"
|
||||
type="text" id="username"
|
||||
name="username" placeholder="Choose a username" minlength="3" required autofocus>
|
||||
name="username" placeholder="Choose a username" minlength="1" required autofocus>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||
|
@ -158,6 +158,8 @@
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
const editorColors = {{ .EditorColors | json }}
|
||||
const osColors = {{ .OSColors | json }}
|
||||
|
||||
const wakapiData = {}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
|
Reference in New Issue
Block a user