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

Compare commits

..

26 Commits

Author SHA1 Message Date
625ca8215e fix: concurrent access to language mappings (resolve #83) 2021-01-07 10:52:49 +01:00
9e735eb47e fix: do not attempt to bind on ipv6 in docker 2021-01-07 10:04:36 +01:00
9766d8e903 feat: ability to choose number of top entities to display (resolve #81) 2021-01-05 12:41:01 +01:00
39c4777fc8 fix: crash on fail to listen 2021-01-05 11:28:51 +01:00
143c80b7b4 docs: update readme 2021-01-04 11:39:02 +01:00
72e42a9c42 feat: add ipv6 and tls support (resolve #79) 2020-12-12 22:07:00 +01:00
439a87dec9 docs: advanced setup instructions for client-side reverse proxy 2020-12-11 23:05:49 +01:00
e8067bb13e fix: crash when running aggregation job on schedule (fix #78)
chore: move from gocron to its maintained fork
2020-12-11 10:05:17 +01:00
219e969957 Merge branch 'master' of github.com:muety/wakapi into master 2020-12-02 23:16:48 +01:00
e610bb3ee3 fix: html footer rendering
chore: update chartjs
2020-12-02 23:16:12 +01:00
889edd7a33 Merge pull request #77 from notarock/master
Added missing closing parens in language mapping description
2020-12-01 07:48:04 +01:00
4161623c24 Added missing closing parens 2020-11-30 21:42:29 -05:00
67fe6eea56 chore: even more code smell 2020-11-28 20:57:13 +01:00
095fef4868 chore: minor code smell 2020-11-28 20:50:35 +01:00
a0e64ca955 chore: show badges on front page 2020-11-28 20:44:39 +01:00
903defca99 fix: commit missing files
chore: add favicon
2020-11-28 20:31:28 +01:00
16b9aa2282 feat: add front page (resolve #34) 2020-11-28 20:23:40 +01:00
4a78f66778 chore: set samesite attributes and configurable max age for cookies (resolve #75)
fix: sort entities by total time descending (resolve #74)
2020-11-21 22:30:56 +01:00
f4328c452f test: add essential unit tests for core functionality (resolve #6) 2020-11-14 12:30:45 +01:00
e806e5455e chore: attempt to exclude test and mock code from analysis 2020-11-08 13:13:48 +01:00
97e1fb27eb chore: attempt to configure coverage for sonar 2020-11-08 13:07:37 +01:00
ad8168801c test: add first few unit tests 2020-11-08 12:46:12 +01:00
35cdc7b485 refactor: define interface types for all services and repositories 2020-11-08 10:12:49 +01:00
664714de8f fix: filters 2020-11-07 18:39:36 +01:00
7befb82814 chore: remove clean up related parameters 2020-11-07 12:34:17 +01:00
2f12d8efde refactor: simplify summary generation (resolve #68) 2020-11-07 12:01:35 +01:00
77 changed files with 2629 additions and 678 deletions

View File

@ -1 +1,6 @@
.env
.env
config*.yml
!config.default.yml
*.db
*.exe
wakapi

3
.gitignore vendored
View File

@ -6,5 +6,6 @@ wakapi
build
*.exe
*.db
config.yml
config*.yml
!config.default.yml
config.ini

View File

@ -1,6 +1,6 @@
# Build Stage
FROM golang:1.13 AS build-env
FROM golang:1.15 AS build-env
WORKDIR /src
ADD ./go.mod .
RUN go mod download
@ -8,19 +8,11 @@ RUN go mod download
ADD . .
RUN go build -o wakapi
# Final Stage
# Run Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values using `-e` syntax.
# Available options are:
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD
# WAKAPI_DB_HOST
# WAKAPI_DB_PORT
# WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT
# WAKAPI_BASE_PATH
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM debian
WORKDIR /app
@ -32,13 +24,14 @@ ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.default.yml /app/config.yml
COPY --from=build-env /src/version.txt /app/
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
RUN sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' /app/config.yml
ADD static /app/static
ADD data /app/data

View File

@ -1,6 +1,6 @@
# 📈 wakapi
![](https://img.shields.io/github/license/muety/wakapi)
![](https://badges.fw-web.space/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
@ -21,9 +21,10 @@
![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
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!
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you like, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
## 👀 Hosted Service
## 👀 Demo
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
@ -52,25 +53,33 @@ To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. Howe
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
### Run with Docker
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
```bash
docker run -d -p 3000:3000 -e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" --name wakapi n1try/wakapi
```
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
## 🔧 Configuration
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.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
| `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` | Network address to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
@ -92,6 +101,10 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server`
You can view your API Key after logging in to the web interface.
### Optional: Client-side proxy
See the [advanced setup instructions](docs/advanced_setup.md).
## 🔵 Customization
### Aliases
@ -136,6 +149,9 @@ We recently introduced support for [Shields.io](https://shields.io) badges (see
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ⚠️ Important Note
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**

View File

@ -1,12 +1,14 @@
env: development
server:
listen_ipv4: 127.0.0.1
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
port: 3000
base_path: /
app:
cleanup: false # only edit, if you know what you're doing
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
custom_languages:
vue: Vue
@ -24,3 +26,4 @@ db:
security:
password_salt: # CHANGE !
insecure_cookies: false
cookie_max_age: 172800

View File

@ -14,6 +14,7 @@ import (
"gorm.io/gorm"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
)
@ -28,13 +29,10 @@ const (
SQLDialectSqlite = "sqlite3"
)
var (
cfg *Config
cFlag *string
)
var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct {
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"`
LanguageColors map[string]string `yaml:"-"`
@ -44,6 +42,7 @@ type securityConfig struct {
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
}
@ -58,9 +57,12 @@ type dbConfig struct {
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type Config struct {
@ -72,15 +74,34 @@ type Config struct {
Server serverConfig
}
func init() {
cFlag = flag.String("c", defaultConfigPath, "config file location")
flag.Parse()
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
}
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
return c.createCookie(name, "", path, -1)
}
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: path,
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
}
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
@ -158,6 +179,14 @@ func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.LanguageColors)
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
@ -221,6 +250,8 @@ func Get() *Config {
func Load() *Config {
config := &Config{}
flag.Parse()
maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
@ -244,6 +275,10 @@ func Load() *Config {
}
}
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
log.Fatalln("either of listen_ipv4 or listen_ipv6 must be set")
}
Set(config)
return Get()
}

66
config/config_test.go Normal file
View File

@ -0,0 +1,66 @@
package config
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
func TestConfig_IsDev(t *testing.T) {
assert.True(t, IsDev("dev"))
assert.True(t, IsDev("development"))
assert.False(t, IsDev("prod"))
assert.False(t, IsDev("production"))
assert.False(t, IsDev("anything else"))
}
func Test_mysqlConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "mysql",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Host,
c.Port,
c.Name,
"Local",
), mysqlConnectionString(c))
}
func Test_postgresConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "postgres",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
c.Host,
c.Port,
c.User,
c.Name,
c.Password,
), postgresConnectionString(c))
}
func Test_sqliteConnectionString(t *testing.T) {
c := &dbConfig{
Name: "test_name",
Dialect: "sqlite3",
}
assert.Equal(t, c.Name, sqliteConnectionString(c))
}

View File

@ -2,6 +2,7 @@ package config
const (
IndexTemplate = "index.tpl.html"
LoginTemplate = "login.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html"

9
config/utils.go Normal file
View File

@ -0,0 +1,9 @@
package config
func cloneStringMap(m map[string]string) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
m2[k] = v
}
return m2
}

519
coverage/coverage.out Normal file
View File

@ -0,0 +1,519 @@
mode: set
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
github.com/muety/wakapi/models/filters.go:32.49,33.21 1 0
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 0
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 0
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 0
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 0
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 0
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 0
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 0
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
github.com/muety/wakapi/models/filters.go:47.42,50.21 2 1
github.com/muety/wakapi/models/filters.go:53.2,53.20 1 1
github.com/muety/wakapi/models/filters.go:56.2,56.22 1 1
github.com/muety/wakapi/models/filters.go:59.2,59.21 1 1
github.com/muety/wakapi/models/filters.go:62.2,62.16 1 1
github.com/muety/wakapi/models/filters.go:66.2,66.12 1 1
github.com/muety/wakapi/models/filters.go:50.21,52.3 1 1
github.com/muety/wakapi/models/filters.go:53.20,55.3 1 0
github.com/muety/wakapi/models/filters.go:56.22,58.3 1 1
github.com/muety/wakapi/models/filters.go:59.21,61.3 1 0
github.com/muety/wakapi/models/filters.go:62.16,64.3 1 0
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/heartbeat.go:26.34,28.2 1 1
github.com/muety/wakapi/models/heartbeat.go:30.65,31.28 1 1
github.com/muety/wakapi/models/heartbeat.go:34.2,35.45 2 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.44 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,42.42 1 1
github.com/muety/wakapi/models/heartbeat.go:31.28,33.3 1 1
github.com/muety/wakapi/models/heartbeat.go:35.45,37.3 1 0
github.com/muety/wakapi/models/heartbeat.go:39.44,41.3 1 0
github.com/muety/wakapi/models/heartbeat.go:45.50,46.11 1 1
github.com/muety/wakapi/models/heartbeat.go:59.2,59.15 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.12 1 1
github.com/muety/wakapi/models/heartbeat.go:47.22,48.18 1 1
github.com/muety/wakapi/models/heartbeat.go:49.21,50.17 1 1
github.com/muety/wakapi/models/heartbeat.go:51.23,52.19 1 1
github.com/muety/wakapi/models/heartbeat.go:53.17,54.26 1 1
github.com/muety/wakapi/models/heartbeat.go:55.22,56.18 1 1
github.com/muety/wakapi/models/heartbeat.go:59.15,61.3 1 1
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/summary.go:29.27,33.2 1 0
github.com/muety/wakapi/models/summary.go:83.29,85.2 1 1
github.com/muety/wakapi/models/summary.go:87.37,94.2 6 1
github.com/muety/wakapi/models/summary.go:96.35,98.2 1 1
github.com/muety/wakapi/models/summary.go:100.57,108.2 1 1
github.com/muety/wakapi/models/summary.go:121.33,126.26 4 1
github.com/muety/wakapi/models/summary.go:133.2,133.37 1 1
github.com/muety/wakapi/models/summary.go:137.2,140.33 2 1
github.com/muety/wakapi/models/summary.go:126.26,127.30 1 1
github.com/muety/wakapi/models/summary.go:127.30,129.4 1 1
github.com/muety/wakapi/models/summary.go:133.37,135.3 1 0
github.com/muety/wakapi/models/summary.go:140.33,146.3 1 1
github.com/muety/wakapi/models/summary.go:149.45,154.30 3 1
github.com/muety/wakapi/models/summary.go:163.2,163.30 1 1
github.com/muety/wakapi/models/summary.go:154.30,155.47 1 1
github.com/muety/wakapi/models/summary.go:155.47,156.32 1 1
github.com/muety/wakapi/models/summary.go:159.4,159.9 1 1
github.com/muety/wakapi/models/summary.go:156.32,158.5 1 1
github.com/muety/wakapi/models/summary.go:166.73,168.55 2 1
github.com/muety/wakapi/models/summary.go:173.2,173.16 1 1
github.com/muety/wakapi/models/summary.go:168.55,169.31 1 1
github.com/muety/wakapi/models/summary.go:169.31,171.4 1 1
github.com/muety/wakapi/models/summary.go:176.88,178.55 2 1
github.com/muety/wakapi/models/summary.go:186.2,186.16 1 1
github.com/muety/wakapi/models/summary.go:178.55,179.31 1 1
github.com/muety/wakapi/models/summary.go:179.31,180.23 1 1
github.com/muety/wakapi/models/summary.go:183.4,183.46 1 1
github.com/muety/wakapi/models/summary.go:180.23,181.13 1 1
github.com/muety/wakapi/models/summary.go:189.79,190.33 1 1
github.com/muety/wakapi/models/summary.go:193.2,193.16 1 1
github.com/muety/wakapi/models/summary.go:190.33,192.3 1 1
github.com/muety/wakapi/models/summary.go:196.71,197.63 1 1
github.com/muety/wakapi/models/summary.go:237.2,243.10 6 1
github.com/muety/wakapi/models/summary.go:197.63,200.45 2 1
github.com/muety/wakapi/models/summary.go:209.3,209.31 1 1
github.com/muety/wakapi/models/summary.go:216.3,216.31 1 1
github.com/muety/wakapi/models/summary.go:233.3,233.16 1 1
github.com/muety/wakapi/models/summary.go:200.45,201.32 1 1
github.com/muety/wakapi/models/summary.go:206.4,206.14 1 1
github.com/muety/wakapi/models/summary.go:201.32,202.24 1 1
github.com/muety/wakapi/models/summary.go:202.24,204.6 1 1
github.com/muety/wakapi/models/summary.go:209.31,211.60 1 1
github.com/muety/wakapi/models/summary.go:211.60,213.5 1 1
github.com/muety/wakapi/models/summary.go:216.31,218.60 1 1
github.com/muety/wakapi/models/summary.go:218.60,219.55 1 1
github.com/muety/wakapi/models/summary.go:219.55,221.6 1 1
github.com/muety/wakapi/models/summary.go:221.11,229.6 1 1
github.com/muety/wakapi/models/summary.go:246.33,248.2 1 1
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 1
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 1
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
github.com/muety/wakapi/config/config.go:77.70,79.2 1 0
github.com/muety/wakapi/config/config.go:81.65,83.2 1 0
github.com/muety/wakapi/config/config.go:85.82,95.2 1 0
github.com/muety/wakapi/config/config.go:97.31,99.2 1 0
github.com/muety/wakapi/config/config.go:101.32,103.2 1 0
github.com/muety/wakapi/config/config.go:105.74,106.19 1 0
github.com/muety/wakapi/config/config.go:107.10,108.34 1 0
github.com/muety/wakapi/config/config.go:108.34,117.4 8 0
github.com/muety/wakapi/config/config.go:121.73,122.33 1 0
github.com/muety/wakapi/config/config.go:122.33,130.17 5 0
github.com/muety/wakapi/config/config.go:134.3,135.13 2 0
github.com/muety/wakapi/config/config.go:130.17,132.4 1 0
github.com/muety/wakapi/config/config.go:139.50,140.19 1 0
github.com/muety/wakapi/config/config.go:153.2,153.12 1 0
github.com/muety/wakapi/config/config.go:141.23,145.5 1 0
github.com/muety/wakapi/config/config.go:146.26,149.5 1 0
github.com/muety/wakapi/config/config.go:150.24,151.48 1 0
github.com/muety/wakapi/config/config.go:156.53,166.2 1 1
github.com/muety/wakapi/config/config.go:168.56,176.2 1 1
github.com/muety/wakapi/config/config.go:178.54,180.2 1 1
github.com/muety/wakapi/config/config.go:182.60,184.2 1 0
github.com/muety/wakapi/config/config.go:186.59,188.2 1 0
github.com/muety/wakapi/config/config.go:190.29,192.2 1 1
github.com/muety/wakapi/config/config.go:194.27,196.16 2 0
github.com/muety/wakapi/config/config.go:199.2,202.16 3 0
github.com/muety/wakapi/config/config.go:206.2,206.22 1 0
github.com/muety/wakapi/config/config.go:196.16,198.3 1 0
github.com/muety/wakapi/config/config.go:202.16,204.3 1 0
github.com/muety/wakapi/config/config.go:209.45,219.16 4 0
github.com/muety/wakapi/config/config.go:223.2,223.57 1 0
github.com/muety/wakapi/config/config.go:227.2,227.30 1 0
github.com/muety/wakapi/config/config.go:231.2,231.15 1 0
github.com/muety/wakapi/config/config.go:219.16,221.3 1 0
github.com/muety/wakapi/config/config.go:223.57,225.3 1 0
github.com/muety/wakapi/config/config.go:227.30,229.3 1 0
github.com/muety/wakapi/config/config.go:234.38,235.43 1 0
github.com/muety/wakapi/config/config.go:239.2,239.15 1 0
github.com/muety/wakapi/config/config.go:235.43,237.3 1 0
github.com/muety/wakapi/config/config.go:242.26,244.2 1 0
github.com/muety/wakapi/config/config.go:246.20,248.2 1 0
github.com/muety/wakapi/config/config.go:250.21,257.96 4 0
github.com/muety/wakapi/config/config.go:261.2,268.52 4 0
github.com/muety/wakapi/config/config.go:272.2,272.47 1 0
github.com/muety/wakapi/config/config.go:278.2,278.70 1 0
github.com/muety/wakapi/config/config.go:282.2,283.14 2 0
github.com/muety/wakapi/config/config.go:257.96,259.3 1 0
github.com/muety/wakapi/config/config.go:268.52,270.3 1 0
github.com/muety/wakapi/config/config.go:272.47,273.14 1 0
github.com/muety/wakapi/config/config.go:273.14,275.4 1 0
github.com/muety/wakapi/config/config.go:278.70,280.3 1 0
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
github.com/muety/wakapi/config/utils.go:3.60,5.22 2 0
github.com/muety/wakapi/config/utils.go:8.2,8.11 1 0
github.com/muety/wakapi/config/utils.go:5.22,7.3 1 0
github.com/muety/wakapi/utils/color.go:8.93,10.41 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.41,11.50 1 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.56,24.45 3 1
github.com/muety/wakapi/utils/common.go:27.2,27.40 1 1
github.com/muety/wakapi/utils/common.go:24.45,26.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
github.com/muety/wakapi/utils/summary.go:37.2,37.22 1 0
github.com/muety/wakapi/utils/summary.go:14.28,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.32,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.31,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.32,22.24 1 0
github.com/muety/wakapi/utils/summary.go:23.31,24.23 1 0
github.com/muety/wakapi/utils/summary.go:25.32,26.42 1 0
github.com/muety/wakapi/utils/summary.go:27.33,28.43 1 0
github.com/muety/wakapi/utils/summary.go:29.35,30.43 1 0
github.com/muety/wakapi/utils/summary.go:31.26,32.21 1 0
github.com/muety/wakapi/utils/summary.go:33.10,34.39 1 0
github.com/muety/wakapi/utils/summary.go:40.73,47.56 5 0
github.com/muety/wakapi/utils/summary.go:61.2,68.8 2 0
github.com/muety/wakapi/utils/summary.go:47.56,49.3 1 0
github.com/muety/wakapi/utils/summary.go:49.8,51.17 2 0
github.com/muety/wakapi/utils/summary.go:55.3,56.17 2 0
github.com/muety/wakapi/utils/summary.go:51.17,53.4 1 0
github.com/muety/wakapi/utils/summary.go:56.17,58.4 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/auth.go:18.79,20.54 2 0
github.com/muety/wakapi/utils/auth.go:24.2,26.16 3 0
github.com/muety/wakapi/utils/auth.go:30.2,32.45 3 0
github.com/muety/wakapi/utils/auth.go:35.2,36.32 2 0
github.com/muety/wakapi/utils/auth.go:20.54,22.3 1 0
github.com/muety/wakapi/utils/auth.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/auth.go:32.45,34.3 1 0
github.com/muety/wakapi/utils/auth.go:39.65,41.54 2 0
github.com/muety/wakapi/utils/auth.go:45.2,46.30 2 0
github.com/muety/wakapi/utils/auth.go:41.54,43.3 1 0
github.com/muety/wakapi/utils/auth.go:49.97,51.16 2 0
github.com/muety/wakapi/utils/auth.go:55.2,55.104 1 0
github.com/muety/wakapi/utils/auth.go:59.2,59.19 1 0
github.com/muety/wakapi/utils/auth.go:51.16,53.3 1 0
github.com/muety/wakapi/utils/auth.go:55.104,57.3 1 0
github.com/muety/wakapi/utils/auth.go:62.30,64.2 1 0
github.com/muety/wakapi/utils/auth.go:66.56,70.2 3 0
github.com/muety/wakapi/utils/auth.go:73.53,75.2 1 0
github.com/muety/wakapi/utils/auth.go:77.55,80.16 3 0
github.com/muety/wakapi/utils/auth.go:83.2,83.16 1 0
github.com/muety/wakapi/utils/auth.go:80.16,82.3 1 0
github.com/muety/wakapi/utils/auth.go:86.43,91.2 4 0
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/alias.go:16.77,21.2 1 1
github.com/muety/wakapi/services/alias.go:25.63,27.16 2 1
github.com/muety/wakapi/services/alias.go:30.2,30.12 1 1
github.com/muety/wakapi/services/alias.go:27.16,29.3 1 1
github.com/muety/wakapi/services/alias.go:33.108,34.32 1 1
github.com/muety/wakapi/services/alias.go:40.2,41.46 2 1
github.com/muety/wakapi/services/alias.go:46.2,46.19 1 1
github.com/muety/wakapi/services/alias.go:34.32,35.53 1 1
github.com/muety/wakapi/services/alias.go:35.53,37.4 1 1
github.com/muety/wakapi/services/alias.go:41.46,42.48 1 1
github.com/muety/wakapi/services/alias.go:42.48,44.4 1 1
github.com/muety/wakapi/services/alias.go:49.60,50.43 1 1
github.com/muety/wakapi/services/alias.go:53.2,53.14 1 1
github.com/muety/wakapi/services/alias.go:50.43,52.3 1 1
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
github.com/muety/wakapi/services/language_mapping.go:17.118,23.2 1 0
github.com/muety/wakapi/services/language_mapping.go:25.86,27.2 1 0
github.com/muety/wakapi/services/language_mapping.go:29.96,30.53 1 0
github.com/muety/wakapi/services/language_mapping.go:34.2,35.16 2 0
github.com/muety/wakapi/services/language_mapping.go:38.2,39.22 2 0
github.com/muety/wakapi/services/language_mapping.go:30.53,32.3 1 0
github.com/muety/wakapi/services/language_mapping.go:35.16,37.3 1 0
github.com/muety/wakapi/services/language_mapping.go:42.92,45.16 3 0
github.com/muety/wakapi/services/language_mapping.go:49.2,49.33 1 0
github.com/muety/wakapi/services/language_mapping.go:52.2,52.22 1 0
github.com/muety/wakapi/services/language_mapping.go:45.16,47.3 1 0
github.com/muety/wakapi/services/language_mapping.go:49.33,51.3 1 0
github.com/muety/wakapi/services/language_mapping.go:55.109,57.16 2 0
github.com/muety/wakapi/services/language_mapping.go:61.2,62.20 2 0
github.com/muety/wakapi/services/language_mapping.go:57.16,59.3 1 0
github.com/muety/wakapi/services/language_mapping.go:65.82,69.2 3 0
github.com/muety/wakapi/services/language_mapping.go:71.74,74.2 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
github.com/muety/wakapi/services/summary.go:53.2,53.66 1 1
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
github.com/muety/wakapi/services/summary.go:53.66,55.3 1 0
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:16.73,21.2 1 0
github.com/muety/wakapi/services/user.go:23.74,25.2 1 0
github.com/muety/wakapi/services/user.go:27.72,29.2 1 0
github.com/muety/wakapi/services/user.go:31.58,33.2 1 0
github.com/muety/wakapi/services/user.go:35.88,42.93 2 0
github.com/muety/wakapi/services/user.go:48.2,48.38 1 0
github.com/muety/wakapi/services/user.go:42.93,44.3 1 0
github.com/muety/wakapi/services/user.go:44.8,46.3 1 0
github.com/muety/wakapi/services/user.go:51.73,53.2 1 0
github.com/muety/wakapi/services/user.go:55.78,58.2 2 0
github.com/muety/wakapi/services/user.go:60.79,62.2 1 0
github.com/muety/wakapi/services/user.go:64.106,66.96 2 0
github.com/muety/wakapi/services/user.go:71.2,71.68 1 0
github.com/muety/wakapi/services/user.go:66.96,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0

29
docs/advanced_setup.md Normal file
View File

@ -0,0 +1,29 @@
# Advanced Setup
## Optional: Client-side proxy
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API Wakapi in this case.
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up webserver / reverse proxy.
1. [Install Caddy](https://caddyserver.com/)
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
1. Create a Caddyfile
```
# /etc/caddy/Caddyfile
http://localhost:8070 {
reverse_proxy * {
to https://wakapi.dev # <-- substitute your own Wakapi host here
header_up Host {http.reverse_proxy.upstream.host}
header_down -Server
}
}
```
1. Restart Caddy
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
1. Update `~/.wakatime.cfg`
* Set `api_url = http://localhost:8070/api/heartbeat`
1. Done

3
go.mod
View File

@ -3,11 +3,11 @@ module github.com/muety/wakapi
go 1.13
require (
github.com/go-co-op/gocron v0.3.3
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/configor v1.2.0
github.com/joho/godotenv v1.3.0
github.com/kr/text v0.2.0 // indirect
@ -16,6 +16,7 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0

10
go.sum
View File

@ -64,6 +64,8 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
@ -204,8 +206,6 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -382,12 +382,15 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
@ -452,6 +455,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -554,6 +558,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=

113
main.go
View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
@ -28,22 +29,22 @@ var (
)
var (
aliasRepository *repositories.AliasRepository
heartbeatRepository *repositories.HeartbeatRepository
userRepository *repositories.UserRepository
languageMappingRepository *repositories.LanguageMappingRepository
summaryRepository *repositories.SummaryRepository
keyValueRepository *repositories.KeyValueRepository
aliasRepository repositories.IAliasRepository
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
languageMappingService *services.LanguageMappingService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
aliasService services.IAliasService
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
summaryService services.ISummaryService
aggregationService services.IAggregationService
keyValueService services.IKeyValueService
)
// TODO: Refactor entire project to be structured after business domains
@ -102,12 +103,15 @@ func main() {
// TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(userService)
homeHandler := routes.NewHomeHandler()
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
@ -140,10 +144,11 @@ func main() {
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Summary Routes
@ -174,15 +179,71 @@ func main() {
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
// Listen HTTP
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s := &http.Server{
Handler: router,
Addr: portString,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
listen(router)
}
func listen(handler http.Handler) {
var s4, s6 *http.Server
// IPv4
if config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{
Handler: handler,
Addr: bindString4,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
log.Printf("Listening on %+s\n", portString)
s.ListenAndServe()
// IPv6
if config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{
Handler: handler,
Addr: bindString6,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
if config.UseTLS() {
if s4 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
} else {
if s4 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
}
<-make(chan interface{}, 1)
}
func runDatabaseMigrations() {

View File

@ -19,12 +19,12 @@ import (
type AuthenticateMiddleware struct {
config *conf.Config
userSrvc *services.UserService
cache *cache.Cache
userSrvc services.IUserService
whitelistPaths []string
}
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
@ -58,7 +58,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized)
} else {
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
}
return
@ -107,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return nil, err
}
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) {
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, &m.userSrvc) {
return nil, errors.New("invalid password")
}
@ -115,11 +115,11 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
}
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.IUserService) bool {
if utils.IsMd5(user.Password) {
if utils.CompareMd5(user.Password, login.Password, "") {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login)
(*userServiceRef).MigrateMd5Password(user, login)
return true
}
return false

View File

@ -0,0 +1,81 @@
package middlewares
import (
"encoding/base64"
"fmt"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"testing"
)
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_GetFromCache(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
sut.cache.SetDefault(testApiKey, testUser)
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
userServiceMock.AssertNotCalled(t, "GetUserByKey", mock.Anything)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
mockRequest := &http.Request{
Header: http.Header{
// 'Basic' prefix missing here
"Authorization": []string{fmt.Sprintf("%s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
// TODO: somehow test cookie auth function

View File

@ -22,7 +22,7 @@ func init() {
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPostMigrations {
log.Printf("running migration '%s'\n", m.name)
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}

View File

@ -100,7 +100,7 @@ func init() {
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPreMigrations {
log.Printf("running migration '%s'\n", m.name)
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}

15
mocks/alias_repository.go Normal file
View File

@ -0,0 +1,15 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type AliasRepositoryMock struct {
mock.Mock
}
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1)
}

24
mocks/alias_service.go Normal file
View File

@ -0,0 +1,24 @@
package mocks
import (
"github.com/stretchr/testify/mock"
)
type AliasServiceMock struct {
mock.Mock
}
func (m *AliasServiceMock) LoadUserAliases(s string) error {
args := m.Called(s)
return args.Error(0)
}
func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (string, error) {
args := m.Called(s, u, s2)
return args.String(0), args.Error(1)
}
func (m *AliasServiceMock) IsInitialized(s string) bool {
args := m.Called(s)
return args.Bool(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type HeartbeatServiceMock struct {
mock.Mock
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats)
return args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type SummaryRepositoryMock struct {
mock.Mock
}
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
args := m.Called(summary)
return args.Error(0)
}
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
args := m.Called(user, time, time2)
return args.Get(0).([]*models.Summary), args.Error(1)
}
func (m *SummaryRepositoryMock) GetLastByUser() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
args := m.Called(s)
return args.Error(0)
}

50
mocks/user_service.go Normal file
View File

@ -0,0 +1,50 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type UserServiceMock struct {
mock.Mock
}
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
args := m.Called(signup)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1)
}

View File

@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
return &Filters{OS: key}
case SummaryLanguage:
return &Filters{Project: key}
return &Filters{Language: key}
case SummaryEditor:
return &Filters{Project: key}
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Project: key}
return &Filters{Machine: key}
}
return &Filters{}
}

View File

@ -24,7 +24,7 @@ type Heartbeat struct {
}
func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
}
func (h *Heartbeat) Augment(languageMappings map[string]string) {
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
}
h.Language, _ = languageMappings[ending]
}
func (h *Heartbeat) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = h.Project
case SummaryEditor:
key = h.Editor
case SummaryLanguage:
key = h.Language
case SummaryOS:
key = h.OperatingSystem
case SummaryMachine:
key = h.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

53
models/heartbeat_test.go Normal file
View File

@ -0,0 +1,53 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestHeartbeat_Valid_Success(t *testing.T) {
sut := &Heartbeat{
User: &User{
ID: "johndoe@example.org",
},
UserID: "johndoe@example.org",
Time: CustomTime(time.Now()),
}
assert.True(t, sut.Valid())
}
func TestHeartbeat_Valid_MissingUser(t *testing.T) {
sut := &Heartbeat{
Time: CustomTime(time.Now()),
}
assert.False(t, sut.Valid())
}
func TestHeartbeat_Augment(t *testing.T) {
testMappings := map[string]string{
"py": "Python3",
}
sut := &Heartbeat{
Entity: "~/dev/file.py",
Language: "Python",
}
sut.Augment(testMappings)
assert.Equal(t, "Python3", sut.Language)
}
func TestHeartbeat_GetKey(t *testing.T) {
sut := &Heartbeat{
Project: "wakapi",
}
assert.Equal(t, "wakapi", sut.GetKey(SummaryProject))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryOS))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryMachine))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryLanguage))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
}

