mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
1c0477f861 | |||
28a3418ad5 | |||
c5db2c235f | |||
9cbddaeedf | |||
485dfe2888 | |||
78a26dbf3c | |||
b2c72c6420 | |||
6852494d36 | |||
305166ce68 | |||
400f25c23e | |||
3aacd3461d | |||
7e2460e1f0 | |||
57175ae7f8 | |||
5df0f48303 | |||
76a7cf7e80 | |||
7cae3c43d0 | |||
5fc87dd143 | |||
7329f6a34e | |||
3b96bd3723 | |||
2c7977cf63 | |||
782da0b49e | |||
ed9a7ccd5a | |||
9451848ad4 | |||
6c0145b149 | |||
a94092e31c | |||
52744dbcd0 | |||
cc11226eab | |||
8d073aaef2 | |||
d2f078443e | |||
c6e1651d9e | |||
630090e38a | |||
5394349c73 | |||
5cd3bf83a6 | |||
13cf911edf | |||
fe0f41cecb | |||
265080453a | |||
2f9b8fbcfe |
35
.github/workflows/docker.yml
vendored
35
.github/workflows/docker.yml
vendored
@ -20,39 +20,42 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
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
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push to Docker Hub
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
n1try/wakapi:${{ env.GIT_TAG }}
|
|
||||||
n1try/wakapi:latest
|
n1try/wakapi:latest
|
||||||
|
n1try/wakapi:${{ env.GIT_TAG }}
|
||||||
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=registry,ref=n1try/wakapi:buildcache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
cache-to: type=registry,ref=n1try/wakapi:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push to Docker Hub (Alpine)
|
- name: Build and push (Alpine)
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
file: Dockerfile.alpine
|
file: Dockerfile.alpine
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
n1try/wakapi:${{ env.GIT_TAG }}-alpine
|
|
||||||
n1try/wakapi:latest-alpine
|
n1try/wakapi:latest-alpine
|
||||||
|
n1try/wakapi:${{ env.GIT_TAG }}-alpine
|
||||||
|
ghcr.io/${{ github.repository }}:latest-alpine
|
||||||
|
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}-alpine
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max
|
||||||
|
13
.github/workflows/linux-build-on-release.yml
vendored
13
.github/workflows/linux-build-on-release.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Build Wakapi on Linux
|
name: Linux
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
name: Build
|
name: Linux - Build, Test & Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
@ -24,8 +24,15 @@ jobs:
|
|||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
|
run: go get
|
||||||
|
|
||||||
|
- name: Unit Tests
|
||||||
|
run: go test ./... -run ./...
|
||||||
|
|
||||||
|
- name: API Tests
|
||||||
run: |
|
run: |
|
||||||
go get
|
npm -g install newman
|
||||||
|
./testing/run_api_tests.sh
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: GO111MODULE=on go build -v .
|
run: GO111MODULE=on go build -v .
|
||||||
|
4
.github/workflows/win-build-on-release.yml
vendored
4
.github/workflows/win-build-on-release.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Build Wakapi on Windows
|
name: Windows
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
name: Build
|
name: Windows - Build & Release
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
|
6
.gitpod.yml
Normal file
6
.gitpod.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# List the start up tasks. Learn more https://www.gitpod.io/docs/config-start-tasks/
|
||||||
|
tasks:
|
||||||
|
- before: printf "\n[settings]\napi_key = $WAKA_TIME_API_KEY\napi_url = $WAKA_TIME_API_URL\n" > ~/.wakatime.cfg
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
visibility: public
|
115
README.md
115
README.md
@ -4,14 +4,11 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||||
|
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a>
|
||||||
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></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">
|
<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">
|
||||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
||||||
<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>
|
<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>
|
</p>
|
||||||
|
|
||||||
@ -35,21 +32,7 @@
|
|||||||
<img src="static/assets/images/screenshot.png" width="500px">
|
<img src="static/assets/images/screenshot.png" width="500px">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Table of Contents
|
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||||
* [User Survey](#-user-survey)
|
|
||||||
* [Features](#-features)
|
|
||||||
* [Roadmap](#-roadmap)
|
|
||||||
* [How to use](#-how-to-use)
|
|
||||||
* [Configuration Options](#-configuration-options)
|
|
||||||
* [API Endpoints](#-api-endpoints)
|
|
||||||
* [Integrations](#-integrations)
|
|
||||||
* [Best Practices](#-best-practices)
|
|
||||||
* [Tests](#-tests)
|
|
||||||
* [Developer Notes](#-developer-notes)
|
|
||||||
* [Support](#-support)
|
|
||||||
* [FAQs](#-faqs)
|
|
||||||
|
|
||||||
Further instructions can be found in the [Wiki](https://github.com/muety/wakapi/wiki).
|
|
||||||
|
|
||||||
## 📬 **User Survey**
|
## 📬 **User Survey**
|
||||||
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
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!
|
||||||
@ -74,7 +57,7 @@ Plans for the near future mainly include, besides usual improvements and bug fix
|
|||||||
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
||||||
|
|
||||||
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
|
### ☁️ 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).
|
If you want to try 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 ❕
|
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||||
|
|
||||||
@ -144,42 +127,43 @@ Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi
|
|||||||
## 🔧 Configuration Options
|
## 🔧 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.
|
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 |
|
| YAML Key / Env. Variable | Default | Description |
|
||||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
|-----------------------------|--------------|---------------------------------------------------------------------|
|
||||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
| `app.custom_languages` | - | Map from file endings to language names |
|
||||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
|
||||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||||
| `server.listen_socket` | `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
|
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||||
| `server.timeout_sec` | `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
|
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
|
||||||
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
|
||||||
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (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) |
|
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||||
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||||
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
||||||
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
|
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
||||||
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
|
| `security.cookie_max_age` /<br> `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
|
||||||
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
|
| `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
|
||||||
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
|
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
|
||||||
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
|
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
|
||||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
|
||||||
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
|
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
|
||||||
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||||
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
|
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||||
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
| `db.dialect` /<br> `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.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
||||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||||
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||||
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
||||||
| `mail.sender` | `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
|
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
||||||
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
|
||||||
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
|
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
||||||
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details |
|
| `mail.smtp.*` /<br> `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
|
||||||
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
| `mail.mailwhale.*` /<br> `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details |
|
||||||
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
||||||
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
||||||
| `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||||
|
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS`| `0.1` | Probability of tracing a heartbeats request in Sentry |
|
||||||
|
|
||||||
### Supported databases
|
### Supported databases
|
||||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||||
@ -189,9 +173,6 @@ Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of differ
|
|||||||
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
|
* [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_)
|
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
|
||||||
|
|
||||||
### Client-side proxy (`optional`)
|
|
||||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
|
||||||
|
|
||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
||||||
|
|
||||||
@ -306,10 +287,7 @@ To get a predictable environment, tests are run against a fresh and clean Wakapi
|
|||||||
# 1. sqlite (cli)
|
# 1. sqlite (cli)
|
||||||
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
|
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
|
||||||
|
|
||||||
# 2. screen
|
# 2. newman
|
||||||
$ sudo apt install screen # Fedora: sudo dnf install screen
|
|
||||||
|
|
||||||
# 3. newman
|
|
||||||
$ npm install -g newman
|
$ npm install -g newman
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -335,9 +313,6 @@ $ node scripts/bundle_icons.js
|
|||||||
|
|
||||||
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
||||||
|
|
||||||
## 🙏 Support
|
|
||||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
|
||||||
|
|
||||||
## ❔ FAQs
|
## ❔ 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.
|
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.
|
||||||
|
|
||||||
@ -367,7 +342,7 @@ All data is cached locally on your machine and sent in batches once you're onlin
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>How did Wakapi come about?</b></summary>
|
<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!
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@ -411,6 +386,12 @@ It is unclear how to handle the three minutes in between. Did the developer do a
|
|||||||
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between Wakapi and WakaTime.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## 🌳 Treeware
|
||||||
|
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats.
|
||||||
|
|
||||||
|
## 👏 Support
|
||||||
|
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So 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 my motivation to keep improving Wakapi!
|
||||||
|
|
||||||
## 🙏 Thanks
|
## 🙏 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.
|
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
|
||||||
|
|
||||||
|
@ -21,6 +21,10 @@ app:
|
|||||||
jsx: JSX
|
jsx: JSX
|
||||||
svelte: Svelte
|
svelte: Svelte
|
||||||
|
|
||||||
|
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||||
|
# available variable placeholders are: username, username_hash, email, email_hash
|
||||||
|
avatar_url_template: https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg
|
||||||
|
|
||||||
db:
|
db:
|
||||||
host: # leave blank when using sqlite3
|
host: # leave blank when using sqlite3
|
||||||
port: # leave blank when using sqlite3
|
port: # leave blank when using sqlite3
|
||||||
@ -39,6 +43,7 @@ security:
|
|||||||
cookie_max_age: 172800
|
cookie_max_age: 172800
|
||||||
allow_signup: true
|
allow_signup: true
|
||||||
expose_metrics: false
|
expose_metrics: false
|
||||||
|
enable_proxy: false # only intended for production instance at wakapi.dev
|
||||||
|
|
||||||
sentry:
|
sentry:
|
||||||
dsn: # leave blank to disable sentry integration
|
dsn: # leave blank to disable sentry integration
|
||||||
|
@ -62,19 +62,21 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
|||||||
var env string
|
var env string
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||||
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||||
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||||
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type securityConfig struct {
|
type securityConfig struct {
|
||||||
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
|
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
|
||||||
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
|
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
|
||||||
|
EnableProxy bool `yaml:"enable_proxy" default:"false" env:"WAKAPI_ENABLE_PROXY"` // only intended for production instance at wakapi.dev
|
||||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||||
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
||||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||||
@ -237,6 +239,10 @@ func (c *appConfig) GetWeeklyReportTime() string {
|
|||||||
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dbConfig) IsSQLite() bool {
|
||||||
|
return c.Dialect == "sqlite3"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *serverConfig) GetPublicUrl() string {
|
func (c *serverConfig) GetPublicUrl() string {
|
||||||
return strings.TrimSuffix(c.PublicUrl, "/")
|
return strings.TrimSuffix(c.PublicUrl, "/")
|
||||||
}
|
}
|
||||||
@ -363,6 +369,10 @@ func Load(version string) *Config {
|
|||||||
if config.Db.MaxConn <= 0 {
|
if config.Db.MaxConn <= 0 {
|
||||||
logbuch.Fatal("you must allow at least one database connection")
|
logbuch.Fatal("you must allow at least one database connection")
|
||||||
}
|
}
|
||||||
|
if config.Db.MaxConn > 1 && config.Db.IsSQLite() {
|
||||||
|
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
|
||||||
|
config.Db.MaxConn = 1
|
||||||
|
}
|
||||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
14
main.go
14
main.go
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/routes/relay"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -117,9 +118,12 @@ func main() {
|
|||||||
// Connect to database
|
// Connect to database
|
||||||
var err error
|
var err error
|
||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||||
if config.Db.Dialect == "sqlite3" {
|
if config.Db.IsSQLite() {
|
||||||
db.Raw("PRAGMA foreign_keys = ON;")
|
db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
db.DisableForeignKeyConstraintWhenMigrating = true
|
if !utils.IsCleanDB(db) && !utils.HasConstraints(db) {
|
||||||
|
db.DisableForeignKeyConstraintWhenMigrating = true
|
||||||
|
logbuch.Warn("using existing sqlite database without foreign key constraints and no ability to migrate, functionality may be limited")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
@ -191,6 +195,9 @@ func main() {
|
|||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
|
||||||
|
// Other Handlers
|
||||||
|
relayHandler := relay.NewRelayHandler()
|
||||||
|
|
||||||
// Setup Routers
|
// Setup Routers
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
rootRouter := router.PathPrefix("/").Subrouter()
|
rootRouter := router.PathPrefix("/").Subrouter()
|
||||||
@ -219,6 +226,7 @@ func main() {
|
|||||||
imprintHandler.RegisterRoutes(rootRouter)
|
imprintHandler.RegisterRoutes(rootRouter)
|
||||||
summaryHandler.RegisterRoutes(rootRouter)
|
summaryHandler.RegisterRoutes(rootRouter)
|
||||||
settingsHandler.RegisterRoutes(rootRouter)
|
settingsHandler.RegisterRoutes(rootRouter)
|
||||||
|
relayHandler.RegisterRoutes(rootRouter)
|
||||||
|
|
||||||
// API route registrations
|
// API route registrations
|
||||||
summaryApiHandler.RegisterRoutes(apiRouter)
|
summaryApiHandler.RegisterRoutes(apiRouter)
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
package middlewares
|
package middlewares
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
)
|
||||||
"strings"
|
|
||||||
|
const (
|
||||||
|
// queryApiKey is the query parameter name for api key.
|
||||||
|
queryApiKey = "api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errEmptyKey = fmt.Errorf("the api_key is empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthenticateMiddleware struct {
|
type AuthenticateMiddleware struct {
|
||||||
@ -45,7 +56,10 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
|||||||
user, err := m.tryGetUserByCookie(r)
|
user, err := m.tryGetUserByCookie(r)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
user, err = m.tryGetUserByApiKey(r)
|
user, err = m.tryGetUserByApiKeyHeader(r)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
user, err = m.tryGetUserByApiKeyQuery(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
@ -77,7 +91,7 @@ func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
|
func (m *AuthenticateMiddleware) tryGetUserByApiKeyHeader(r *http.Request) (*models.User, error) {
|
||||||
key, err := utils.ExtractBearerAuth(r)
|
key, err := utils.ExtractBearerAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -92,6 +106,20 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*models.User, error) {
|
||||||
|
key := r.URL.Query().Get(queryApiKey)
|
||||||
|
var user *models.User
|
||||||
|
userKey := strings.TrimSpace(key)
|
||||||
|
if userKey == "" {
|
||||||
|
return nil, errEmptyKey
|
||||||
|
}
|
||||||
|
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) {
|
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
username, err := utils.ExtractCookieAuth(r, m.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -3,14 +3,16 @@ package middlewares
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/muety/wakapi/mocks"
|
"github.com/muety/wakapi/mocks"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
|
||||||
testApiKey := "z5uig69cn9ut93n"
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||||
testUser := &models.User{ApiKey: testApiKey}
|
testUser := &models.User{ApiKey: testApiKey}
|
||||||
@ -26,13 +28,13 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
|||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, testUser, result)
|
assert.Equal(t, testUser, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
|
||||||
testApiKey := "z5uig69cn9ut93n"
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||||
|
|
||||||
@ -47,10 +49,55 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
|||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock)
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Nil(t, result)
|
assert.Nil(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
|
||||||
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
|
testUser := &models.User{ApiKey: testApiKey}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("api_key", testApiKey)
|
||||||
|
mockRequest := &http.Request{
|
||||||
|
URL: &url.URL{
|
||||||
|
RawQuery: params.Encode(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
|
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||||
|
|
||||||
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
|
result, err := sut.tryGetUserByApiKeyQuery(mockRequest)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, testUser, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Invalid(t *testing.T) {
|
||||||
|
testApiKey := "z5uig69cn9ut93n"
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("token", testApiKey)
|
||||||
|
mockRequest := &http.Request{
|
||||||
|
URL: &url.URL{
|
||||||
|
RawQuery: params.Encode(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
|
|
||||||
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
|
result, actualErr := sut.tryGetUserByApiKeyQuery(mockRequest)
|
||||||
|
|
||||||
|
assert.Error(t, actualErr)
|
||||||
|
assert.Equal(t, errEmptyKey, actualErr)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: somehow test cookie auth function
|
// TODO: somehow test cookie auth function
|
||||||
|
@ -24,6 +24,11 @@ func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*mode
|
|||||||
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUserGroupedInverted(s string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
|
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
args := p.Called(l)
|
args := p.Called(l)
|
||||||
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||||
|
@ -53,6 +53,7 @@ type SummaryViewModel struct {
|
|||||||
*Summary
|
*Summary
|
||||||
*SummaryParams
|
*SummaryParams
|
||||||
User *User
|
User *User
|
||||||
|
AvatarURL string
|
||||||
LanguageColors map[string]string
|
LanguageColors map[string]string
|
||||||
EditorColors map[string]string
|
EditorColors map[string]string
|
||||||
OSColors map[string]string
|
OSColors map[string]string
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -92,6 +95,18 @@ func (u *User) TZOffset() time.Duration {
|
|||||||
return time.Duration(offset * int(time.Second))
|
return time.Duration(offset * int(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) AvatarURL(urlTemplate string) string {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{username}", u.ID)
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{email}", u.Email)
|
||||||
|
if strings.Contains(urlTemplate, "{username_hash}") {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{username_hash}", fmt.Sprintf("%x", md5.Sum([]byte(u.ID))))
|
||||||
|
}
|
||||||
|
if strings.Contains(urlTemplate, "{email_hash}") {
|
||||||
|
urlTemplate = strings.ReplaceAll(urlTemplate, "{email_hash}", fmt.Sprintf("%x", md5.Sum([]byte(u.Email))))
|
||||||
|
}
|
||||||
|
return urlTemplate
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
|
@ -9,11 +9,12 @@ import (
|
|||||||
func TestUser_TZ(t *testing.T) {
|
func TestUser_TZ(t *testing.T) {
|
||||||
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
|
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
|
||||||
pst, _ := time.LoadLocation("America/Los_Angeles")
|
pst, _ := time.LoadLocation("America/Los_Angeles")
|
||||||
_, offset := time.Now().Zone()
|
_, offset1 := time.Now().Zone()
|
||||||
|
_, offset2 := time.Now().In(pst).Zone()
|
||||||
|
|
||||||
assert.Equal(t, time.Local, sut1.TZ())
|
assert.Equal(t, time.Local, sut1.TZ())
|
||||||
assert.Equal(t, pst, sut2.TZ())
|
assert.Equal(t, pst, sut2.TZ())
|
||||||
|
|
||||||
assert.InDelta(t, time.Duration(offset*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||||
assert.InDelta(t, time.Duration(-7*int(time.Hour)), sut2.TZOffset(), float64(1*time.Second))
|
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,9 @@ func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{UserID: userId}).
|
Where(&models.Alias{UserID: userId}).
|
||||||
Find(&aliases).Error; err != nil {
|
Find(&aliases).Error; err != nil {
|
||||||
@ -34,6 +37,9 @@ func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
@ -47,6 +53,9 @@ func (r *AliasRepository) GetByUserAndKey(userId, key string) ([]*models.Alias,
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||||
var aliases []*models.Alias
|
var aliases []*models.Alias
|
||||||
|
if userId == "" {
|
||||||
|
return aliases, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
@ -61,6 +70,9 @@ func (r *AliasRepository) GetByUserAndKeyAndType(userId, key string, summaryType
|
|||||||
|
|
||||||
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
func (r *AliasRepository) GetByUserAndTypeAndValue(userId string, summaryType uint8, value string) (*models.Alias, error) {
|
||||||
alias := &models.Alias{}
|
alias := &models.Alias{}
|
||||||
|
if userId == "" {
|
||||||
|
return nil, errors.New("invalid input")
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Alias{
|
Where(&models.Alias{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
|
@ -34,6 +34,9 @@ func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, e
|
|||||||
|
|
||||||
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||||
var mappings []*models.LanguageMapping
|
var mappings []*models.LanguageMapping
|
||||||
|
if userId == "" {
|
||||||
|
return mappings, nil
|
||||||
|
}
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.LanguageMapping{UserID: userId}).
|
Where(&models.LanguageMapping{UserID: userId}).
|
||||||
Find(&mappings).Error; err != nil {
|
Find(&mappings).Error; err != nil {
|
||||||
|
@ -33,6 +33,9 @@ func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
||||||
|
if userId == "" {
|
||||||
|
return []*models.ProjectLabel{}, nil
|
||||||
|
}
|
||||||
var labels []*models.ProjectLabel
|
var labels []*models.ProjectLabel
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.ProjectLabel{UserID: userId}).
|
Where(&models.ProjectLabel{UserID: userId}).
|
||||||
|
@ -35,6 +35,9 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("invalid input")
|
||||||
|
}
|
||||||
u := &models.User{}
|
u := &models.User{}
|
||||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
|
@ -41,7 +41,7 @@ func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI"
|
// @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /plugins/errors [post]
|
// @Router /api/plugins/errors [post]
|
||||||
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||||
var diagnostics models.Diagnostics
|
var diagnostics models.Diagnostics
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Tags misc
|
// @Tags misc
|
||||||
// @Produce plain
|
// @Produce plain
|
||||||
// @Success 200 {string} string
|
// @Success 200 {string} string
|
||||||
// @Router /health [get]
|
// @Router /api/health [get]
|
||||||
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
var dbStatus int
|
var dbStatus int
|
||||||
if sqlDb, err := h.db.DB(); err == nil {
|
if sqlDb, err := h.db.DB(); err == nil {
|
||||||
|
@ -60,7 +60,7 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /heartbeat [post]
|
// @Router /api/heartbeat [post]
|
||||||
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -182,7 +182,7 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
|
|||||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /v1/users/{user}/heartbeats [post]
|
// @Router /api/v1/users/{user}/heartbeats [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias1() {}
|
func (h *HeartbeatApiHandler) postAlias1() {}
|
||||||
|
|
||||||
// @Summary Push a new heartbeat
|
// @Summary Push a new heartbeat
|
||||||
@ -192,7 +192,7 @@ func (h *HeartbeatApiHandler) postAlias1() {}
|
|||||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
|
// @Router /api/compat/wakatime/v1/users/{user}/heartbeats [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias2() {}
|
func (h *HeartbeatApiHandler) postAlias2() {}
|
||||||
|
|
||||||
// @Summary Push a new heartbeat
|
// @Summary Push a new heartbeat
|
||||||
@ -202,7 +202,7 @@ func (h *HeartbeatApiHandler) postAlias2() {}
|
|||||||
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /users/{user}/heartbeats [post]
|
// @Router /api/users/{user}/heartbeats [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias3() {}
|
func (h *HeartbeatApiHandler) postAlias3() {}
|
||||||
|
|
||||||
// @Summary Push new heartbeats
|
// @Summary Push new heartbeats
|
||||||
@ -212,7 +212,7 @@ func (h *HeartbeatApiHandler) postAlias3() {}
|
|||||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /heartbeats [post]
|
// @Router /api/heartbeats [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias4() {}
|
func (h *HeartbeatApiHandler) postAlias4() {}
|
||||||
|
|
||||||
// @Summary Push new heartbeats
|
// @Summary Push new heartbeats
|
||||||
@ -222,7 +222,7 @@ func (h *HeartbeatApiHandler) postAlias4() {}
|
|||||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /v1/users/{user}/heartbeats.bulk [post]
|
// @Router /api/v1/users/{user}/heartbeats.bulk [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias5() {}
|
func (h *HeartbeatApiHandler) postAlias5() {}
|
||||||
|
|
||||||
// @Summary Push new heartbeats
|
// @Summary Push new heartbeats
|
||||||
@ -232,7 +232,7 @@ func (h *HeartbeatApiHandler) postAlias5() {}
|
|||||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
|
// @Router /api/compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias6() {}
|
func (h *HeartbeatApiHandler) postAlias6() {}
|
||||||
|
|
||||||
// @Summary Push new heartbeats
|
// @Summary Push new heartbeats
|
||||||
@ -242,5 +242,5 @@ func (h *HeartbeatApiHandler) postAlias6() {}
|
|||||||
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /users/{user}/heartbeats.bulk [post]
|
// @Router /api/users/{user}/heartbeats.bulk [post]
|
||||||
func (h *HeartbeatApiHandler) postAlias7() {}
|
func (h *HeartbeatApiHandler) postAlias7() {}
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -34,6 +35,10 @@ const (
|
|||||||
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
|
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
|
||||||
DescAdminTotalUsers = "Total number of registered users."
|
DescAdminTotalUsers = "Total number of registered users."
|
||||||
DescAdminActiveUsers = "Number of active users."
|
DescAdminActiveUsers = "Number of active users."
|
||||||
|
|
||||||
|
DescMemAllocTotal = "Total number of bytes allocated for heap"
|
||||||
|
DescMemSysTotal = "Total number of bytes obtained from the OS"
|
||||||
|
DescGoroutines = "Total number of running goroutines"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MetricsHandler struct {
|
type MetricsHandler struct {
|
||||||
@ -208,6 +213,31 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runtime metrics
|
||||||
|
var memStats runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
|
metrics = append(metrics, &mm.CounterMetric{
|
||||||
|
Name: MetricsPrefix + "_goroutines_total",
|
||||||
|
Desc: DescGoroutines,
|
||||||
|
Value: runtime.NumGoroutine(),
|
||||||
|
Labels: []mm.Label{},
|
||||||
|
})
|
||||||
|
|
||||||
|
metrics = append(metrics, &mm.CounterMetric{
|
||||||
|
Name: MetricsPrefix + "_mem_alloc_total",
|
||||||
|
Desc: DescMemAllocTotal,
|
||||||
|
Value: int(memStats.Alloc),
|
||||||
|
Labels: []mm.Label{},
|
||||||
|
})
|
||||||
|
|
||||||
|
metrics = append(metrics, &mm.CounterMetric{
|
||||||
|
Name: MetricsPrefix + "_mem_sys_total",
|
||||||
|
Desc: DescMemSysTotal,
|
||||||
|
Value: int(memStats.Sys),
|
||||||
|
Labels: []mm.Label{},
|
||||||
|
})
|
||||||
|
|
||||||
return &metrics, nil
|
return &metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
|
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} models.Summary
|
// @Success 200 {object} models.Summary
|
||||||
// @Router /summary [get]
|
// @Router /api/summary [get]
|
||||||
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -50,7 +50,7 @@ func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||||
// @Param filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
|
// @Param filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
|
||||||
// @Success 200 {object} v1.BadgeData
|
// @Success 200 {object} v1.BadgeData
|
||||||
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
|
// @Router /api/compat/shields/v1/{user}/{interval}/{filter} [get]
|
||||||
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
intervalReg := regexp.MustCompile(intervalPattern)
|
intervalReg := regexp.MustCompile(intervalPattern)
|
||||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||||
|
@ -44,7 +44,7 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.AllTimeViewModel
|
// @Success 200 {object} v1.AllTimeViewModel
|
||||||
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
|
// @Router /api/compat/wakatime/v1/users/{user}/all_time_since_today [get]
|
||||||
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ func (h *ProjectsHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param q query string true "Query to filter projects by"
|
// @Param q query string true "Query to filter projects by"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.ProjectsViewModel
|
// @Success 200 {object} v1.ProjectsViewModel
|
||||||
// @Router /compat/wakatime/v1/users/{user}/projects [get]
|
// @Router /api/compat/wakatime/v1/users/{user}/projects [get]
|
||||||
func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -50,7 +50,7 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param range path string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
// @Param range path string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.StatsViewModel
|
// @Success 200 {object} v1.StatsViewModel
|
||||||
// @Router /compat/wakatime/v1/users/{user}/stats/{range} [get]
|
// @Router /api/compat/wakatime/v1/users/{user}/stats/{range} [get]
|
||||||
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
var vars = mux.Vars(r)
|
var vars = mux.Vars(r)
|
||||||
var authorizedUser, requestedUser *models.User
|
var authorizedUser, requestedUser *models.User
|
||||||
|
@ -51,8 +51,8 @@ func (h *StatusBarHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.StatusBarViewModel
|
// @Success 200 {object} StatusBarViewModel
|
||||||
// @Router /users/{user}/statusbar/today [get]
|
// @Router /api/users/{user}/statusbar/today [get]
|
||||||
func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -53,7 +53,7 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param end query string false "End date (e.g. '2021-02-08')"
|
// @Param end query string false "End date (e.g. '2021-02-08')"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.SummariesViewModel
|
// @Success 200 {object} v1.SummariesViewModel
|
||||||
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
|
// @Router /api/compat/wakatime/v1/users/{user}/summaries [get]
|
||||||
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -41,7 +41,7 @@ func (h *UsersHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// @Param user path string true "User ID to fetch (or 'current')"
|
// @Param user path string true "User ID to fetch (or 'current')"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 200 {object} v1.UserViewModel
|
// @Success 200 {object} v1.UserViewModel
|
||||||
// @Router /compat/wakatime/v1/users/{user} [get]
|
// @Router /api/compat/wakatime/v1/users/{user} [get]
|
||||||
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
120
routes/relay/relay.go
Normal file
120
routes/relay/relay.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const targetUrlHeader = "X-Target-URL"
|
||||||
|
const pathMatcherPattern = `^/api/(heartbeat|heartbeats|summary|users|v1/users|compat/wakatime)`
|
||||||
|
|
||||||
|
type RelayHandler struct {
|
||||||
|
config *conf.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRelayHandler() *RelayHandler {
|
||||||
|
return &RelayHandler{
|
||||||
|
config: conf.Get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type filteringMiddleware struct {
|
||||||
|
handler http.Handler
|
||||||
|
pathMatcher *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilteringMiddleware() func(http.Handler) http.Handler {
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return &filteringMiddleware{
|
||||||
|
handler: h,
|
||||||
|
pathMatcher: regexp.MustCompile(pathMatcherPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *filteringMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetUrl, err := url.Parse(r.Header.Get(targetUrlHeader))
|
||||||
|
if err != nil || !m.pathMatcher.MatchString(targetUrl.Path) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RelayHandler) RegisterRoutes(router *mux.Router) {
|
||||||
|
if !h.config.Security.EnableProxy {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r := router.PathPrefix("/relay").Subrouter()
|
||||||
|
r.Use(newFilteringMiddleware())
|
||||||
|
r.Path("").HandlerFunc(h.Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RelayHandler) Any(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetUrl, err := url.Parse(r.Header.Get(targetUrlHeader))
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := httputil.ReverseProxy{
|
||||||
|
Director: func(r *http.Request) {
|
||||||
|
r.URL = targetUrl
|
||||||
|
r.Host = targetUrl.Host
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Proxy an GET API request to another Wakapi instance
|
||||||
|
// @ID relay-get
|
||||||
|
// @Tags relay
|
||||||
|
// @Param X-Target-URL header string true "Original URL to perform the request to"
|
||||||
|
// @Failure 403 {string} string "Returned if request path is not whitelisted"
|
||||||
|
// @Failure 502 {string} string "Returned if upstream host is down"
|
||||||
|
// @Router /relay [get]
|
||||||
|
func (h *RelayHandler) alias1() {}
|
||||||
|
|
||||||
|
// @Summary Proxy an POST API request to another Wakapi instance
|
||||||
|
// @ID relay-post
|
||||||
|
// @Tags relay
|
||||||
|
// @Param X-Target-URL header string true "Original URL to perform the request to"
|
||||||
|
// @Failure 403 {string} string "Returned if request path is not whitelisted"
|
||||||
|
// @Failure 502 {string} string "Returned if upstream host is down"
|
||||||
|
// @Router /relay [post]
|
||||||
|
func (h *RelayHandler) alias2() {}
|
||||||
|
|
||||||
|
// @Summary Proxy an PUT API request to another Wakapi instance
|
||||||
|
// @ID relay-put
|
||||||
|
// @Tags relay
|
||||||
|
// @Param X-Target-URL header string true "Original URL to perform the request to"
|
||||||
|
// @Failure 403 {string} string "Returned if request path is not whitelisted"
|
||||||
|
// @Failure 502 {string} string "Returned if upstream host is down"
|
||||||
|
// @Router /relay [put]
|
||||||
|
func (h *RelayHandler) alias3() {}
|
||||||
|
|
||||||
|
// @Summary Proxy an PATCH API request to another Wakapi instance
|
||||||
|
// @ID relay-patch
|
||||||
|
// @Tags relay
|
||||||
|
// @Param X-Target-URL header string true "Original URL to perform the request to"
|
||||||
|
// @Failure 403 {string} string "Returned if request path is not whitelisted"
|
||||||
|
// @Failure 502 {string} string "Returned if upstream host is down"
|
||||||
|
// @Router /relay [patch]
|
||||||
|
func (h *RelayHandler) alias4() {}
|
||||||
|
|
||||||
|
// @Summary Proxy an DELETE API request to another Wakapi instance
|
||||||
|
// @ID relay-delete
|
||||||
|
// @Tags relay
|
||||||
|
// @Param X-Target-URL header string true "Original URL to perform the request to"
|
||||||
|
// @Failure 403 {string} string "Returned if request path is not whitelisted"
|
||||||
|
// @Failure 502 {string} string "Returned if upstream host is down"
|
||||||
|
// @Router /relay [delete]
|
||||||
|
func (h *RelayHandler) alias5() {}
|
@ -52,6 +52,9 @@ func DefaultTemplateFuncs() template.FuncMap {
|
|||||||
"htmlSafe": func(html string) template.HTML {
|
"htmlSafe": func(html string) template.HTML {
|
||||||
return template.HTML(html)
|
return template.HTML(html)
|
||||||
},
|
},
|
||||||
|
"avatarUrlTemplate": func() string {
|
||||||
|
return config.Get().App.AvatarURLTemplate
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,27 +354,23 @@ func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
labelKey := r.PostFormValue("key")
|
labelKey := r.PostFormValue("key") // label key
|
||||||
labelValue := r.PostFormValue("value")
|
labelValue := r.PostFormValue("value") // project key
|
||||||
|
|
||||||
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
labels, err := h.projectLabelSrvc.GetByUser(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, "", "could not delete label"
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
}
|
}
|
||||||
|
|
||||||
if projectLabels, ok := labelMap[labelKey]; ok {
|
for _, l := range labels {
|
||||||
for _, l := range projectLabels {
|
if l.Label == labelKey && l.ProjectKey == labelValue {
|
||||||
if l.Label == labelValue {
|
if err := h.projectLabelSrvc.Delete(l); err != nil {
|
||||||
if err := h.projectLabelSrvc.Delete(l); err != nil {
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
return http.StatusInternalServerError, "", "could not delete label"
|
|
||||||
}
|
|
||||||
return http.StatusOK, "label deleted successfully", ""
|
|
||||||
}
|
}
|
||||||
|
return http.StatusOK, "label deleted successfully", ""
|
||||||
}
|
}
|
||||||
return http.StatusNotFound, "", "label not found"
|
|
||||||
} else {
|
|
||||||
return http.StatusNotFound, "", "project not found"
|
|
||||||
}
|
}
|
||||||
|
return http.StatusNotFound, "", "label not found"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
@ -651,7 +647,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// labels
|
// labels
|
||||||
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
||||||
return &view.SettingsViewModel{Error: criticalError}
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
@ -660,11 +656,11 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
||||||
for _, l := range labelMap {
|
for _, l := range labelMap {
|
||||||
cl := &view.SettingsVMCombinedLabel{
|
cl := &view.SettingsVMCombinedLabel{
|
||||||
Key: l[0].ProjectKey,
|
Key: l[0].Label,
|
||||||
Values: make([]string, len(l)),
|
Values: make([]string, len(l)),
|
||||||
}
|
}
|
||||||
for i, l1 := range l {
|
for i, l1 := range l {
|
||||||
cl.Values[i] = l1.Label
|
cl.Values[i] = l1.ProjectKey
|
||||||
}
|
}
|
||||||
combinedLabels = append(combinedLabels, cl)
|
combinedLabels = append(combinedLabels, cl)
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,8 @@ let icons = [
|
|||||||
'twemoji:gear',
|
'twemoji:gear',
|
||||||
'eva:corner-right-down-fill',
|
'eva:corner-right-down-fill',
|
||||||
'bi:heart-fill',
|
'bi:heart-fill',
|
||||||
|
'fxemoji:running',
|
||||||
|
'ic:round-person'
|
||||||
]
|
]
|
||||||
|
|
||||||
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))
|
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
@ -16,20 +17,20 @@ import (
|
|||||||
type HeartbeatService struct {
|
type HeartbeatService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
cache2 *cache.Cache
|
|
||||||
eventBus *hub.Hub
|
eventBus *hub.Hub
|
||||||
repository repositories.IHeartbeatRepository
|
repository repositories.IHeartbeatRepository
|
||||||
languageMappingSrvc ILanguageMappingService
|
languageMappingSrvc ILanguageMappingService
|
||||||
|
entityCacheLock *sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||||
srv := &HeartbeatService{
|
srv := &HeartbeatService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||||
cache2: cache.New(cache.NoExpiration, cache.NoExpiration),
|
|
||||||
eventBus: config.EventBus(),
|
eventBus: config.EventBus(),
|
||||||
repository: heartbeatRepo,
|
repository: heartbeatRepo,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
|
entityCacheLock: &sync.RWMutex{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// using event hub is an unnecessary indirection here, however, we might
|
// using event hub is an unnecessary indirection here, however, we might
|
||||||
@ -48,7 +49,7 @@ func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, langua
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||||
srv.updateEntityUserCacheByHeartbeat(heartbeat)
|
go srv.updateEntityUserCacheByHeartbeat(heartbeat)
|
||||||
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
|
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +63,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
|||||||
filteredHeartbeats = append(filteredHeartbeats, hb)
|
filteredHeartbeats = append(filteredHeartbeats, hb)
|
||||||
hashes[hb.Hash] = true
|
hashes[hb.Hash] = true
|
||||||
}
|
}
|
||||||
srv.updateEntityUserCacheByHeartbeat(hb)
|
go srv.updateEntityUserCacheByHeartbeat(hb)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := srv.repository.InsertBatch(filteredHeartbeats)
|
err := srv.repository.InsertBatch(filteredHeartbeats)
|
||||||
@ -147,7 +148,9 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
|||||||
|
|
||||||
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
||||||
if results, found := srv.cache2.Get(cacheKey); found {
|
if results, found := srv.cache.Get(cacheKey); found {
|
||||||
|
srv.entityCacheLock.RLock()
|
||||||
|
defer srv.entityCacheLock.RUnlock()
|
||||||
return utils.SetToStrings(results.(map[string]bool)), nil
|
return utils.SetToStrings(results.(map[string]bool)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +166,7 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.cache2.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
|
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.NoExpiration)
|
||||||
return filtered, nil
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,21 +193,27 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
|
|||||||
|
|
||||||
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
||||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
||||||
if entities, found := srv.cache2.Get(cacheKey); found {
|
if entities, found := srv.cache.Get(cacheKey); found {
|
||||||
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
|
entitySet := entities.(map[string]bool)
|
||||||
|
|
||||||
|
srv.entityCacheLock.Lock()
|
||||||
|
defer srv.entityCacheLock.Unlock()
|
||||||
|
|
||||||
|
if _, ok := entitySet[entityKey]; !ok {
|
||||||
|
entitySet[entityKey] = true
|
||||||
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
||||||
// -> invalidate cache
|
// -> update cache instead of just invalidating it, because rebuilding is expensive here
|
||||||
srv.cache2.Delete(cacheKey)
|
srv.cache.Set(cacheKey, entitySet, cache.NoExpiration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
|
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
|
||||||
srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
|
go srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
|
||||||
srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
|
go srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
|
||||||
srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
|
go srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
|
||||||
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
|
go srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
|
||||||
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
|
go srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) notifyBatch(heartbeats []*models.Heartbeat) {
|
func (srv *HeartbeatService) notifyBatch(heartbeats []*models.Heartbeat) {
|
||||||
|
@ -44,21 +44,37 @@ func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||||
labels := make(map[string][]*models.ProjectLabel)
|
labelsByProject := make(map[string][]*models.ProjectLabel)
|
||||||
userLabels, err := srv.GetByUser(userId)
|
userLabels, err := srv.GetByUser(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range userLabels {
|
for _, l := range userLabels {
|
||||||
if _, ok := labels[l.ProjectKey]; !ok {
|
if _, ok := labelsByProject[l.ProjectKey]; !ok {
|
||||||
labels[l.ProjectKey] = []*models.ProjectLabel{l}
|
labelsByProject[l.ProjectKey] = []*models.ProjectLabel{l}
|
||||||
} else {
|
} else {
|
||||||
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
|
labelsByProject[l.ProjectKey] = append(labelsByProject[l.ProjectKey], l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return labelsByProject, nil
|
||||||
|
}
|
||||||
|
|
||||||
return labels, nil
|
func (srv *ProjectLabelService) GetByUserGroupedInverted(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
projectsByLabel := make(map[string][]*models.ProjectLabel)
|
||||||
|
userLabels, err := srv.GetByUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range userLabels {
|
||||||
|
if _, ok := projectsByLabel[l.Label]; !ok {
|
||||||
|
projectsByLabel[l.Label] = []*models.ProjectLabel{l}
|
||||||
|
} else {
|
||||||
|
projectsByLabel[l.Label] = append(projectsByLabel[l.Label], l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projectsByLabel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
|
@ -62,6 +62,7 @@ type IProjectLabelService interface {
|
|||||||
GetById(uint) (*models.ProjectLabel, error)
|
GetById(uint) (*models.ProjectLabel, error)
|
||||||
GetByUser(string) ([]*models.ProjectLabel, error)
|
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||||
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
|
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
|
||||||
|
GetByUserGroupedInverted(string) (map[string][]*models.ProjectLabel, error)
|
||||||
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||||
Delete(*models.ProjectLabel) error
|
Delete(*models.ProjectLabel) error
|
||||||
}
|
}
|
||||||
|
@ -420,6 +420,13 @@ function hexToRgb(hex) {
|
|||||||
} : null;
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showUserMenuPopup(event) {
|
||||||
|
const el = document.getElementById('user-menu-popup')
|
||||||
|
el.classList.remove('hidden')
|
||||||
|
el.classList.add('block')
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
function showApiKeyPopup(event) {
|
function showApiKeyPopup(event) {
|
||||||
const el = document.getElementById('api-key-popup')
|
const el = document.getElementById('api-key-popup')
|
||||||
el.classList.remove('hidden')
|
el.classList.remove('hidden')
|
||||||
|
File diff suppressed because one or more lines are too long
102
static/assets/vendor/tailwind.dist.css
vendored
102
static/assets/vendor/tailwind.dist.css
vendored
@ -9,8 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
html {
|
html {
|
||||||
line-height: 1.15; /* 1 */
|
line-height: 1.15;
|
||||||
-webkit-text-size-adjust: 100%; /* 2 */
|
/* 1 */
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections
|
/* Sections
|
||||||
@ -51,9 +53,12 @@ h1 {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
box-sizing: content-box; /* 1 */
|
box-sizing: content-box;
|
||||||
height: 0; /* 1 */
|
/* 1 */
|
||||||
overflow: visible; /* 2 */
|
height: 0;
|
||||||
|
/* 1 */
|
||||||
|
overflow: visible;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,8 +67,10 @@ hr {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
font-family: monospace, monospace; /* 1 */
|
font-family: monospace, monospace;
|
||||||
font-size: 1em; /* 2 */
|
/* 1 */
|
||||||
|
font-size: 1em;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text-level semantics
|
/* Text-level semantics
|
||||||
@ -83,10 +90,13 @@ a {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
abbr[title] {
|
abbr[title] {
|
||||||
border-bottom: none; /* 1 */
|
border-bottom: none;
|
||||||
text-decoration: underline; /* 2 */
|
/* 1 */
|
||||||
|
text-decoration: underline;
|
||||||
|
/* 2 */
|
||||||
-webkit-text-decoration: underline dotted;
|
-webkit-text-decoration: underline dotted;
|
||||||
text-decoration: underline dotted; /* 2 */
|
text-decoration: underline dotted;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,8 +116,10 @@ strong {
|
|||||||
code,
|
code,
|
||||||
kbd,
|
kbd,
|
||||||
samp {
|
samp {
|
||||||
font-family: monospace, monospace; /* 1 */
|
font-family: monospace, monospace;
|
||||||
font-size: 1em; /* 2 */
|
/* 1 */
|
||||||
|
font-size: 1em;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,10 +175,14 @@ input,
|
|||||||
optgroup,
|
optgroup,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font-family: inherit; /* 1 */
|
font-family: inherit;
|
||||||
font-size: 100%; /* 1 */
|
/* 1 */
|
||||||
line-height: 1.15; /* 1 */
|
font-size: 100%;
|
||||||
margin: 0; /* 2 */
|
/* 1 */
|
||||||
|
line-height: 1.15;
|
||||||
|
/* 1 */
|
||||||
|
margin: 0;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -175,7 +191,8 @@ textarea {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input { /* 1 */
|
input {
|
||||||
|
/* 1 */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +202,8 @@ input { /* 1 */
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
button,
|
button,
|
||||||
select { /* 1 */
|
select {
|
||||||
|
/* 1 */
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,12 +257,18 @@ fieldset {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
legend {
|
legend {
|
||||||
box-sizing: border-box; /* 1 */
|
box-sizing: border-box;
|
||||||
color: inherit; /* 2 */
|
/* 1 */
|
||||||
display: table; /* 1 */
|
color: inherit;
|
||||||
max-width: 100%; /* 1 */
|
/* 2 */
|
||||||
padding: 0; /* 3 */
|
display: table;
|
||||||
white-space: normal; /* 1 */
|
/* 1 */
|
||||||
|
max-width: 100%;
|
||||||
|
/* 1 */
|
||||||
|
padding: 0;
|
||||||
|
/* 3 */
|
||||||
|
white-space: normal;
|
||||||
|
/* 1 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -394,8 +418,10 @@ ul {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 1 */
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
line-height: 1.5; /* 2 */
|
/* 1 */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -427,10 +453,14 @@ html {
|
|||||||
*,
|
*,
|
||||||
::before,
|
::before,
|
||||||
::after {
|
::after {
|
||||||
box-sizing: border-box; /* 1 */
|
box-sizing: border-box;
|
||||||
border-width: 0; /* 2 */
|
/* 1 */
|
||||||
border-style: solid; /* 2 */
|
border-width: 0;
|
||||||
border-color: #e2e8f0; /* 2 */
|
/* 2 */
|
||||||
|
border-style: solid;
|
||||||
|
/* 2 */
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
/* 2 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -723,6 +753,10 @@ video {
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-2 {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
@ -831,6 +865,10 @@ video {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.font-semibold {
|
.font-semibold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@ -999,6 +1037,10 @@ video {
|
|||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-24 {
|
||||||
|
margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.-ml-1 {
|
.-ml-1 {
|
||||||
margin-left: -0.25rem;
|
margin-left: -0.25rem;
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ var doc = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
"/compat/shields/v1/{user}/{interval}/{filter}": {
|
"/api/compat/shields/v1/{user}/{interval}/{filter}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
|
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
|
||||||
"produces": [
|
"produces": [
|
||||||
@ -90,7 +90,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}": {
|
"/api/compat/wakatime/v1/users/{user}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -125,7 +125,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/all_time_since_today": {
|
"/api/compat/wakatime/v1/users/{user}/all_time_since_today": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -160,7 +160,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/heartbeats": {
|
"/api/compat/wakatime/v1/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -193,7 +193,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
|
"/api/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -229,7 +229,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/projects": {
|
"/api/compat/wakatime/v1/users/{user}/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -271,7 +271,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/stats/{range}": {
|
"/api/compat/wakatime/v1/users/{user}/stats/{range}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -326,7 +326,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/summaries": {
|
"/api/compat/wakatime/v1/users/{user}/summaries": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -393,7 +393,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/health": {
|
"/api/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"text/plain"
|
"text/plain"
|
||||||
@ -413,7 +413,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/heartbeat": {
|
"/api/heartbeat": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -446,7 +446,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/heartbeats": {
|
"/api/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -482,7 +482,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/plugins/errors": {
|
"/api/plugins/errors": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -515,7 +515,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/summary": {
|
"/api/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -580,7 +580,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{user}/heartbeats": {
|
"/api/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -613,7 +613,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{user}/heartbeats.bulk": {
|
"/api/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -649,7 +649,42 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/users/{user}/heartbeats": {
|
"/api/users/{user}/statusbar/today": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Mimics https://wakatime.com/api/v1/users/current/statusbar/today. Have no official documentation",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"wakatime"
|
||||||
|
],
|
||||||
|
"summary": "Retrieve summary for statusbar",
|
||||||
|
"operationId": "statusbar",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "User ID to fetch data for (or 'current')",
|
||||||
|
"name": "user",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/v1.StatusBarViewModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -682,7 +717,7 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/users/{user}/heartbeats.bulk": {
|
"/api/v1/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -717,6 +752,158 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/relay": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an GET API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an PUT API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-put",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an POST API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an DELETE API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an PATCH API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@ -791,6 +978,9 @@ var doc = `{
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1014,6 +1204,17 @@ var doc = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"v1.StatusBarViewModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cached_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/v1.SummariesData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"v1.SummariesData": {
|
"v1.SummariesData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"basePath": "/api",
|
"basePath": "/api",
|
||||||
"paths": {
|
"paths": {
|
||||||
"/compat/shields/v1/{user}/{interval}/{filter}": {
|
"/api/compat/shields/v1/{user}/{interval}/{filter}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
|
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
|
||||||
"produces": [
|
"produces": [
|
||||||
@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}": {
|
"/api/compat/wakatime/v1/users/{user}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -109,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/all_time_since_today": {
|
"/api/compat/wakatime/v1/users/{user}/all_time_since_today": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -144,7 +144,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/heartbeats": {
|
"/api/compat/wakatime/v1/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -177,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
|
"/api/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -213,7 +213,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/projects": {
|
"/api/compat/wakatime/v1/users/{user}/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -255,7 +255,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/stats/{range}": {
|
"/api/compat/wakatime/v1/users/{user}/stats/{range}": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -310,7 +310,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/compat/wakatime/v1/users/{user}/summaries": {
|
"/api/compat/wakatime/v1/users/{user}/summaries": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -377,7 +377,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/health": {
|
"/api/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"text/plain"
|
"text/plain"
|
||||||
@ -397,7 +397,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/heartbeat": {
|
"/api/heartbeat": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -430,7 +430,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/heartbeats": {
|
"/api/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -466,7 +466,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/plugins/errors": {
|
"/api/plugins/errors": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -499,7 +499,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/summary": {
|
"/api/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -564,7 +564,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{user}/heartbeats": {
|
"/api/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -597,7 +597,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{user}/heartbeats.bulk": {
|
"/api/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -633,7 +633,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/users/{user}/heartbeats": {
|
"/api/users/{user}/statusbar/today": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Mimics https://wakatime.com/api/v1/users/current/statusbar/today. Have no official documentation",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"wakatime"
|
||||||
|
],
|
||||||
|
"summary": "Retrieve summary for statusbar",
|
||||||
|
"operationId": "statusbar",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "User ID to fetch data for (or 'current')",
|
||||||
|
"name": "user",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/v1.StatusBarViewModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/users/{user}/heartbeats": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -666,7 +701,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/users/{user}/heartbeats.bulk": {
|
"/api/v1/users/{user}/heartbeats.bulk": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@ -701,6 +736,158 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/relay": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an GET API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an PUT API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-put",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an POST API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an DELETE API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"relay"
|
||||||
|
],
|
||||||
|
"summary": "Proxy an PATCH API request to another Wakapi instance",
|
||||||
|
"operationId": "relay-patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original URL to perform the request to",
|
||||||
|
"name": "X-Target-URL",
|
||||||
|
"in": "header",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"description": "Returned if request path is not whitelisted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Returned if upstream host is down",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@ -775,6 +962,9 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -998,6 +1188,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"v1.StatusBarViewModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cached_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/v1.SummariesData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"v1.SummariesData": {
|
"v1.SummariesData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -49,6 +49,8 @@ definitions:
|
|||||||
type: number
|
type: number
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
|
user_agent:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
models.Summary:
|
models.Summary:
|
||||||
properties:
|
properties:
|
||||||
@ -198,6 +200,13 @@ definitions:
|
|||||||
data:
|
data:
|
||||||
$ref: '#/definitions/v1.StatsData'
|
$ref: '#/definitions/v1.StatsData'
|
||||||
type: object
|
type: object
|
||||||
|
v1.StatusBarViewModel:
|
||||||
|
properties:
|
||||||
|
cached_at:
|
||||||
|
type: string
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/v1.SummariesData'
|
||||||
|
type: object
|
||||||
v1.SummariesData:
|
v1.SummariesData:
|
||||||
properties:
|
properties:
|
||||||
categories:
|
categories:
|
||||||
@ -342,7 +351,7 @@ info:
|
|||||||
title: Wakapi API
|
title: Wakapi API
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths:
|
paths:
|
||||||
/compat/shields/v1/{user}/{interval}/{filter}:
|
/api/compat/shields/v1/{user}/{interval}/{filter}:
|
||||||
get:
|
get:
|
||||||
description: Retrieve total time for a given entity (e.g. a project) within
|
description: Retrieve total time for a given entity (e.g. a project) within
|
||||||
a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint).
|
a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint).
|
||||||
@ -387,7 +396,7 @@ paths:
|
|||||||
summary: Get badge data
|
summary: Get badge data
|
||||||
tags:
|
tags:
|
||||||
- badges
|
- badges
|
||||||
/compat/wakatime/v1/users/{user}:
|
/api/compat/wakatime/v1/users/{user}:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#users
|
description: Mimics https://wakatime.com/developers#users
|
||||||
operationId: get-wakatime-user
|
operationId: get-wakatime-user
|
||||||
@ -409,7 +418,7 @@ paths:
|
|||||||
summary: Retrieve the given user
|
summary: Retrieve the given user
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- wakatime
|
||||||
/compat/wakatime/v1/users/{user}/all_time_since_today:
|
/api/compat/wakatime/v1/users/{user}/all_time_since_today:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#all_time_since_today
|
description: Mimics https://wakatime.com/developers#all_time_since_today
|
||||||
operationId: get-all-time
|
operationId: get-all-time
|
||||||
@ -431,7 +440,7 @@ paths:
|
|||||||
summary: Retrieve summary for all time
|
summary: Retrieve summary for all time
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- wakatime
|
||||||
/compat/wakatime/v1/users/{user}/heartbeats:
|
/api/compat/wakatime/v1/users/{user}/heartbeats:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -451,7 +460,7 @@ paths:
|
|||||||
summary: Push a new heartbeat
|
summary: Push a new heartbeat
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/compat/wakatime/v1/users/{user}/heartbeats.bulk:
|
/api/compat/wakatime/v1/users/{user}/heartbeats.bulk:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -473,7 +482,7 @@ paths:
|
|||||||
summary: Push new heartbeats
|
summary: Push new heartbeats
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/compat/wakatime/v1/users/{user}/projects:
|
/api/compat/wakatime/v1/users/{user}/projects:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#projects
|
description: Mimics https://wakatime.com/developers#projects
|
||||||
operationId: get-wakatime-projects
|
operationId: get-wakatime-projects
|
||||||
@ -500,7 +509,7 @@ paths:
|
|||||||
summary: Retrieve and fitler the user's projects
|
summary: Retrieve and fitler the user's projects
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- wakatime
|
||||||
/compat/wakatime/v1/users/{user}/stats/{range}:
|
/api/compat/wakatime/v1/users/{user}/stats/{range}:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#stats
|
description: Mimics https://wakatime.com/developers#stats
|
||||||
operationId: get-wakatimes-tats
|
operationId: get-wakatimes-tats
|
||||||
@ -539,7 +548,7 @@ paths:
|
|||||||
summary: Retrieve statistics for a given user
|
summary: Retrieve statistics for a given user
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- wakatime
|
||||||
/compat/wakatime/v1/users/{user}/summaries:
|
/api/compat/wakatime/v1/users/{user}/summaries:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#summaries.
|
description: Mimics https://wakatime.com/developers#summaries.
|
||||||
operationId: get-wakatime-summaries
|
operationId: get-wakatime-summaries
|
||||||
@ -586,7 +595,7 @@ paths:
|
|||||||
summary: Retrieve WakaTime-compatible summaries
|
summary: Retrieve WakaTime-compatible summaries
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- wakatime
|
||||||
/health:
|
/api/health:
|
||||||
get:
|
get:
|
||||||
operationId: get-health
|
operationId: get-health
|
||||||
produces:
|
produces:
|
||||||
@ -599,7 +608,7 @@ paths:
|
|||||||
summary: Check the application's health status
|
summary: Check the application's health status
|
||||||
tags:
|
tags:
|
||||||
- misc
|
- misc
|
||||||
/heartbeat:
|
/api/heartbeat:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -619,7 +628,7 @@ paths:
|
|||||||
summary: Push a new heartbeat
|
summary: Push a new heartbeat
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/heartbeats:
|
/api/heartbeats:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -641,7 +650,7 @@ paths:
|
|||||||
summary: Push new heartbeats
|
summary: Push new heartbeats
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/plugins/errors:
|
/api/plugins/errors:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -661,7 +670,7 @@ paths:
|
|||||||
summary: Push a new diagnostics object
|
summary: Push a new diagnostics object
|
||||||
tags:
|
tags:
|
||||||
- diagnostics
|
- diagnostics
|
||||||
/summary:
|
/api/summary:
|
||||||
get:
|
get:
|
||||||
operationId: get-summary
|
operationId: get-summary
|
||||||
parameters:
|
parameters:
|
||||||
@ -706,7 +715,7 @@ paths:
|
|||||||
summary: Retrieve a summary
|
summary: Retrieve a summary
|
||||||
tags:
|
tags:
|
||||||
- summary
|
- summary
|
||||||
/users/{user}/heartbeats:
|
/api/users/{user}/heartbeats:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -726,7 +735,7 @@ paths:
|
|||||||
summary: Push a new heartbeat
|
summary: Push a new heartbeat
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/users/{user}/heartbeats.bulk:
|
/api/users/{user}/heartbeats.bulk:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -748,7 +757,30 @@ paths:
|
|||||||
summary: Push new heartbeats
|
summary: Push new heartbeats
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/v1/users/{user}/heartbeats:
|
/api/users/{user}/statusbar/today:
|
||||||
|
get:
|
||||||
|
description: Mimics https://wakatime.com/api/v1/users/current/statusbar/today.
|
||||||
|
Have no official documentation
|
||||||
|
operationId: statusbar
|
||||||
|
parameters:
|
||||||
|
- description: User ID to fetch data for (or 'current')
|
||||||
|
in: path
|
||||||
|
name: user
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/v1.StatusBarViewModel'
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Retrieve summary for statusbar
|
||||||
|
tags:
|
||||||
|
- wakatime
|
||||||
|
/api/v1/users/{user}/heartbeats:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -768,7 +800,7 @@ paths:
|
|||||||
summary: Push a new heartbeat
|
summary: Push a new heartbeat
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
/v1/users/{user}/heartbeats.bulk:
|
/api/v1/users/{user}/heartbeats.bulk:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@ -790,6 +822,107 @@ paths:
|
|||||||
summary: Push new heartbeats
|
summary: Push new heartbeats
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- heartbeat
|
||||||
|
/relay:
|
||||||
|
delete:
|
||||||
|
operationId: relay-delete
|
||||||
|
parameters:
|
||||||
|
- description: Original URL to perform the request to
|
||||||
|
in: header
|
||||||
|
name: X-Target-URL
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"403":
|
||||||
|
description: Returned if request path is not whitelisted
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: Returned if upstream host is down
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Proxy an DELETE API request to another Wakapi instance
|
||||||
|
tags:
|
||||||
|
- relay
|
||||||
|
get:
|
||||||
|
operationId: relay-get
|
||||||
|
parameters:
|
||||||
|
- description: Original URL to perform the request to
|
||||||
|
in: header
|
||||||
|
name: X-Target-URL
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"403":
|
||||||
|
description: Returned if request path is not whitelisted
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: Returned if upstream host is down
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Proxy an GET API request to another Wakapi instance
|
||||||
|
tags:
|
||||||
|
- relay
|
||||||
|
patch:
|
||||||
|
operationId: relay-patch
|
||||||
|
parameters:
|
||||||
|
- description: Original URL to perform the request to
|
||||||
|
in: header
|
||||||
|
name: X-Target-URL
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"403":
|
||||||
|
description: Returned if request path is not whitelisted
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: Returned if upstream host is down
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Proxy an PATCH API request to another Wakapi instance
|
||||||
|
tags:
|
||||||
|
- relay
|
||||||
|
post:
|
||||||
|
operationId: relay-post
|
||||||
|
parameters:
|
||||||
|
- description: Original URL to perform the request to
|
||||||
|
in: header
|
||||||
|
name: X-Target-URL
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"403":
|
||||||
|
description: Returned if request path is not whitelisted
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: Returned if upstream host is down
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Proxy an POST API request to another Wakapi instance
|
||||||
|
tags:
|
||||||
|
- relay
|
||||||
|
put:
|
||||||
|
operationId: relay-put
|
||||||
|
parameters:
|
||||||
|
- description: Original URL to perform the request to
|
||||||
|
in: header
|
||||||
|
name: X-Target-URL
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"403":
|
||||||
|
description: Returned if request path is not whitelisted
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"502":
|
||||||
|
description: Returned if upstream host is down
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Proxy an PUT API request to another Wakapi instance
|
||||||
|
tags:
|
||||||
|
- relay
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
ApiKeyAuth:
|
ApiKeyAuth:
|
||||||
in: header
|
in: header
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "36595622-81dc-4f4a-826e-345ae63fc83b",
|
"_postman_id": "da93a75e-e931-4f00-80b8-428f0e7ae824",
|
||||||
"name": "Wakapi API Tests",
|
"name": "Wakapi API Tests",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
@ -251,6 +251,105 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authenticate (header)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disableCookies": true,
|
||||||
|
"followRedirects": false
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{WRITEUSER_TOKEN}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/api/summary?interval=today",
|
||||||
|
"host": [
|
||||||
|
"{{BASE_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"summary"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "interval",
|
||||||
|
"value": "today"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authenticate (query param)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disableCookies": true,
|
||||||
|
"followRedirects": false
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "noauth"
|
||||||
|
},
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/api/summary?interval=today&api_key={{WRITEUSER_API_KEY}}",
|
||||||
|
"host": [
|
||||||
|
"{{BASE_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"summary"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "interval",
|
||||||
|
"value": "today"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "api_key",
|
||||||
|
"value": "{{WRITEUSER_API_KEY}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ ! -f "wakapi" ]; then
|
echo "Compiling."
|
||||||
echo "Wakapi executable not found. Compiling."
|
go build
|
||||||
go build
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v newman &> /dev/null
|
if ! command -v newman &> /dev/null
|
||||||
then
|
then
|
||||||
@ -20,7 +18,8 @@ echo "Importing seed data ..."
|
|||||||
sqlite3 wakapi_testing.db < data.sql
|
sqlite3 wakapi_testing.db < data.sql
|
||||||
|
|
||||||
echo "Running Wakapi testing instance in background ..."
|
echo "Running Wakapi testing instance in background ..."
|
||||||
screen -S wakapi_testing -dm bash -c "../wakapi -config config.testing.yml"
|
../wakapi -config config.testing.yml &
|
||||||
|
pid=$!
|
||||||
|
|
||||||
echo "Waiting for Wakapi to come up ..."
|
echo "Waiting for Wakapi to come up ..."
|
||||||
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
|
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
|
||||||
@ -32,9 +31,12 @@ echo ""
|
|||||||
|
|
||||||
echo "Running test collection ..."
|
echo "Running test collection ..."
|
||||||
newman run "Wakapi API Tests.postman_collection.json"
|
newman run "Wakapi API Tests.postman_collection.json"
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
echo "Shutting down Wakapi ..."
|
echo "Shutting down Wakapi ..."
|
||||||
screen -S wakapi_testing -X quit
|
kill -TERM $pid
|
||||||
|
|
||||||
echo "Deleting database ..."
|
echo "Deleting database ..."
|
||||||
rm wakapi_testing.db
|
rm wakapi_testing.db
|
||||||
|
|
||||||
|
exit $exit_code
|
32
utils/db.go
Normal file
32
utils/db.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsCleanDB(db *gorm.DB) bool {
|
||||||
|
if db.Dialector.Name() == "sqlite" {
|
||||||
|
var count int64
|
||||||
|
if err := db.Raw("SELECT count(*) from sqlite_master WHERE type = 'table'").Scan(&count).Error; err != nil {
|
||||||
|
logbuch.Error("failed to check if database is clean - '%v'", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count == 0
|
||||||
|
}
|
||||||
|
logbuch.Warn("IsCleanDB is not yet implemented for dialect '%s'", db.Dialector.Name())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasConstraints(db *gorm.DB) bool {
|
||||||
|
if db.Dialector.Name() == "sqlite" {
|
||||||
|
var count int64
|
||||||
|
if err := db.Raw("SELECT count(*) from sqlite_master WHERE sql LIKE '%CONSTRAINT%'").Scan(&count).Error; err != nil {
|
||||||
|
logbuch.Error("failed to check if database has constraints - '%v'", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count != 0
|
||||||
|
}
|
||||||
|
logbuch.Warn("HasForeignKeyConstraints is not yet implemented for dialect '%s'", db.Dialector.Name())
|
||||||
|
return false
|
||||||
|
}
|
@ -1 +1 @@
|
|||||||
1.29.6
|
1.30.2
|
||||||
|
@ -46,7 +46,7 @@
|
|||||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="fxemoji:satelliteantenna"></span> Host it
|
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="fxemoji:satelliteantenna"></span> Host it
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://liberapay.com/muety" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/sponsors/muety" target="_blank" rel="noopener noreferrer">
|
||||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="flat-color-icons:donate"></span> Support it
|
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="flat-color-icons:donate"></span> Support it
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
@ -74,6 +74,7 @@
|
|||||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>
|
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>
|
||||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Lightning fast</li>
|
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Lightning fast</li>
|
||||||
|
<li title="Wakapi.dev is hosted in Germany, none of your data will leave the EU"><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> GDPR-compliant</li>
|
||||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Self-hosted</li>
|
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Self-hosted</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -221,9 +221,9 @@
|
|||||||
<div class="flex items-center" action="" method="post">
|
<div class="flex items-center" action="" method="post">
|
||||||
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
<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">
|
style="line-height: 1.8">
|
||||||
▸ <span class="font-semibold text-white">{{ $label.Key }}:</span>
|
▸ <span class="font-semibold text-white font-mono">{{ $label.Key }}:</span>
|
||||||
{{ range $j, $value := $label.Values }}
|
{{ range $j, $value := $label.Values }}
|
||||||
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
|
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 my-1 inline-flex justify-between items-center space-x-2">
|
||||||
<input type="hidden" name="action" value="delete_label">
|
<input type="hidden" name="action" value="delete_label">
|
||||||
<input type="hidden" name="key" value="{{ $label.Key }}">
|
<input type="hidden" name="key" value="{{ $label.Key }}">
|
||||||
<input type="hidden" name="value" value="{{ $value }}">
|
<input type="hidden" name="value" value="{{ $value }}">
|
||||||
|
@ -19,6 +19,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup mt-24"
|
||||||
|
id="user-menu-popup" style="min-width: 200px;">
|
||||||
|
<div class="flex-grow flex flex-col px-2">
|
||||||
|
<div class="flex flex-col text-xs text-gray-300 mx-1 mb-4 items-center">
|
||||||
|
<span class="font-semibold">{{ .User.ID }}</span>
|
||||||
|
{{ if .User.Email }}
|
||||||
|
<span>({{ .User.Email }})</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<form action="logout" method="post" class="flex-grow">
|
||||||
|
<button type="submit" class="py-1 px-3 h-8 rounded bg-green-700 text-white text-sm w-full">
|
||||||
|
<span>Logout</span>
|
||||||
|
<span class="iconify inline" data-icon="fxemoji:running"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||||
<div class="mx-1">
|
<div class="mx-1">
|
||||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
|
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
|
||||||
@ -30,10 +48,12 @@
|
|||||||
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-1">
|
<div class="mx-1 flex items-center">
|
||||||
<form action="logout" method="post">
|
{{ if avatarUrlTemplate }}
|
||||||
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
|
<img src="{{ .User.AvatarURL avatarUrlTemplate }}" width="32px" class="rounded-full border-2 border-green-700 cursor-pointer" onclick="showUserMenuPopup(event)" alt="User Profile Avatar" title="Looks like you, doesn't it?"/>
|
||||||
</form>
|
{{ else }}
|
||||||
|
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-2 border-green-700" style="width: 32px; height: 32px" data-icon="ic:round-person" onclick="showUserMenuPopup(event)"></span>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user