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

Compare commits

..

43 Commits

Author SHA1 Message Date
7dd0967451 chore: drop debian based docker image and use alpine by default 2021-12-15 15:37:14 +01:00
d6aa2c4405 chore: bump version 2021-12-15 15:16:31 +01:00
821ae94c1e fix: auto increment in bigint migration 2021-12-15 13:17:07 +01:00
adcd7b35ae fix: adapt tests 2021-12-15 12:52:24 +01:00
b0bd26f0ec chore: upgrade dependencies (fix #280) 2021-12-15 12:51:44 +01:00
259f711f2d fix: migrate id column type to bigint (resolve #281) 2021-12-15 10:50:16 +01:00
1c0477f861 Merge pull request #279 from muety/docker
fix: anticipated docker push issue
2021-12-14 15:31:06 +01:00
28a3418ad5 fix: limit sqlite connection pool to one 2021-12-14 02:17:59 +01:00
c5db2c235f chore: enable foreign key constraints for new sqlite databases 2021-12-14 00:47:04 +01:00
9cbddaeedf fix: anticipated docker push issue 2021-12-02 23:06:19 +11:00
485dfe2888 fix: user time zone test (fix #275) [ci skip] 2021-11-28 12:40:46 +01:00
Kid
78a26dbf3c Another typo fix 2021-11-26 22:55:43 +11:00
b2c72c6420 Merge pull request #272 from kidonng/patch-1 [ci skip]
Fix a typo
2021-11-25 15:31:05 +01:00
Kid
6852494d36 Fix a typo 2021-11-25 22:28:20 +08:00
305166ce68 Remove Table of Contents from README [ci skip]
Github has menu, and the links don't seem to work on Docker Hub
2021-11-25 20:31:41 +11:00
400f25c23e fix: remove dead client-side proxy link in README [ci skip]
This link is present in Client Setup section
2021-11-25 20:28:43 +11:00
3aacd3461d Merge pull request #265 from muety/ghcr
ci: add ghcr for Docker deploy, change cache
2021-10-21 13:42:29 +02:00
7e2460e1f0 ci: add ghcr for Docker deploy, change cache 2021-10-21 20:54:13 +11:00
57175ae7f8 docs: update readme [ci skip] 2021-10-14 13:09:26 +02:00
5df0f48303 feat: user avatars 2021-10-14 12:04:21 +02:00
76a7cf7e80 chore: include runtime metrics 2021-10-14 10:35:01 +02:00
7cae3c43d0 chore: enhanced caching for user entity sets (resolve #264) 2021-10-14 10:22:59 +02:00
5fc87dd143 fix: tests 2021-10-13 17:51:54 +02:00
7329f6a34e chore: version 2021-10-13 17:47:33 +02:00
3b96bd3723 docs: include relay endpoint in swagger docs 2021-10-13 17:47:18 +02:00
2c7977cf63 chore: invert visualization of project labels (resolve #263) 2021-10-13 17:12:55 +02:00
782da0b49e chore: update landing page 2021-10-13 11:27:04 +02:00
ed9a7ccd5a fix: tests 2021-10-11 11:38:55 +02:00
9451848ad4 chore: bump version 2021-10-11 11:30:18 +02:00
6c0145b149 Merge branch 'gaocegege-auth' 2021-10-11 11:30:08 +02:00
a94092e31c test: add api tests for query auth 2021-10-11 11:29:38 +02:00
52744dbcd0 Merge branch 'auth' of https://github.com/gaocegege/wakapi into gaocegege-auth 2021-10-11 11:22:01 +02:00
cc11226eab fix: add missing non-zero field checks (fix #259) 2021-10-11 11:07:04 +02:00
8d073aaef2 feat: implement relay endpoint (see #237) 2021-10-11 11:00:50 +02:00
d2f078443e fix: Remove hard coded string 2021-10-11 16:00:48 +08:00
c6e1651d9e fix: Fix the empty key error 2021-10-11 15:58:29 +08:00
630090e38a feat: Support query parameter token 2021-10-11 15:10:30 +08:00
5394349c73 update readme [ci skip] 2021-09-29 21:04:00 +02:00
5cd3bf83a6 Merge pull request #253 from muety/ci-test
feat: add test steps to Linux workflow
2021-09-18 14:25:15 +02:00
13cf911edf fix: make test script fail if tests fail [ci skip] 2021-09-18 14:24:16 +02:00
fe0f41cecb feat: add test steps to Linux workflow 2021-09-17 21:24:12 +10:00
265080453a Merge pull request #252 from kondr1/gitpod
Config for working with repo use gitpod [ci skip]
2021-09-09 12:22:47 +02:00
2f9b8fbcfe .gitpod.yml 2021-09-07 13:29:36 +00:00
67 changed files with 2165 additions and 1161 deletions

View File

@ -20,39 +20,31 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to Docker Hub
uses: docker/build-push-action@v2
- name: Log in to the Container registry
uses: docker/login-action@v1
with:
push: true
tags: |
n1try/wakapi:${{ env.GIT_TAG }}
n1try/wakapi:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to Docker Hub (Alpine)
- name: Build and push
uses: docker/build-push-action@v2
with:
file: Dockerfile.alpine
file: Dockerfile
push: true
tags: |
n1try/wakapi:${{ env.GIT_TAG }}-alpine
n1try/wakapi:latest-alpine
n1try/wakapi:latest
n1try/wakapi:alpine
n1try/wakapi:${{ env.GIT_TAG }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:alpine
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max

View File

@ -1,4 +1,4 @@
name: Build Wakapi on Linux
name: Linux
on:
push:
@ -10,7 +10,7 @@ on:
jobs:
build-and-release:
name: Build
name: Linux - Build, Test & Release
runs-on: ubuntu-latest
steps:
@ -24,8 +24,15 @@ jobs:
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: go test ./... -run ./...
- name: API Tests
run: |
go get
npm -g install newman
./testing/run_api_tests.sh
- name: Build
run: GO111MODULE=on go build -v .

View File

@ -1,4 +1,4 @@
name: Build Wakapi on Windows
name: Windows
on:
push:
@ -10,7 +10,7 @@ on:
jobs:
build-and-release:
name: Build
name: Windows - Build & Release
runs-on: windows-latest
steps:

6
.gitpod.yml Normal file
View 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

View File

@ -1,12 +1,15 @@
# Build Stage
FROM golang:1.16 AS build-env
FROM golang:1.16-alpine AS build-env
WORKDIR /src
# Required for go-sqlite3
RUN apk add gcc musl-dev
ADD ./go.mod .
RUN go mod download
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
chmod +x wait-for-it.sh
ADD . .
@ -25,12 +28,10 @@ RUN cp /src/wakapi . && \
# to override config values using `-e` syntax.
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM debian
FROM alpine:3
WORKDIR /app
RUN apt update && \
apt install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod
@ -48,4 +49,4 @@ COPY --from=build-env /app .
VOLUME /data
ENTRYPOINT ./entrypoint.sh
ENTRYPOINT /app/entrypoint.sh

View File

@ -1,52 +0,0 @@
# Build Stage
FROM golang:1.16-alpine AS build-env
WORKDIR /src
# Required for go-sqlite3
RUN apk add gcc musl-dev
ADD ./go.mod .
RUN go mod download
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
chmod +x wait-for-it.sh
ADD . .
RUN go build -o wakapi
WORKDIR /app
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
cp /src/wait-for-it.sh . && \
cp /src/entrypoint.sh .
# Run Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values using `-e` syntax.
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM alpine:3
WORKDIR /app
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
ENV WAKAPI_ALLOW_SIGNUP 'true'
COPY --from=build-env /app .
VOLUME /data
ENTRYPOINT /app/entrypoint.sh

115
README.md
View File

@ -4,14 +4,11 @@
<p align="center">
<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>
<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">
</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://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
@ -35,21 +32,7 @@
<img src="static/assets/images/screenshot.png" width="500px">
</p>
## Table of Contents
* [User Survey](#-user-survey)
* [Features](#-features)
* [Roadmap](#-roadmap)
* [How to use](#-how-to-use)
* [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints)
* [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).
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
## 📬 **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!
@ -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.
### ☁️ 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 ❕
@ -144,42 +127,43 @@ Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi
## 🔧 Configuration Options
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key | Environment Variable | Default | Description |
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.custom_languages` | - | - | Map from file endings to language names |
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` | `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `server.timeout_sec` | `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` | `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. 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) |
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
| YAML Key / Env. Variable | Default | Description |
|-----------------------------|--------------|---------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.custom_languages` | - | Map from file endings to language names |
| `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.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `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.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` /<br> `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
| `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.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `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
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_)
* [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
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)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
# 2. screen
$ sudo apt install screen # Fedora: sudo dnf install screen
# 3. newman
# 2. 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).
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
@ -367,7 +342,7 @@ All data is cached locally on your machine and sent in batches once you're onlin
<details>
<summary><b>How did Wakapi come about?</b></summary>
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing)">9 $ a month</a> back then. Luckily, most parts of WakaTime are open source!
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>
@ -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.
</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 youll 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
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.

View File

@ -21,6 +21,10 @@ app:
jsx: JSX
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:
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
@ -39,6 +43,7 @@ security:
cookie_max_age: 172800
allow_signup: true
expose_metrics: false
enable_proxy: false # only intended for production instance at wakapi.dev
sentry:
dsn: # leave blank to disable sentry integration

View File

@ -62,19 +62,21 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct {
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"`
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"`
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"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
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"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
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"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
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))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
@ -237,6 +239,18 @@ func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
}
func (c *dbConfig) IsSQLite() bool {
return c.Dialect == "sqlite3"
}
func (c *dbConfig) IsMySQL() bool {
return c.Dialect == "mysql"
}
func (c *dbConfig) IsPostgres() bool {
return c.Dialect == "postgres"
}
func (c *serverConfig) GetPublicUrl() string {
return strings.TrimSuffix(c.PublicUrl, "/")
}
@ -283,6 +297,12 @@ func resolveDbDialect(dbType string) string {
if dbType == "cockroach" {
return "postgres"
}
if dbType == "sqlite" {
return "sqlite3"
}
if dbType == "mariadb" {
return "mysql"
}
return dbType
}
@ -363,6 +383,10 @@ func Load(version string) *Config {
if config.Db.MaxConn <= 0 {
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, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
}

File diff suppressed because it is too large Load Diff

19
go.mod
View File

@ -4,20 +4,23 @@ go 1.16
require (
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.2.0
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/getsentry/sentry-go v0.11.0
github.com/go-co-op/gocron v1.6.2
github.com/go-co-op/gocron v1.11.0
github.com/go-kit/kit v0.10.0 // indirect
github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/jackc/pgx/v4 v4.13.0 // indirect
github.com/jackc/pgx/v4 v4.14.1 // indirect
github.com/jinzhu/configor v1.2.1
github.com/jinzhu/now v1.1.4 // indirect
github.com/leandro-lugaresi/hub v1.1.1
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
@ -27,11 +30,11 @@ require (
github.com/stretchr/testify v1.7.0
github.com/swaggo/swag v1.7.0
go.uber.org/atomic v1.9.0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/tools v0.1.0 // indirect
gorm.io/driver/mysql v1.1.1
gorm.io/driver/postgres v1.1.0
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.21.12
gorm.io/driver/mysql v1.2.1
gorm.io/driver/postgres v1.2.3
gorm.io/driver/sqlite v1.2.6
gorm.io/gorm v1.22.4
)

36
go.sum
View File

@ -79,6 +79,8 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
@ -105,6 +107,8 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v1.6.2 h1:x5g1tWnWcXIZesdosJJcbziRi4XG6tKB92yKLUpoBkU=
github.com/go-co-op/gocron v1.6.2/go.mod h1:DbJm9kdgr1sEvWpHCA7dFFs/PGHPMil9/97EXCRPr4k=
github.com/go-co-op/gocron v1.11.0 h1:ujOMubCpGcTxnnR/9vJIPIEpgwuAjbueAYqJRNr+nHg=
github.com/go-co-op/gocron v1.11.0/go.mod h1:qtlsoMpHlSdIZ3E/xuZzrrAbeX3u5JtPvWf2TcdutU0=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@ -227,6 +231,8 @@ github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.0 h1:4EYhlDVEMsJ30nNj0mmgwIUXoq7e9sMJrVC2ED6QlCU=
github.com/jackc/pgconn v1.10.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.1 h1:DzdIHIjG1AxGwoEEqS+mGsURyjt4enSmqzACXvVzOT8=
github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@ -245,6 +251,8 @@ github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwX
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns=
github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@ -258,6 +266,9 @@ github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.8.1 h1:9k0IXtdJXHJbyAWQgbWr1lU+MEhPXZz6RIXxfR5oxXs=
github.com/jackc/pgtype v1.8.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.9.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.9.1 h1:MJc2s0MFS8C3ok1wQTdQxWuXQcB6+HwAm5x1CzW7mf0=
github.com/jackc/pgtype v1.9.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
@ -268,11 +279,15 @@ github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CI
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.13.0 h1:JCjhT5vmhMAf/YwBHLvrBn4OGdIQBiFG6ym8Zmdx570=
github.com/jackc/pgx/v4 v4.13.0/go.mod h1:9P4X524sErlaxj0XSGZk7s+LD0eOyu1ZDUrrpznYDF0=
github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8=
github.com/jackc/pgx/v4 v4.14.1 h1:71oo1KAGI6mXhLiTMn6iDFcp3e7+zon/capWjl2OEFU=
github.com/jackc/pgx/v4 v4.14.1/go.mod h1:RgDuE4Z34o7XE92RpLsvFiOEfrAUT0Xt2KxvX73W06M=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -280,6 +295,9 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@ -342,6 +360,7 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
@ -539,6 +558,9 @@ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -574,6 +596,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -611,6 +635,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@ -622,6 +647,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -702,14 +729,23 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.1.1 h1:yr1bpyqiwuSPJ4aGGUX9nu46RHXlF8RASQVb1QQNcvo=
gorm.io/driver/mysql v1.1.1/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
gorm.io/driver/mysql v1.2.1 h1:h+3f1l9Ng2C072Y2tIiLgPpWN78r1KXL7bHJ0nTjlhU=
gorm.io/driver/mysql v1.2.1/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo=
gorm.io/driver/postgres v1.1.0 h1:afBljg7PtJ5lA6YUWluV2+xovIPhS+YiInuL3kUjrbk=
gorm.io/driver/postgres v1.1.0/go.mod h1:hXQIwafeRjJvUm+OMxcFWyswJ/vevcpPLlGocwAwuqw=
gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To=
gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4=
gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.9/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.21.12 h1:3fQM0Eiz7jcJEhPggHEpoYnsGZqynMzverL77DV40RM=
gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM=
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

10
main.go
View File

@ -3,6 +3,7 @@ package main
import (
"embed"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"io/fs"
"log"
"net"
@ -117,9 +118,8 @@ func main() {
// Connect to database
var err error
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
db.DisableForeignKeyConstraintWhenMigrating = true
if config.Db.IsSQLite() {
db.Exec("PRAGMA foreign_keys = ON;")
}
if config.IsDev() {
@ -191,6 +191,9 @@ func main() {
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
// Other Handlers
relayHandler := relay.NewRelayHandler()
// Setup Routers
router := mux.NewRouter()
rootRouter := router.PathPrefix("/").Subrouter()
@ -219,6 +222,7 @@ func main() {
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter)
// API route registrations
summaryApiHandler.RegisterRoutes(apiRouter)

View File

@ -1,12 +1,23 @@
package middlewares
import (
"fmt"
"net/http"
"strings"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"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 {
@ -45,7 +56,10 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
user, err := m.tryGetUserByCookie(r)
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 {
@ -77,7 +91,7 @@ func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
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)
if err != nil {
return nil, err
@ -92,6 +106,20 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.Us
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) {
username, err := utils.ExtractCookieAuth(r, m.config)
if err != nil {

View File

@ -3,14 +3,16 @@ package middlewares
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
@ -26,13 +28,13 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
@ -47,10 +49,55 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Error(t, err)
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

View File

@ -31,13 +31,7 @@ func init() {
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
if hasRun(name, db) {
return nil
}
@ -64,13 +58,7 @@ func init() {
}
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -26,13 +26,7 @@ func init() {
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
if hasRun(name, db) {
return nil
}
@ -43,13 +37,7 @@ func init() {
}
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -1,9 +1,7 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
@ -12,13 +10,7 @@ func init() {
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
if hasRun(name, db) {
return nil
}
@ -26,13 +18,7 @@ func init() {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -1,9 +1,7 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
@ -12,13 +10,7 @@ func init() {
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
if hasRun(name, db) {
return nil
}
@ -26,13 +18,7 @@ func init() {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -1,7 +1,6 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
@ -13,15 +12,14 @@ func init() {
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
if err := db.
@ -32,13 +30,7 @@ func init() {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -13,14 +13,7 @@ func init() {
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
if hasRun(name, db) {
return nil
}
@ -35,13 +28,7 @@ func init() {
}
logbuch.Info("successfully deleted project label summary items")
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}

View File

@ -0,0 +1,50 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20211215-migrate_id_to_bigint-add_has_data_field"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if cfg.Db.IsMySQL() {
tx := db.Begin()
if err := tx.Exec("ALTER TABLE heartbeats MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE summary_items MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil {
return err
}
tx.Commit()
} else if cfg.Db.IsPostgres() {
// postgres does not have unsigned data types
// https://www.postgresql.org/docs/10/datatype-numeric.html
tx := db.Begin()
if err := tx.Exec("ALTER TABLE heartbeats ALTER COLUMN id TYPE BIGINT").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE summary_items ALTER COLUMN id TYPE BIGINT").Error; err != nil {
return err
}
tx.Commit()
} else {
// sqlite doesn't allow for changing column type easily
// https://stackoverflow.com/a/2083562/3112139
logbuch.Warn("unable to migrate id columns to bigint on %s", cfg.Db.Dialect)
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

30
migrations/shared.go Normal file
View File

@ -0,0 +1,30 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func hasRun(name string, db *gorm.DB) bool {
condition := "key = ?"
if config.Get().Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return true
}
return false
}
func setHasRun(name string, db *gorm.DB) {
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
logbuch.Error("failed to mark migration %s as run - %v", name, err)
}
}

View File

@ -24,6 +24,11 @@ func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*mode
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) {
args := p.Called(l)
return args.Get(0).(*models.ProjectLabel), args.Error(1)

View File

@ -9,7 +9,7 @@ import (
)
type Heartbeat struct {
ID uint `gorm:"primary_key" hash:"ignore"`
ID uint64 `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`

View File

@ -36,7 +36,7 @@ type Summary struct {
type SummaryItems []*SummaryItem
type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"`
ID uint64 `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-"`
Type uint8 `json:"-" gorm:"index:idx_type"`
@ -53,6 +53,7 @@ type SummaryViewModel struct {
*Summary
*SummaryParams
User *User
AvatarURL string
LanguageColors map[string]string
EditorColors map[string]string
OSColors map[string]string

View File

@ -1,7 +1,10 @@
package models
import (
"crypto/md5"
"fmt"
"regexp"
"strings"
"time"
)
@ -92,6 +95,18 @@ func (u *User) TZOffset() time.Duration {
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 {
return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat

View File

@ -9,11 +9,12 @@ import (
func TestUser_TZ(t *testing.T) {
sut1, sut2 := &User{Location: ""}, &User{Location: "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, pst, sut2.TZ())
assert.InDelta(t, time.Duration(offset*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(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
}

View File

@ -24,6 +24,9 @@ func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{UserID: userId}).
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) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{
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) {
var aliases []*models.Alias
if userId == "" {
return aliases, nil
}
if err := r.db.
Where(&models.Alias{
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) {
alias := &models.Alias{}
if userId == "" {
return nil, errors.New("invalid input")
}
if err := r.db.
Where(&models.Alias{
UserID: userId,

View File

@ -34,6 +34,9 @@ func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, e
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if userId == "" {
return mappings, nil
}
if err := r.db.
Where(&models.LanguageMapping{UserID: userId}).
Find(&mappings).Error; err != nil {

View File

@ -33,6 +33,9 @@ func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error)
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if userId == "" {
return []*models.ProjectLabel{}, nil
}
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).

View File

@ -35,6 +35,9 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
if key == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
@ -123,16 +126,16 @@ func (r *UserRepository) Count() (int64, error) {
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
if u, err := r.GetById(user.ID); err == nil && u != nil && u.ID != "" {
return u, false, nil
}
result := r.db.Create(user)
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return user, true, nil
}
return user, false, nil
return user, true, nil
}
func (r *UserRepository) Update(user *models.User) (*models.User, error) {

View File

@ -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"
// @Security ApiKeyAuth
// @Success 201
// @Router /plugins/errors [post]
// @Router /api/plugins/errors [post]
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var diagnostics models.Diagnostics

View File

@ -25,7 +25,7 @@ func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
// @Tags misc
// @Produce plain
// @Success 200 {string} string
// @Router /health [get]
// @Router /api/health [get]
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if sqlDb, err := h.db.DB(); err == nil {

View File

@ -60,7 +60,7 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /heartbeat [post]
// @Router /api/heartbeat [post]
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
@ -182,7 +182,7 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats [post]
// @Router /api/v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias1() {}
// @Summary Push a new heartbeat
@ -192,7 +192,7 @@ func (h *HeartbeatApiHandler) postAlias1() {}
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
// @Router /api/compat/wakatime/v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias2() {}
// @Summary Push a new heartbeat
@ -202,7 +202,7 @@ func (h *HeartbeatApiHandler) postAlias2() {}
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats [post]
// @Router /api/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias3() {}
// @Summary Push new heartbeats
@ -212,7 +212,7 @@ func (h *HeartbeatApiHandler) postAlias3() {}
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /heartbeats [post]
// @Router /api/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias4() {}
// @Summary Push new heartbeats
@ -222,7 +222,7 @@ func (h *HeartbeatApiHandler) postAlias4() {}
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats.bulk [post]
// @Router /api/v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias5() {}
// @Summary Push new heartbeats
@ -232,7 +232,7 @@ func (h *HeartbeatApiHandler) postAlias5() {}
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @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() {}
// @Summary Push new heartbeats
@ -242,5 +242,5 @@ func (h *HeartbeatApiHandler) postAlias6() {}
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats.bulk [post]
// @Router /api/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias7() {}

View File

@ -12,6 +12,7 @@ import (
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"runtime"
"sort"
"time"
)
@ -34,6 +35,10 @@ const (
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
DescAdminTotalUsers = "Total number of registered 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 {
@ -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
}

View File

@ -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"
// @Security ApiKeyAuth
// @Success 200 {object} models.Summary
// @Router /summary [get]
// @Router /api/summary [get]
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {

View File

@ -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 filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
// @Success 200 {object} v1.BadgeData
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
// @Router /api/compat/shields/v1/{user}/{interval}/{filter} [get]
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)

View File

@ -44,7 +44,7 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
// @Router /api/compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
values, _ := url.ParseQuery(r.URL.RawQuery)

View File

@ -44,7 +44,7 @@ func (h *ProjectsHandler) RegisterRoutes(router *mux.Router) {
// @Param q query string true "Query to filter projects by"
// @Security ApiKeyAuth
// @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) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {

View File

@ -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)
// @Security ApiKeyAuth
// @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) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User

View File

@ -51,8 +51,8 @@ func (h *StatusBarHandler) RegisterRoutes(router *mux.Router) {
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.StatusBarViewModel
// @Router /users/{user}/statusbar/today [get]
// @Success 200 {object} StatusBarViewModel
// @Router /api/users/{user}/statusbar/today [get]
func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {

View File

@ -53,7 +53,7 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Param end query string false "End date (e.g. '2021-02-08')"
// @Security ApiKeyAuth
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
// @Router /api/compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {

View File

@ -41,7 +41,7 @@ func (h *UsersHandler) RegisterRoutes(router *mux.Router) {
// @Param user path string true "User ID to fetch (or 'current')"
// @Security ApiKeyAuth
// @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) {
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {

120
routes/relay/relay.go Normal file
View 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() {}

View File

@ -52,6 +52,9 @@ func DefaultTemplateFuncs() template.FuncMap {
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
},
"avatarUrlTemplate": func() string {
return config.Get().App.AvatarURLTemplate
},
}
}

View File

@ -354,27 +354,23 @@ func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Reque
}
user := middlewares.GetPrincipal(r)
labelKey := r.PostFormValue("key")
labelValue := r.PostFormValue("value")
labelKey := r.PostFormValue("key") // label key
labelValue := r.PostFormValue("value") // project key
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
labels, err := h.projectLabelSrvc.GetByUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
if projectLabels, ok := labelMap[labelKey]; ok {
for _, l := range projectLabels {
if l.Label == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
return http.StatusOK, "label deleted successfully", ""
for _, l := range labels {
if l.Label == labelKey && l.ProjectKey == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
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) {
@ -651,7 +647,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
}
// labels
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
@ -660,11 +656,11 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
for _, l := range labelMap {
cl := &view.SettingsVMCombinedLabel{
Key: l[0].ProjectKey,
Key: l[0].Label,
Values: make([]string, len(l)),
}
for i, l1 := range l {
cl.Values[i] = l1.Label
cl.Values[i] = l1.ProjectKey
}
combinedLabels = append(combinedLabels, cl)
}

View File

@ -32,6 +32,8 @@ let icons = [
'twemoji:gear',
'eva:corner-right-down-fill',
'bi:heart-fill',
'fxemoji:running',
'ic:round-person'
]
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))

View File

@ -8,6 +8,7 @@ import (
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"strings"
"sync"
"time"
"github.com/muety/wakapi/models"
@ -16,20 +17,20 @@ import (
type HeartbeatService struct {
config *config.Config
cache *cache.Cache
cache2 *cache.Cache
eventBus *hub.Hub
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
entityCacheLock *sync.RWMutex
}
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
srv := &HeartbeatService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
cache2: cache.New(cache.NoExpiration, cache.NoExpiration),
eventBus: config.EventBus(),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
entityCacheLock: &sync.RWMutex{},
}
// 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 {
srv.updateEntityUserCacheByHeartbeat(heartbeat)
go srv.updateEntityUserCacheByHeartbeat(heartbeat)
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
}
@ -62,7 +63,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
filteredHeartbeats = append(filteredHeartbeats, hb)
hashes[hb.Hash] = true
}
srv.updateEntityUserCacheByHeartbeat(hb)
go srv.updateEntityUserCacheByHeartbeat(hb)
}
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) {
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
}
@ -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
}
@ -190,21 +193,27 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if entities, found := srv.cache2.Get(cacheKey); found {
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
if entities, found := srv.cache.Get(cacheKey); found {
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
// -> invalidate cache
srv.cache2.Delete(cacheKey)
// -> update cache instead of just invalidating it, because rebuilding is expensive here
srv.cache.Set(cacheKey, entitySet, cache.NoExpiration)
}
}
}
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
go srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
go srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
go srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
go srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
go srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
}
func (srv *HeartbeatService) notifyBatch(heartbeats []*models.Heartbeat) {

View File

@ -44,21 +44,37 @@ func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel
}
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)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labels[l.ProjectKey]; !ok {
labels[l.ProjectKey] = []*models.ProjectLabel{l}
if _, ok := labelsByProject[l.ProjectKey]; !ok {
labelsByProject[l.ProjectKey] = []*models.ProjectLabel{l}
} 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) {

View File

@ -62,6 +62,7 @@ type IProjectLabelService interface {
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
GetByUserGroupedInverted(string) (map[string][]*models.ProjectLabel, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(*models.ProjectLabel) error
}

View File

@ -50,7 +50,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
ID: uint(rand.Uint32()),
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
@ -60,7 +60,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime),
},
{
ID: uint(rand.Uint32()),
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
@ -70,7 +70,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
},
{
ID: uint(rand.Uint32()),
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
@ -375,7 +375,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
heartbeats := filter(from, to, suite.TestHeartbeats)
heartbeats = append(heartbeats, &models.Heartbeat{
ID: uint(rand.Uint32()),
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject2,
Language: TestLanguageGo,
@ -414,7 +414,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
heartbeats := filter(from, to, suite.TestHeartbeats)
heartbeats = append(heartbeats, &models.Heartbeat{
ID: uint(rand.Uint32()),
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject2,
Language: TestLanguageGo,

View File

@ -420,6 +420,13 @@ function hexToRgb(hex) {
} : null;
}
function showUserMenuPopup(event) {
const el = document.getElementById('user-menu-popup')
el.classList.remove('hidden')
el.classList.add('block')
event.stopPropagation()
}
function showApiKeyPopup(event) {
const el = document.getElementById('api-key-popup')
el.classList.remove('hidden')

File diff suppressed because one or more lines are too long

View File

@ -9,8 +9,10 @@
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
line-height: 1.15;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
}
/* Sections
@ -51,9 +53,12 @@ h1 {
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
box-sizing: content-box;
/* 1 */
height: 0;
/* 1 */
overflow: visible;
/* 2 */
}
/**
@ -62,8 +67,10 @@ hr {
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
font-family: monospace, monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/* Text-level semantics
@ -83,10 +90,13 @@ a {
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
border-bottom: none;
/* 1 */
text-decoration: underline;
/* 2 */
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted; /* 2 */
text-decoration: underline dotted;
/* 2 */
}
/**
@ -106,8 +116,10 @@ strong {
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
font-family: monospace, monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/**
@ -163,10 +175,14 @@ input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
line-height: 1.15;
/* 1 */
margin: 0;
/* 2 */
}
/**
@ -175,7 +191,8 @@ textarea {
*/
button,
input { /* 1 */
input {
/* 1 */
overflow: visible;
}
@ -185,7 +202,8 @@ input { /* 1 */
*/
button,
select { /* 1 */
select {
/* 1 */
text-transform: none;
}
@ -239,12 +257,18 @@ fieldset {
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
box-sizing: border-box;
/* 1 */
color: inherit;
/* 2 */
display: table;
/* 1 */
max-width: 100%;
/* 1 */
padding: 0;
/* 3 */
white-space: normal;
/* 1 */
}
/**
@ -394,8 +418,10 @@ ul {
*/
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 */
line-height: 1.5; /* 2 */
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 */
line-height: 1.5;
/* 2 */
}
/**
@ -427,10 +453,14 @@ html {
*,
::before,
::after {
box-sizing: border-box; /* 1 */
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: #e2e8f0; /* 2 */
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e2e8f0;
/* 2 */
}
/*
@ -723,6 +753,10 @@ video {
border-radius: 9999px;
}
.border-2 {
border-width: 2px;
}
.border {
border-width: 1px;
}
@ -831,6 +865,10 @@ video {
font-weight: 400;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
@ -999,6 +1037,10 @@ video {
margin-top: 4rem;
}
.mt-24 {
margin-top: 6rem;
}
.-ml-1 {
margin-left: -0.25rem;
}

View File

@ -32,7 +32,7 @@ var doc = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/compat/shields/v1/{user}/{interval}/{filter}": {
"/api/compat/shields/v1/{user}/{interval}/{filter}": {
"get": {
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
"produces": [
@ -90,7 +90,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}": {
"/api/compat/wakatime/v1/users/{user}": {
"get": {
"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": {
"security": [
{
@ -160,7 +160,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"/api/compat/wakatime/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
@ -193,7 +193,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"/api/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
@ -229,7 +229,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"/api/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
@ -271,7 +271,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"/api/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
{
@ -326,7 +326,7 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": {
"/api/compat/wakatime/v1/users/{user}/summaries": {
"get": {
"security": [
{
@ -393,7 +393,7 @@ var doc = `{
}
}
},
"/health": {
"/api/health": {
"get": {
"produces": [
"text/plain"
@ -413,7 +413,7 @@ var doc = `{
}
}
},
"/heartbeat": {
"/api/heartbeat": {
"post": {
"security": [
{
@ -446,7 +446,7 @@ var doc = `{
}
}
},
"/heartbeats": {
"/api/heartbeats": {
"post": {
"security": [
{
@ -482,7 +482,7 @@ var doc = `{
}
}
},
"/plugins/errors": {
"/api/plugins/errors": {
"post": {
"security": [
{
@ -515,7 +515,7 @@ var doc = `{
}
}
},
"/summary": {
"/api/summary": {
"get": {
"security": [
{
@ -580,7 +580,7 @@ var doc = `{
}
}
},
"/users/{user}/heartbeats": {
"/api/users/{user}/heartbeats": {
"post": {
"security": [
{
@ -613,7 +613,7 @@ var doc = `{
}
}
},
"/users/{user}/heartbeats.bulk": {
"/api/users/{user}/heartbeats.bulk": {
"post": {
"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": {
"security": [
{
@ -682,7 +717,7 @@ var doc = `{
}
}
},
"/v1/users/{user}/heartbeats.bulk": {
"/api/v1/users/{user}/heartbeats.bulk": {
"post": {
"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": {
@ -791,6 +978,9 @@ var doc = `{
},
"type": {
"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": {
"type": "object",
"properties": {

View File

@ -16,7 +16,7 @@
},
"basePath": "/api",
"paths": {
"/compat/shields/v1/{user}/{interval}/{filter}": {
"/api/compat/shields/v1/{user}/{interval}/{filter}": {
"get": {
"description": "Retrieve total time for a given entity (e.g. a project) within a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint). Requires public data access to be allowed.",
"produces": [
@ -74,7 +74,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}": {
"/api/compat/wakatime/v1/users/{user}": {
"get": {
"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": {
"security": [
{
@ -144,7 +144,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"/api/compat/wakatime/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
@ -177,7 +177,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"/api/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
@ -213,7 +213,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"/api/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
@ -255,7 +255,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"/api/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
{
@ -310,7 +310,7 @@
}
}
},
"/compat/wakatime/v1/users/{user}/summaries": {
"/api/compat/wakatime/v1/users/{user}/summaries": {
"get": {
"security": [
{
@ -377,7 +377,7 @@
}
}
},
"/health": {
"/api/health": {
"get": {
"produces": [
"text/plain"
@ -397,7 +397,7 @@
}
}
},
"/heartbeat": {
"/api/heartbeat": {
"post": {
"security": [
{
@ -430,7 +430,7 @@
}
}
},
"/heartbeats": {
"/api/heartbeats": {
"post": {
"security": [
{
@ -466,7 +466,7 @@
}
}
},
"/plugins/errors": {
"/api/plugins/errors": {
"post": {
"security": [
{
@ -499,7 +499,7 @@
}
}
},
"/summary": {
"/api/summary": {
"get": {
"security": [
{
@ -564,7 +564,7 @@
}
}
},
"/users/{user}/heartbeats": {
"/api/users/{user}/heartbeats": {
"post": {
"security": [
{
@ -597,7 +597,7 @@
}
}
},
"/users/{user}/heartbeats.bulk": {
"/api/users/{user}/heartbeats.bulk": {
"post": {
"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": {
"security": [
{
@ -666,7 +701,7 @@
}
}
},
"/v1/users/{user}/heartbeats.bulk": {
"/api/v1/users/{user}/heartbeats.bulk": {
"post": {
"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": {
@ -775,6 +962,9 @@
},
"type": {
"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": {
"type": "object",
"properties": {

View File

@ -49,6 +49,8 @@ definitions:
type: number
type:
type: string
user_agent:
type: string
type: object
models.Summary:
properties:
@ -198,6 +200,13 @@ definitions:
data:
$ref: '#/definitions/v1.StatsData'
type: object
v1.StatusBarViewModel:
properties:
cached_at:
type: string
data:
$ref: '#/definitions/v1.SummariesData'
type: object
v1.SummariesData:
properties:
categories:
@ -342,7 +351,7 @@ info:
title: Wakapi API
version: "1.0"
paths:
/compat/shields/v1/{user}/{interval}/{filter}:
/api/compat/shields/v1/{user}/{interval}/{filter}:
get:
description: Retrieve total time for a given entity (e.g. a project) within
a given range (e.g. one week) in a format compatible with [Shields.io](https://shields.io/endpoint).
@ -387,7 +396,7 @@ paths:
summary: Get badge data
tags:
- badges
/compat/wakatime/v1/users/{user}:
/api/compat/wakatime/v1/users/{user}:
get:
description: Mimics https://wakatime.com/developers#users
operationId: get-wakatime-user
@ -409,7 +418,7 @@ paths:
summary: Retrieve the given user
tags:
- wakatime
/compat/wakatime/v1/users/{user}/all_time_since_today:
/api/compat/wakatime/v1/users/{user}/all_time_since_today:
get:
description: Mimics https://wakatime.com/developers#all_time_since_today
operationId: get-all-time
@ -431,7 +440,7 @@ paths:
summary: Retrieve summary for all time
tags:
- wakatime
/compat/wakatime/v1/users/{user}/heartbeats:
/api/compat/wakatime/v1/users/{user}/heartbeats:
post:
consumes:
- application/json
@ -451,7 +460,7 @@ paths:
summary: Push a new heartbeat
tags:
- heartbeat
/compat/wakatime/v1/users/{user}/heartbeats.bulk:
/api/compat/wakatime/v1/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
@ -473,7 +482,7 @@ paths:
summary: Push new heartbeats
tags:
- heartbeat
/compat/wakatime/v1/users/{user}/projects:
/api/compat/wakatime/v1/users/{user}/projects:
get:
description: Mimics https://wakatime.com/developers#projects
operationId: get-wakatime-projects
@ -500,7 +509,7 @@ paths:
summary: Retrieve and fitler the user's projects
tags:
- wakatime
/compat/wakatime/v1/users/{user}/stats/{range}:
/api/compat/wakatime/v1/users/{user}/stats/{range}:
get:
description: Mimics https://wakatime.com/developers#stats
operationId: get-wakatimes-tats
@ -539,7 +548,7 @@ paths:
summary: Retrieve statistics for a given user
tags:
- wakatime
/compat/wakatime/v1/users/{user}/summaries:
/api/compat/wakatime/v1/users/{user}/summaries:
get:
description: Mimics https://wakatime.com/developers#summaries.
operationId: get-wakatime-summaries
@ -586,7 +595,7 @@ paths:
summary: Retrieve WakaTime-compatible summaries
tags:
- wakatime
/health:
/api/health:
get:
operationId: get-health
produces:
@ -599,7 +608,7 @@ paths:
summary: Check the application's health status
tags:
- misc
/heartbeat:
/api/heartbeat:
post:
consumes:
- application/json
@ -619,7 +628,7 @@ paths:
summary: Push a new heartbeat
tags:
- heartbeat
/heartbeats:
/api/heartbeats:
post:
consumes:
- application/json
@ -641,7 +650,7 @@ paths:
summary: Push new heartbeats
tags:
- heartbeat
/plugins/errors:
/api/plugins/errors:
post:
consumes:
- application/json
@ -661,7 +670,7 @@ paths:
summary: Push a new diagnostics object
tags:
- diagnostics
/summary:
/api/summary:
get:
operationId: get-summary
parameters:
@ -706,7 +715,7 @@ paths:
summary: Retrieve a summary
tags:
- summary
/users/{user}/heartbeats:
/api/users/{user}/heartbeats:
post:
consumes:
- application/json
@ -726,7 +735,7 @@ paths:
summary: Push a new heartbeat
tags:
- heartbeat
/users/{user}/heartbeats.bulk:
/api/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
@ -748,7 +757,30 @@ paths:
summary: Push new heartbeats
tags:
- 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:
consumes:
- application/json
@ -768,7 +800,7 @@ paths:
summary: Push a new heartbeat
tags:
- heartbeat
/v1/users/{user}/heartbeats.bulk:
/api/v1/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
@ -790,6 +822,107 @@ paths:
summary: Push new heartbeats
tags:
- 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:
ApiKeyAuth:
in: header

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "36595622-81dc-4f4a-826e-345ae63fc83b",
"_postman_id": "da93a75e-e931-4f00-80b8-428f0e7ae824",
"name": "Wakapi API Tests",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@ -251,6 +251,105 @@
}
},
"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": []
}
]
},

View File

@ -1,3 +1,15 @@
BEGIN TRANSACTION;
INSERT INTO "key_string_values" VALUES ('20210213-add_has_data_field','done');
INSERT INTO "key_string_values" VALUES ('20210221-add_created_date_column','done');
INSERT INTO "key_string_values" VALUES ('imprint','no content here');
INSERT INTO "key_string_values" VALUES ('20210411-add_imprint_content','done');
INSERT INTO "key_string_values" VALUES ('20210806-remove_persisted_project_labels','done');
INSERT INTO "key_string_values" VALUES ('20211215-migrate_id_to_bigint-add_has_data_field','done');
INSERT INTO "key_string_values" VALUES ('latest_total_time','0s');
INSERT INTO "key_string_values" VALUES ('latest_total_users','0');
COMMIT;
BEGIN TRANSACTION;
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
@ -11,4 +23,4 @@ INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
COMMIT;
COMMIT;

View File

@ -1,9 +1,7 @@
#!/bin/bash
if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Compiling."
go build
fi
echo "Compiling."
go build
if ! command -v newman &> /dev/null
then
@ -20,7 +18,8 @@ echo "Importing seed data ..."
sqlite3 wakapi_testing.db < data.sql
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 ..."
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
@ -32,9 +31,12 @@ echo ""
echo "Running test collection ..."
newman run "Wakapi API Tests.postman_collection.json"
exit_code=$?
echo "Shutting down Wakapi ..."
screen -S wakapi_testing -X quit
kill -TERM $pid
echo "Deleting database ..."
rm wakapi_testing.db
rm wakapi_testing.db
exit $exit_code

View File

@ -1,147 +1,53 @@
-- Created with SQLite DB Browser through:
-- File -> Export -> to SQL file
-- with options:
-- Overwrite, Keep original CREATE
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "users";
CREATE TABLE IF NOT EXISTS "users" (
"id" text,
"api_key" text UNIQUE,
"email" text,
"password" text,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"last_logged_in_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"share_data_max_days" integer DEFAULT 0,
"share_editors" numeric DEFAULT false,
"share_languages" numeric DEFAULT false,
"share_projects" numeric DEFAULT false,
"share_oss" numeric DEFAULT false,
"share_machines" numeric DEFAULT false,
"is_admin" numeric DEFAULT false,
"has_data" numeric DEFAULT false,
"wakatime_api_key" text,
"reset_token" text,
"location" text,
"reports_weekly" numeric DEFAULT false,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "key_string_values";
CREATE TABLE IF NOT EXISTS "key_string_values" (
"key" text,
"value" text,
PRIMARY KEY("key")
);
DROP TABLE IF EXISTS "summary_items";
CREATE TABLE IF NOT EXISTS "summary_items" (
"id" integer,
"summary_id" integer,
"type" integer,
"key" text,
"total" integer,
CONSTRAINT "fk_summaries_languages" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summary_items_summary" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_machines" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_projects" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_editors" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "aliases";
CREATE TABLE IF NOT EXISTS "aliases" (
"id" integer,
"type" integer NOT NULL,
"user_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
CONSTRAINT "fk_aliases_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
CREATE TABLE `aliases` (`id` integer,`type` integer NOT NULL,`user_id` text NOT NULL,`key` text NOT NULL,`value` text NOT NULL,PRIMARY KEY (`id`),CONSTRAINT `fk_aliases_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "diagnostics";
CREATE TABLE `diagnostics` (`id` integer,`user_id` text NOT NULL,`platform` text,`architecture` text,`plugin` text,`cli_version` text,`logs` text,`stack_trace` text,PRIMARY KEY (`id`),CONSTRAINT `fk_diagnostics_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "heartbeats";
CREATE TABLE IF NOT EXISTS "heartbeats" (
"id" integer,
"user_id" text NOT NULL,
"entity" text NOT NULL,
"type" text,
"category" text,
"project" text,
"branch" text,
"language" text,
"is_write" numeric,
"editor" text,
"operating_system" text,
"machine" text,
"time" timestamp,
"hash" varchar(17),
"origin" text,
"origin_id" text,
"created_at" timestamp,
CONSTRAINT "fk_heartbeats_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "summaries";
CREATE TABLE IF NOT EXISTS "summaries" (
"id" integer,
"user_id" text NOT NULL,
"from_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"to_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_summaries_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
CREATE TABLE `heartbeats` (`id` integer,`user_id` text NOT NULL,`entity` text NOT NULL,`type` text,`category` text,`project` text,`branch` text,`language` text,`is_write` numeric,`editor` text,`operating_system` text,`machine` text,`user_agent` text,`time` timestamp,`hash` varchar(17),`origin` text,`origin_id` text,`created_at` timestamp,PRIMARY KEY (`id`),CONSTRAINT `fk_heartbeats_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "key_string_values";
CREATE TABLE `key_string_values` (`key` text,`value` text,PRIMARY KEY (`key`));
DROP TABLE IF EXISTS "language_mappings";
CREATE TABLE IF NOT EXISTS "language_mappings" (
"id" integer,
"user_id" text NOT NULL,
"extension" varchar(16),
"language" varchar(64),
CONSTRAINT "fk_language_mappings_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX IF NOT EXISTS "idx_user_email" ON "users" (
"email"
);
DROP INDEX IF EXISTS "idx_type";
CREATE INDEX IF NOT EXISTS "idx_type" ON "summary_items" (
"type"
);
CREATE TABLE `language_mappings` (`id` integer,`user_id` text NOT NULL,`extension` varchar(16),`language` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_language_mappings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "project_labels";
CREATE TABLE `project_labels` (`id` integer,`user_id` text NOT NULL,`project_key` text,`label` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_project_labels_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "summaries";
CREATE TABLE "summaries" (`id` integer,`user_id` text NOT NULL,`from_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`to_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "summary_items";
CREATE TABLE `summary_items` (`id` integer,`summary_id` integer,`type` integer,`key` text,`total` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_editors` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_operating_systems` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_machines` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_languages` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "users";
CREATE TABLE `users` (`id` text,`api_key` text UNIQUE,`email` text,`location` text,`password` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,`last_logged_in_at` timestamp DEFAULT CURRENT_TIMESTAMP,`share_data_max_days` integer DEFAULT 0,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,PRIMARY KEY (`id`));
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX IF NOT EXISTS "idx_alias_type_key" ON "aliases" (
"type",
"key"
);
CREATE INDEX `idx_alias_type_key` ON `aliases`(`type`,`key`);
DROP INDEX IF EXISTS "idx_alias_user";
CREATE INDEX IF NOT EXISTS "idx_alias_user" ON "aliases" (
"user_id"
);
DROP INDEX IF EXISTS "idx_time";
CREATE INDEX IF NOT EXISTS "idx_time" ON "heartbeats" (
"time"
);
DROP INDEX IF EXISTS "idx_heartbeats_hash";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_heartbeats_hash" ON "heartbeats" (
"hash"
);
DROP INDEX IF EXISTS "idx_time_user";
CREATE INDEX IF NOT EXISTS "idx_time_user" ON "heartbeats" (
"user_id"
);
CREATE INDEX `idx_alias_user` ON `aliases`(`user_id`);
DROP INDEX IF EXISTS "idx_diagnostics_user";
CREATE INDEX `idx_diagnostics_user` ON `diagnostics`(`user_id`);
DROP INDEX IF EXISTS "idx_entity";
CREATE INDEX IF NOT EXISTS "idx_entity" ON "heartbeats" (
"entity"
);
CREATE INDEX `idx_entity` ON `heartbeats`(`entity`);
DROP INDEX IF EXISTS "idx_heartbeats_hash";
CREATE UNIQUE INDEX `idx_heartbeats_hash` ON `heartbeats`(`hash`);
DROP INDEX IF EXISTS "idx_language";
CREATE INDEX IF NOT EXISTS "idx_language" ON "heartbeats" (
"language"
);
DROP INDEX IF EXISTS "idx_time_summary_user";
CREATE INDEX IF NOT EXISTS "idx_time_summary_user" ON "summaries" (
"user_id",
"from_time",
"to_time"
);
CREATE INDEX `idx_language` ON `heartbeats`(`language`);
DROP INDEX IF EXISTS "idx_language_mapping_composite";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_language_mapping_composite" ON "language_mappings" (
"user_id",
"extension"
);
CREATE UNIQUE INDEX `idx_language_mapping_composite` ON `language_mappings`(`user_id`,`extension`);
DROP INDEX IF EXISTS "idx_language_mapping_user";
CREATE INDEX IF NOT EXISTS "idx_language_mapping_user" ON "language_mappings" (
"user_id"
);
CREATE INDEX `idx_language_mapping_user` ON `language_mappings`(`user_id`);
DROP INDEX IF EXISTS "idx_project_label_user";
CREATE INDEX `idx_project_label_user` ON `project_labels`(`user_id`);
DROP INDEX IF EXISTS "idx_time";
CREATE INDEX `idx_time` ON `heartbeats`(`time`);
DROP INDEX IF EXISTS "idx_time_summary_user";
CREATE INDEX `idx_time_summary_user` ON `summaries`(`user_id`,`from_time`,`to_time`);
DROP INDEX IF EXISTS "idx_time_user";
CREATE INDEX `idx_time_user` ON `heartbeats`(`user_id`);
DROP INDEX IF EXISTS "idx_type";
CREATE INDEX `idx_type` ON `summary_items`(`type`);
DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX `idx_user_email` ON `users`(`email`);
COMMIT;

32
utils/db.go Normal file
View 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
}

View File

@ -1 +1 @@
1.29.6
1.30.3

View File

@ -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>
</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>
</a>
@ -74,6 +74,7 @@
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; 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> &nbsp; <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> &nbsp; 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> &nbsp; GDPR-compliant</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Self-hosted</li>
</ul>
</div>

View File

@ -221,9 +221,9 @@
<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"
style="line-height: 1.8">
&#9656;&nbsp;<span class="font-semibold text-white">{{ $label.Key }}:</span>
&#9656;&nbsp;<span class="font-semibold text-white font-mono">{{ $label.Key }}:</span>
{{ 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="key" value="{{ $label.Key }}">
<input type="hidden" name="value" value="{{ $value }}">

View File

@ -19,6 +19,24 @@
</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="mx-1">
<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>
</a>
</div>
<div class="mx-1">
<form action="logout" method="post">
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
<div class="mx-1 flex items-center">
{{ if avatarUrlTemplate }}
<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?"/>
{{ 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>