38
models/heartbeats.go Normal file
View File

@ -0,0 +1,38 @@
package models
import "sort"
type Heartbeats []*Heartbeat
func (h Heartbeats) Len() int {
return len(h)
}
func (h Heartbeats) Less(i, j int) bool {
return h[i].Time.T().Before(h[j].Time.T())
}
func (h Heartbeats) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *Heartbeats) Sorted() *Heartbeats {
sort.Sort(h)
return h
}
func (h *Heartbeats) First() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[0]
}
func (h *Heartbeats) Last() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[h.Len()-1]
}

View File

@ -24,6 +24,11 @@ type KeyStringValue struct {
Value string `gorm:"type:text"`
}
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time
func (j *CustomTime) UnmarshalJSON(b []byte) error {
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
func (j CustomTime) T() time.Time {
return time.Time(j)
}
func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0
}

View File

@ -1,6 +1,7 @@
package models
import (
"sort"
"time"
)
@ -34,18 +35,20 @@ func Intervals() []string {
const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
@ -75,16 +78,27 @@ type SummaryParams struct {
Recompute bool
}
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Projects))
sort.Sort(sort.Reverse(s.Machines))
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
return s
}
func (s *Summary) Types() []uint8 {
return SummaryTypes()
}
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
return map[uint8]*[]*SummaryItem{
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
return map[uint8]*SummaryItems{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
@ -178,3 +192,65 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
}
return timeSum
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
s.Projects = processAliases(s.Projects)
s.Editors = processAliases(s.Editors)
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
return s
}
func (s SummaryItems) Len() int {
return len(s)
}
func (s SummaryItems) Less(i, j int) bool {
return s[i].Total < s[j].Total
}
func (s SummaryItems) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

195
models/summary_test.go Normal file
View File

@ -0,0 +1,195 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration / time.Second,
},
},
}
sut.FillUnknown()
itemLists := [][]*SummaryItem{
sut.Machines,
sut.OperatingSystems,
sut.Languages,
sut.Editors,
}
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
}
}
func TestSummary_TotalTimeBy(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
}
func TestSummary_TotalTimeByFilters(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
filters1 := &Filters{Project: "wakapi"}
filters2 := &Filters{Project: "wakapi", Language: "Go"} // filters have OR logic
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration1+testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
}
func TestSummary_WithResolvedAliases(t *testing.T) {
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
var resolver AliasResolver = func(t uint8, k string) string {
switch t {
case SummaryProject:
switch k {
case "wakapi-mobile":
return "wakapi"
}
case SummaryLanguage:
switch k {
case "Java 8":
return "Java"
}
}
return k
}
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "wakapi-mobile",
Total: testDuration2 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration3 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Java 8",
Total: testDuration4 / time.Second,
},
},
}
sut = sut.WithResolvedAliases(resolver)
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
assert.Len(t, sut.Projects, 2)
assert.Len(t, sut.Languages, 1)
assert.Empty(t, sut.Editors)
assert.Empty(t, sut.OperatingSystems)
assert.Empty(t, sut.Machines)
}
func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2,
},
{
Type: SummaryProject,
Key: "anchr-mobile",
Total: testDuration3,
},
},
}
sut = sut.Sorted()
assert.Equal(t, testDuration3, sut.Projects[0].Total)
assert.Equal(t, testDuration1, sut.Projects[1].Total)
assert.Equal(t, testDuration2, sut.Projects[2].Total)
}

View File

@ -26,6 +26,11 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"`
}
type TimeByUser struct {
User string
Time CustomTime
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat

16
models/view/login.go Normal file
View File

@ -0,0 +1,16 @@
package view
type LoginViewModel struct {
Success string
Error string
}
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
s.Success = m
return s
}
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
s.Error = m
return s
}

View File

@ -34,18 +34,14 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
return heartbeats, nil
}
// Will return *models.Heartbeat object with only user_id and time fields filled
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.
Table("heartbeats").
Select("user_id, min(time) as time").
Where("user_id IN (?)", userIds).
Group("user_id").
Scan(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, min(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {

View File

@ -0,0 +1,46 @@
package repositories
import (
"github.com/muety/wakapi/models"
"time"
)
type IAliasRepository interface {
GetByUser(string) ([]*models.Alias, error)
}
type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueRepository interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingRepository interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
}
type IUserRepository interface {
GetById(string) (*models.User, error)
GetByApiKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error)
}

View File

@ -39,17 +39,14 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
return summaries, nil
}
// Will return *models.Index objects with only user_id and to_time filled
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Table("summaries").
Select("user_id, max(to_time) as to_time").
Group("user_id").
Scan(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(to_time) as time").
Joins("left join summaries on users.id = summaries.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *SummaryRepository) DeleteByUser(userId string) error {

View File

@ -2,7 +2,7 @@ package v1
import (
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
@ -18,17 +18,16 @@ const (
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
aliasSrvc *services.AliasService
config *config2.Config
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: config2.Get(),
config: conf.Get(),
}
}
@ -97,9 +96,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user,
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,7 +2,7 @@ package v1
import (
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -13,14 +13,14 @@ import (
)
type AllTimeHandler struct {
summarySrvc *services.SummaryService
config *config2.Config
config *conf.Config
summarySrvc services.ISummaryService
}
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{
summarySrvc: summaryService,
config: config2.Get(),
config: conf.Get(),
}
}
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false,
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -3,7 +3,7 @@ package v1
import (
"errors"
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
@ -14,14 +14,14 @@ import (
)
type SummariesHandler struct {
summarySrvc *services.SummaryService
config *config2.Config
config *conf.Config
summarySrvc services.ISummaryService
}
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{
summarySrvc: summaryService,
config: config2.Get(),
config: conf.Get(),
}
}
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
)
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -14,11 +14,11 @@ import (
type HeartbeatHandler struct {
config *conf.Config
heartbeatSrvc *services.HeartbeatService
languageMappingSrvc *services.LanguageMappingService
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler {
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{
config: conf.Get(),
heartbeatSrvc: heartbeatService,

View File

@ -4,28 +4,21 @@ import (
"fmt"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type HomeHandler struct {
config *conf.Config
userSrvc *services.UserService
config *conf.Config
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewHomeHandler(userService *services.UserService) *HomeHandler {
func NewHomeHandler() *HomeHandler {
return &HomeHandler{
config: conf.Get(),
userSrvc: userService,
config: conf.Get(),
}
}
@ -42,129 +35,6 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
}
func (h *HomeHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.Security.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
}
func (h *HomeHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
func (h *HomeHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
}
func (h *HomeHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
return &view.HomeViewModel{
Success: r.URL.Query().Get("success"),

View File

@ -10,10 +10,10 @@ import (
type ImprintHandler struct {
config *conf.Config
keyValueSrvc *services.KeyValueService
keyValueSrvc services.IKeyValueService
}
func NewImprintHandler(keyValueService *services.KeyValueService) *ImprintHandler {
func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
return &ImprintHandler{
config: conf.Get(),
keyValueSrvc: keyValueService,

159
routes/login.go Normal file
View File

@ -0,0 +1,159 @@
package routes
import (
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
"time"
)
type LoginHandler struct {
config *conf.Config
userSrvc services.IUserService
}
func NewLoginHandler(userService services.IUserService) *LoginHandler {
return &LoginHandler{
config: conf.Get(),
userSrvc: userService,
}
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, &h.userSrvc) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -10,7 +10,7 @@ import (
"strings"
)
func init() {
func Init() {
loadTemplates()
}

View File

@ -10,21 +10,20 @@ import (
"github.com/muety/wakapi/utils"
"log"
"net/http"
"net/url"
"strconv"
)
type SettingsHandler struct {
config *conf.Config
userSrvc *services.UserService
summarySrvc *services.SummaryService
aggregationSrvc *services.AggregationService
languageMappingSrvc *services.LanguageMappingService
userSrvc services.IUserService
summarySrvc services.ISummaryService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService, summaryService *services.SummaryService, aggregationService *services.AggregationService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
return &SettingsHandler{
config: conf.Get(),
summarySrvc: summaryService,
@ -99,15 +98,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.Security.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
}
@ -178,7 +169,7 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
return
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
}

View File

@ -10,11 +10,11 @@ import (
)
type SummaryHandler struct {
summarySrvc *services.SummaryService
config *conf.Config
summarySrvc services.ISummaryService
}
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
config: conf.Get(),
@ -59,7 +59,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary),
LanguageColors: utils.FilterLanguageColors(h.config.App.GetLanguageColors(), summary),
ApiKey: user.ApiKey,
}
@ -72,9 +72,12 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
return nil, err, http.StatusBadRequest
}
summary, err := h.summarySrvc.PostProcessWrapped(
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -6,7 +6,7 @@ import (
"runtime"
"time"
"github.com/jasonlvhit/gocron"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
@ -16,12 +16,12 @@ const (
type AggregationService struct {
config *config.Config
userService *UserService
summaryService *SummaryService
heartbeatService *HeartbeatService
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
}
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
return &AggregationService{
config: config.Get(),
userService: userService,
@ -43,8 +43,9 @@ func (srv *AggregationService) Schedule() {
log.Fatalf("failed to run aggregation jobs: %v\n", err)
}
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil)
<-gocron.Start()
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
s.StartBlocking()
}
func (srv *AggregationService) Run(userIds map[string]bool) error {
@ -59,12 +60,19 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
go srv.persistWorker(summaries)
}
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
return srv.trigger(jobs, userIds)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
} else {
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
@ -99,57 +107,59 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
users = allUsers
}
latestSummaries, err := srv.summaryService.GetLatestByUser()
// Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil {
log.Println(err)
return err
}
userSummaryTimes := make(map[string]time.Time)
for _, s := range latestSummaries {
userSummaryTimes[s.UserID] = s.ToTime.T()
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
log.Println(err)
return err
}
missingUserIDs := make([]string, 0)
for _, u := range users {
if _, ok := userSummaryTimes[u.ID]; !ok {
missingUserIDs = append(missingUserIDs, u.ID)
// Build actual lookup table from it
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
for _, e := range firstUserHeartbeatTimes {
firstUserHeartbeatLookup[e.User] = e.Time
}
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
generateUserJobs(e.User, e.Time.T(), jobs)
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
// Case 2: User has no aggregated summaries, yet, but has heartbeats
// -> Spawn jobs to create summaries from their first heartbeat to now
generateUserJobs(e.User, t.T(), jobs)
}
}
firstHeartbeats, err := srv.heartbeatService.GetFirstUserHeartbeats(missingUserIDs)
if err != nil {
log.Println(err)
return err
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
// Case 3: User doesn't have heartbeats at all
// -> Nothing to do
}
return nil
}
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
var from, to time.Time
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var to time.Time
// Go to next day of either user's first heartbeat or latest aggregation
from.Add(-1 * time.Second)
from = time.Date(
from.Year(),
from.Month(),
from.Day()+aggregateIntervalDays,
0, 0, 0, 0,
from.Location(),
)
// Iteratively aggregate per-day summaries until end of yesterday is reached
end := getStartOfToday().Add(-1 * time.Second)
if lastAggregation.Hour() == 0 {
from = lastAggregation
} else {
from = time.Date(
lastAggregation.Year(),
lastAggregation.Month(),
lastAggregation.Day()+aggregateIntervalDays,
0, 0, 0, 0,
lastAggregation.Location(),
)
}
for from.Before(end) && to.Before(end) {
to = time.Date(
from.Year(),

View File

@ -10,10 +10,10 @@ import (
type AliasService struct {
config *config.Config
repository *repositories.AliasRepository
repository repositories.IAliasRepository
}
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
return &AliasService{
config: config.Get(),
repository: aliasRepo,

63
services/alias_test.go Normal file
View File

@ -0,0 +1,63 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"testing"
)
type AliasServiceTestSuite struct {
suite.Suite
TestUserId string
AliasRepository *mocks.AliasRepositoryMock
}
func (suite *AliasServiceTestSuite) SetupSuite() {
suite.TestUserId = "johndoe@example.org"
aliases := []*models.Alias{
{
Type: models.SummaryProject,
UserID: suite.TestUserId,
Key: "wakapi",
Value: "wakapi-mobile",
},
}
aliasRepoMock := new(mocks.AliasRepositoryMock)
aliasRepoMock.On("GetByUser", suite.TestUserId).Return(aliases, nil)
aliasRepoMock.On("GetByUser", mock.AnythingOfType("string")).Return([]*models.Alias{}, assert.AnError)
suite.AliasRepository = aliasRepoMock
}
func TestAliasServiceTestSuite(t *testing.T) {
suite.Run(t, new(AliasServiceTestSuite))
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
sut := NewAliasService(suite.AliasRepository)
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
assert.Equal(suite.T(), "wakapi", result1)
assert.Nil(suite.T(), err1)
assert.Equal(suite.T(), "wakapi", result2)
assert.Nil(suite.T(), err2)
assert.Equal(suite.T(), "anchr", result3)
assert.Nil(suite.T(), err3)
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
sut := NewAliasService(suite.AliasRepository)
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
assert.Empty(suite.T(), result)
assert.Error(suite.T(), err)
}

View File

@ -8,17 +8,13 @@ import (
"github.com/muety/wakapi/models"
)
const (
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
)
type HeartbeatService struct {
config *config.Config
repository *repositories.HeartbeatRepository
languageMappingSrvc *LanguageMappingService
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
config: config.Get(),
repository: heartbeatRepo,
@ -38,8 +34,8 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID)
}
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
return srv.repository.GetFirstByUsers(userIds)
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers()
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {

View File

@ -8,10 +8,10 @@ import (
type KeyValueService struct {
config *config.Config
repository *repositories.KeyValueRepository
repository repositories.IKeyValueRepository
}
func NewKeyValueService(keyValueRepo *repositories.KeyValueRepository) *KeyValueService {
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
return &KeyValueService{
config: config.Get(),
repository: keyValueRepo,

View File

@ -10,11 +10,11 @@ import (
type LanguageMappingService struct {
config *config.Config
repository *repositories.LanguageMappingRepository
cache *cache.Cache
repository repositories.ILanguageMappingRepository
}
func NewLanguageMappingService(languageMappingsRepo *repositories.LanguageMappingRepository) *LanguageMappingService {
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
return &LanguageMappingService{
config: config.Get(),
repository: languageMappingsRepo,
@ -68,6 +68,7 @@ func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error
return err
}
func (srv LanguageMappingService) getServerMappings() map[string]string {
return srv.config.App.CustomLanguages
func (srv *LanguageMappingService) getServerMappings() map[string]string {
// https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it
return srv.config.App.GetCustomLanguages()
}

58
services/services.go Normal file
View File

@ -0,0 +1,58 @@
package services
import (
"github.com/muety/wakapi/models"
"time"
)
type IAggregationService interface {
Schedule()
Run(map[string]bool) error
}
type IAliasService interface {
LoadUserAliases(string) error
GetAliasOrDefault(string, uint8, string) (string, error)
IsInitialized(string) bool
}
type IHeartbeatService interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingService interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
ResolveByUser(string) (map[string]string, error)
Create(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(mapping *models.LanguageMapping) error
}
type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
Insert(*models.Summary) error
}
type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
}

View File

@ -4,14 +4,12 @@ import (
"crypto/md5"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"math"
"sort"
"strconv"
"time"
"github.com/muety/wakapi/models"
)
const HeartbeatDiffThreshold = 2 * time.Minute
@ -19,12 +17,14 @@ const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
config *config.Config
cache *cache.Cache
repository *repositories.SummaryRepository
heartbeatService *HeartbeatService
aliasService *AliasService
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
}
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
return &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
}
}
type Interval struct {
Start time.Time
End time.Time
}
// Public summary generation methods
// TODO: simplify!
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary
var cacheKey string
if recompute {
existingSummaries = make([]*models.Summary, 0)
} else {
cacheKey = getHash([]time.Time{from, to}, user)
if result, ok := srv.cache.Get(cacheKey); ok {
return result.(*models.Summary), nil
}
summaries, err := srv.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
existingSummaries = summaries
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
missingIntervals := getMissingIntervals(from, to, existingSummaries)
// Wrap alias resolution
resolve := func(t uint8, k string) string {
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
return s
}
heartbeats := make([]*models.Heartbeat, 0)
// Initialize alias resolver service
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
}
// Get actual summary
s, err := f(from, to, user)
if err != nil {
return nil, err
}
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
missingIntervals := srv.getMissingIntervals(from, to, summaries)
for _, interval := range missingIntervals {
hb, err := srv.heartbeatService.GetAllWithin(interval.Start, interval.End, user)
if err != nil {
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
summaries = append(summaries, s)
} else {
return nil, err
}
heartbeats = append(heartbeats, hb...)
}
// Merge existing and newly generated summary snippets
summary, err := srv.mergeSummaries(summaries)
if err != nil {
return nil, err
}
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
// Initialize and fetch data
var heartbeats models.Heartbeats
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
heartbeats = rawHeartbeats
} else {
return nil, err
}
types := models.SummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, typedAggregations)
}
// Aggregate raw heartbeats by types in parallel and collect them
var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem
var editorItems []*models.SummaryItem
var osItems []*models.SummaryItem
var machineItems []*models.SummaryItem
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
}
c := make(chan models.SummaryItemContainer)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, user, c)
}
for i := 0; i < len(types); i++ {
item := <-c
item := <-typedAggregations
switch item.Type {
case models.SummaryProject:
projectItems = item.Items
@ -101,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
machineItems = item.Items
}
}
close(c)
realFrom, realTo := from, to
if len(existingSummaries) > 0 {
realFrom = existingSummaries[0].FromTime.T()
realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
for _, summary := range existingSummaries {
summary.FillUnknown()
}
}
if len(heartbeats) > 0 {
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
realFrom = t1
}
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
realTo = t2
}
if heartbeats.Len() > 0 {
from = time.Time(heartbeats.First().Time)
to = time.Time(heartbeats.Last().Time)
}
aggregatedSummary := &models.Summary{
summary := &models.Summary{
UserID: user.ID,
FromTime: models.CustomTime(realFrom),
ToTime: models.CustomTime(realTo),
FromTime: models.CustomTime(from),
ToTime: models.CustomTime(to),
Projects: projectItems,
Languages: languageItems,
Editors: editorItems,
@ -133,123 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
Machines: machineItems,
}
allSummaries := []*models.Summary{aggregatedSummary}
allSummaries = append(allSummaries, existingSummaries...)
//summary.FillUnknown()
summary, err := mergeSummaries(allSummaries)
if err != nil {
return nil, err
}
if cacheKey != "" {
srv.cache.SetDefault(cacheKey, summary)
}
return summary, nil
return summary.Sorted(), nil
}
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) {
if err != nil {
return nil, err
}
return srv.PostProcess(summary), nil
}
// CRUD methods
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary {
updatedSummary := &models.Summary{
ID: summary.ID,
UserID: summary.UserID,
FromTime: summary.FromTime,
ToTime: summary.ToTime,
}
processAliases := func(origin []*models.SummaryItem) []*models.SummaryItem {
target := make([]*models.SummaryItem, 0)
findItem := func(key string) *models.SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &models.SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
updatedSummary.Projects = processAliases(summary.Projects)
updatedSummary.Editors = processAliases(summary.Editors)
updatedSummary.Languages = processAliases(summary.Languages)
updatedSummary.OperatingSystems = processAliases(summary.OperatingSystems)
updatedSummary.Machines = processAliases(summary.Machines)
return updatedSummary
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
return srv.repository.Insert(summary)
}
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
return srv.repository.GetByUserWithin(user, from, to)
}
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
return srv.repository.GetLatestByUser()
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
return srv.repository.GetLastByUser()
}
func (srv *SummaryService) DeleteByUser(userId string) error {
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
func (srv *SummaryService) Insert(summary *models.Summary) error {
return srv.repository.Insert(summary)
}
// Private summary generation and utility methods
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
durations := make(map[string]time.Duration)
for i, h := range heartbeats {
var key string
switch summaryType {
case models.SummaryProject:
key = h.Project
case models.SummaryEditor:
key = h.Editor
case models.SummaryLanguage:
key = h.Language
case models.SummaryOS:
key = h.OperatingSystem
case models.SummaryMachine:
key = h.Machine
}
if key == "" {
key = models.UnknownSummaryKey
}
key := h.GetKey(summaryType)
if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0)
@ -287,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
}
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
if len(existingSummaries) == 0 {
return []*Interval{{from, to}}
}
intervals := make([]*Interval, 0)
// Pre
if from.Before(existingSummaries[0].FromTime.T()) {
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(existingSummaries)-1; i++ {
t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
}
}
// Post
if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
}
return intervals
}
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 {
return nil, errors.New("no summaries given")
}
@ -353,11 +249,11 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
maxTime = s.ToTime.T()
}
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
}
finalSummary.FromTime = models.CustomTime(minTime)
@ -366,7 +262,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
return finalSummary, nil
}
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
items := make(map[string]*models.SummaryItem)
// Build map from existing
@ -396,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
return itemList
}
func getHash(times []time.Time, user *models.User) string {
digest := md5.New()
for _, t := range times {
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
if len(summaries) == 0 {
return []*models.Interval{{from, to}}
}
intervals := make([]*models.Interval, 0)
// Pre
if from.Before(summaries[0].FromTime.T()) {
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(summaries)-1; i++ {
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
}
}
// Post
if to.After(summaries[len(summaries)-1].ToTime.T()) {
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
}
return intervals
}
func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New()
for _, a := range args {
digest.Write([]byte(a))
}
digest.Write([]byte(user.ID))
return string(digest.Sum(nil))
}

290
services/summary_test.go Normal file
View File

@ -0,0 +1,290 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"math/rand"
"strings"
"testing"
"time"
)
const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
TestEditorGoland = "GoLand"
TestEditorIntellij = "idea"
TestEditorVscode = "vscode"
TestOsLinux = "Linux"
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
MinUnixTime1 = 1601510400000 * 1e6
)
type SummaryServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
}
func (suite *SummaryServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId}
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
},
}
}
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
}
func TestSummaryServiceTestSuite(t *testing.T) {
suite.Run(t, new(SummaryServiceTestSuite))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), from, result.FromTime.T())
assert.Equal(suite.T(), to, result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assert.Empty(suite.T(), result.Projects)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assertNumAllItems(suite.T(), 1, result, "")
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T())
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e")
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
summaries []*models.Summary
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(20 * time.Minute)),
ToTime: models.CustomTime(to.Add(-6 * time.Hour)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(to.Add(-6 * time.Hour)),
ToTime: models.CustomTime(to),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject2,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 2)
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.AliasService.On("LoadUserAliases", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
filtered = append(filtered, h)
}
}
return filtered
}
func assertNumAllItems(t *testing.T, expected int, summary *models.Summary, except string) {
if !strings.Contains(except, "p") {
assert.Len(t, summary.Projects, expected)
}
if !strings.Contains(except, "e") {
assert.Len(t, summary.Editors, expected)
}
if !strings.Contains(except, "l") {
assert.Len(t, summary.Languages, expected)
}
if !strings.Contains(except, "o") {
assert.Len(t, summary.OperatingSystems, expected)
}
if !strings.Contains(except, "m") {
assert.Len(t, summary.Machines, expected)
}
}

View File

@ -10,10 +10,10 @@ import (
type UserService struct {
Config *config.Config
repository *repositories.UserRepository
repository repositories.IUserRepository
}
func NewUserService(userRepo *repositories.UserRepository) *UserService {
func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{
Config: config.Get(),
repository: userRepo,

3
sonar-project.properties Normal file
View File

@ -0,0 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**
sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -1,4 +1,3 @@
const SHOW_TOP_N = 10
const CHART_TARGET_SIZE = 200
const projectsCanvas = document.getElementById('chart-projects')
@ -17,7 +16,16 @@ const containers = [projectContainer, osContainer, editorContainer, languageCont
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
topNPickers.forEach(e => {
const idx = parseInt(e.attributes['data-entity'].value)
e.max = Math.min(data[idx].length, 10)
e.value = e.max
})
let charts = []
let showTopN = []
let resizeCount = 0
String.prototype.toHHMMSS = function () {
@ -38,7 +46,7 @@ String.prototype.toHHMMSS = function () {
return hours + ':' + minutes + ':' + seconds
}
function draw() {
function draw(subselection) {
function getTooltipOptions(key, type) {
return {
mode: 'single',
@ -47,19 +55,26 @@ function draw() {
let idx = type === 'pie' ? item.index : item.datasetIndex
let d = wakapiData[key][idx]
return `${d.key}: ${d.total.toString().toHHMMSS()}`
}
},
title: () => 'Total Time'
}
}
}
charts.forEach(c => c.destroy())
function shouldUpdate(index) {
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
}
let projectChart = !projectsCanvas.classList.contains('hidden')
charts
.filter((c, i) => shouldUpdate(i))
.forEach(c => c.destroy())
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
? new Chart(projectsCanvas.getContext('2d'), {
type: 'horizontalBar',
data: {
datasets: wakapiData.projects
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => {
return {
label: p.key,
@ -87,18 +102,18 @@ function draw() {
})
: null
let osChart = !osCanvas.classList.contains('hidden')
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
? new Chart(osCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => p.key)
},
options: {
@ -109,18 +124,18 @@ function draw() {
})
: null
let editorChart = !editorsCanvas.classList.contains('hidden')
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
? new Chart(editorsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
}],
labels: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => p.key)
},
options: {
@ -131,18 +146,18 @@ function draw() {
})
: null
let languageChart = !languagesCanvas.classList.contains('hidden')
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
? new Chart(languagesCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
}],
labels: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => p.key)
},
options: {
@ -153,18 +168,18 @@ function draw() {
})
: null
let machineChart = !machinesCanvas.classList.contains('hidden')
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
? new Chart(machinesCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
}],
labels: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => p.key)
},
options: {
@ -179,13 +194,14 @@ function draw() {
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()
if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()
}
}
function setTopLabels() {
[...document.getElementsByClassName('top-label')]
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
function parseTopN() {
showTopN = topNPickers.map(e => parseInt(e.value))
}
function togglePlaceholders(mask) {
@ -203,7 +219,7 @@ function togglePlaceholders(mask) {
}
function getPresentDataMask() {
return data.map(list => list ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0)
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
}
function getContainer(chart) {
@ -300,7 +316,12 @@ window.addEventListener('click', function (event) {
})
window.addEventListener('load', function () {
setTopLabels()
topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()
draw([parseInt(e.attributes['data-entity'].value)])
}))
parseTopN()
togglePlaceholders(getPresentDataMask())
draw()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#eee"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/><path d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm-.743-.55M28.93 94.535c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zm-.575-.618M31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm0 0M34.573 101.373c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm0 0M39.073 103.324c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm0 0M44.016 103.685c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm0 0M48.614 102.903c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "assets/images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -2,10 +2,11 @@ package utils
import (
"errors"
"github.com/stretchr/testify/assert"
"testing"
)
func TestParseUserAgent(t *testing.T) {
func TestCommon_ParseUserAgent(t *testing.T) {
tests := []struct {
in string
outOs string
@ -38,10 +39,11 @@ func TestParseUserAgent(t *testing.T) {
},
}
for i, test := range tests {
if os, editor, err := ParseUserAgent(test.in); os != test.outOs || editor != test.outEditor || !checkErr(test.outError, err) {
t.Errorf("[%d] Unexpected result of parsing '%s'; got '%v', '%v', '%v'", i, test.in, os, editor, err)
}
for _, test := range tests {
os, editor, err := ParseUserAgent(test.in)
assert.True(t, checkErr(err, test.outError))
assert.Equal(t, test.outOs, os)
assert.Equal(t, test.outEditor, editor)
}
}

View File

@ -13,13 +13,3 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
log.Printf("error while writing json response: %v", err)
}
}
func ClearCookie(w http.ResponseWriter, name string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Secure: secure,
HttpOnly: true,
})
}

View File

@ -1 +1 @@
1.15.1
1.18.1

View File

@ -1,14 +1,2 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
<script>
const languageColors = {{ .LanguageColors | json }}
let wakapiData = {}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
</script>
<script src="assets/app.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>

View File

@ -2,7 +2,10 @@
<title>Wakapi Coding Statistics</title>
<base href="{{ getBasePath }}/">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
<link rel="icon" data-emoji="📊" type="image/png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
<link rel="manifest" href="assets/site.webmanifest">
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet">
<link href="assets/app.css" rel="stylesheet">

6
views/header.tpl.html Normal file
View File

@ -0,0 +1,6 @@
<header class="flex justify-between mb-10">
<a id="logo-container" class="text-2xl font-semibold text-white inline-block" href="">
<span>&#x1f4ca;</span>
<span>Wakapi</span>
</a>
</header>

View File

@ -4,6 +4,9 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>

View File

@ -3,35 +3,62 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1>
</div>
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
{{ template "alerts.tpl.html" . }}
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑 &nbsp;Login</a>
</div>
</div>
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-12">
<form action="login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Enter your username" minlength="3" required autofocus>
<div class="flex flex-col text-white">
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span class="text-green-700">Your</span> Coding Time 🕓</h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks any anyone else.</p>
<div class="flex justify-center mt-4 mb-8 space-x-2">
<a href="login">
<button type="button" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!</button>
</a>
<a href="https://github.com/muety/wakapi#%EF%B8%8F-server-setup" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it</button>
</a>
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
<img alt="GitHub Icon" src="assets/images/ghicon.svg" width="22px">
</button>
</a>
</div>
<div class="flex justify-center my-8">
<img alt="App screenshot" src="assets/images/screenshot.png">
</div>
<div class="flex flex-col items-center mt-10">
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Features</h1>
<div class="mt-4 text-lg">
<ul>
<li>&nbsp; 100 % free and open-source</li>
<li>&nbsp; Built by developers for developers</li>
<li>&nbsp; Fancy statistics and plots</li>
<li>&nbsp; Cool badges for readmes</li>
<li>&nbsp; Intuitive REST API</li>
<li>&nbsp; Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
<li>&nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
<li>&nbsp; Self-hosted</li>
</ul>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
name="password" placeholder="******" minlength="6" required>
</div>
<div class="flex justify-between">
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div>
<div class="flex justify-center space-x-2 mt-12">
<img alt="License badge" src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Go version badge" src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Wakapi coding time badge" src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
</div>
</div>
</main>

46
views/login.tpl.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="flex items-center justify-center">
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Login</h1>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-10">
<form action="login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Enter your username" minlength="3" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
name="password" placeholder="******" minlength="6" required>
</div>
<div class="flex justify-between">
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

View File

@ -4,18 +4,21 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div class="flex items-center justify-between max-w-xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
</div>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-lg mt-8">
<div class="flex flex-col flex-grow max-w-xl mt-8">
<div class="w-full my-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Change Password
@ -72,7 +75,7 @@
</div>
<div class="text-gray-300 text-sm mb-4 mt-6">
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.)
</div>
{{ if .LanguageMappings }}

View File

@ -5,6 +5,8 @@
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<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"
id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
@ -64,9 +66,13 @@
<div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Projects</span>
<span id="project-top-label" class="top-label"></span>
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1">Projects</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="project-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="project-top-picker" data-entity="0" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-projects"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -77,9 +83,13 @@
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Operating Systems</span>
<span id="os-top-label" class="top-label"></span>
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1">Operating Systems</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="os-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="os-top-picker" data-entity="1" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -90,9 +100,13 @@
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Languages</span>
<span id="language-top-label" class="top-label"></span>
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="language-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="language-top-picker" data-entity="3" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -103,9 +117,13 @@
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Editors</span>
<span id="editor-top-label" class="top-label"></span>
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="editor-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -116,9 +134,13 @@
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Machines</span>
<span id="machine-top-label" class="top-label"></span>
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1">Machines</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="machine-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -133,6 +155,19 @@
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
<script>
const languageColors = {{ .LanguageColors | json }}
const wakapiData = {}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
</script>
<script src="assets/app.js"></script>
</body>
</html>