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

Compare commits

...

45 Commits

Author SHA1 Message Date
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
8ddd9904a0 refactor: alert handling 2020-11-06 21:19:54 +01:00
78874566a4 chore: introduce constants for db dialects
chore: go fmt
2020-11-06 17:20:26 +01:00
e269b37b0e feat: add ability to regenerate summaries
fix: database cascade settings
chore: debug log mode for gorm queries is back
2020-11-06 17:09:41 +01:00
e6a04cc76d chore: remove cleanup functionality
chore: minor code changes
2020-11-06 14:07:07 +01:00
cb8f68df82 chore: add quick start scripts for spinning up dev database container 2020-11-03 10:32:18 +01:00
b4d2ee7d16 fix: not creating language mappings table due to broken type definition in users model (resolve #69)
chore: introduce foreign key constraints
2020-11-03 10:26:32 +01:00
1224024913 fix: postgres connection (resolve #70) 2020-11-03 10:02:59 +01:00
8efc3854ab refactor: migrate to latest gorm version
refactor: language mappings implementation
2020-11-01 20:14:10 +01:00
755cabb5f4 refactor: introduce repositories as an additional layer of abstraction to allow for better testability 2020-11-01 16:56:36 +01:00
96ff490d8d fix: crash when calling badge endpoint with no filters applied (resolve #66) 2020-11-01 16:04:30 +01:00
68e66298b8 fix: postprocessing for alias keys not corresponding to an actual entity 2020-11-01 13:09:39 +01:00
c2d30826f6 fix: critical fixes related to alias resolution 2020-11-01 12:50:59 +01:00
861c81e414 docs: fix endpoint paths in readme (thanks @mlunax) 2020-10-27 21:06:41 +01:00
892d265c4d chore: update version 2020-10-26 23:10:15 +01:00
e19761337f Merge branch 'notarock/62' 2020-10-26 22:34:50 +01:00
3f973a28ea Fixed custom rule settings CSS 2020-10-26 00:27:07 -04:00
86fc751e58 Cache rules per user, invalidate cache on update 2020-10-25 23:19:16 -04:00
178c417757 Keep the old configuration rules behaviour 2020-10-25 23:02:28 -04:00
395d039d41 General cleanup and fixed PR comments 2020-10-25 22:18:33 -04:00
fdf2289f8e MVP for custom rules support 2020-10-25 21:51:06 -04:00
06b3fdd17c Improved Dockerfile and docker-compose for dev 2020-10-25 02:21:41 -04:00
4506493353 docs: fix badges 2020-10-18 13:42:59 +02:00
11728b80ac docs: update url 2020-10-17 13:37:43 +02:00
b7c7817923 docs: display latest release as badge 2020-10-16 17:07:35 +02:00
c78ee5465c chore: display database type 2020-10-16 16:58:16 +02:00
4336d732c9 docs: update readme 2020-10-16 16:51:11 +02:00
102 changed files with 3821 additions and 1233 deletions

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,9 +1,12 @@
# Build Stage
FROM golang:1.13 AS build-env
ADD . /src
RUN cd /src && go build -o wakapi
WORKDIR /src
ADD ./go.mod .
RUN go mod download
ADD . .
RUN go build -o wakapi
# Final Stage
@ -45,4 +48,4 @@ ADD wait-for-it.sh .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh
ENTRYPOINT ./wait-for-it.sh

View File

@ -1,11 +1,16 @@
# 📈 wakapi
[![](http://img.shields.io/liberapay/receives/muety.svg?logo=liberapay&style=flat-square)](https://liberapay.com/muety/)
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
![](https://badges.fw-web.space/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
![GitHub issues](https://img.shields.io/github/issues/muety/wakapi)
![GitHub last commit](https://img.shields.io/github/last-commit/muety/wakapi)
[![Say thanks](https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg)](https://saythanks.io/to/n1try)
[![](https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay)](https://liberapay.com/muety/)
![GitHub go.mod Go version](https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
@ -18,10 +23,10 @@
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!
## 👀 Demo
🔥 **New:** There is a hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
## 👀 Hosted Service
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕
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
## ⚙️ Prerequisites
**On the server side:**
@ -47,25 +52,31 @@ To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartb
**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
```
```bash
docker run -d -p 3000:3000 --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" -coverprofile=coverage/coverage.out go test ./...
```
## 🔧 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.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 |
@ -111,8 +122,8 @@ The following API endpoints are available. A more detailed Swagger documentation
* `POST /api/heartbeat`
* `GET /api/summary`
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/compat/wakatime/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health`
## ⤴️ Prometheus Export

View File

@ -6,7 +6,6 @@ server:
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 +23,4 @@ db:
security:
password_salt: # CHANGE !
insecure_cookies: false
cookie_max_age: 172800

View File

@ -3,13 +3,18 @@ package config
import (
"encoding/json"
"flag"
"fmt"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
migrate "github.com/rubenv/sql-migrate"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
)
@ -18,15 +23,16 @@ const (
defaultConfigPath = "config.yml"
defaultConfigPathLegacy = "config.ini"
defaultEnvConfigPathLegacy = ".env"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
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:"-"`
@ -36,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:"-"`
}
@ -64,9 +71,24 @@ 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 {
@ -75,30 +97,15 @@ func (c *Config) IsDev() bool {
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
case "sqlite3":
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/sqlite3",
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d migrations\n", n)
return nil
}
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.Alias{})
db.AutoMigrate(&models.Heartbeat{})
db.AutoMigrate(&models.Summary{})
db.AutoMigrate(&models.SummaryItem{})
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
db.AutoMigrate(&models.SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.LanguageMapping{})
return nil
}
}
@ -111,7 +118,8 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
sqlDb, _ := db.DB()
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
if err != nil {
return err
}
@ -121,6 +129,49 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
}
}
func (c *dbConfig) GetDialector() gorm.Dialector {
switch c.Dialect {
case SQLDialectMysql:
return mysql.New(mysql.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
})
case SQLDialectPostgres:
return postgres.New(postgres.Config{
DSN: postgresConnectionString(c),
})
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
}
return nil
}
func mysqlConnectionString(config *dbConfig) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
@ -184,6 +235,8 @@ func Get() *Config {
func Load() *Config {
config := &Config{}
flag.Parse()
maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
@ -192,7 +245,6 @@ func Load() *Config {
config.Version = readVersion()
config.App.LanguageColors = readLanguageColors()
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),

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

@ -59,7 +59,7 @@ func migrateLegacyConfig() error {
}
if dbType == "" {
dbType = "sqlite3"
dbType = SQLDialectSqlite
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
@ -76,8 +76,6 @@ func migrateLegacyConfig() error {
basePath = basePathEnv
}
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
@ -89,7 +87,6 @@ func migrateLegacyConfig() error {
config := &Config{
Env: env,
App: appConfig{
CleanUp: cleanUp,
CustomLanguages: customLangs,
},
Security: securityConfig{

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"

511
coverage/coverage.out Normal file
View File

@ -0,0 +1,511 @@
mode: set
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/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/models.go:3.14,5.2 0 1
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 0
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 0
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 0
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 0
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
github.com/muety/wakapi/models/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/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/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/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/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/config/config.go:74.70,76.2 1 0
github.com/muety/wakapi/config/config.go:78.65,80.2 1 0
github.com/muety/wakapi/config/config.go:82.82,92.2 1 0
github.com/muety/wakapi/config/config.go:94.31,96.2 1 0
github.com/muety/wakapi/config/config.go:98.74,99.19 1 0
github.com/muety/wakapi/config/config.go:100.10,101.34 1 0
github.com/muety/wakapi/config/config.go:101.34,110.4 8 0
github.com/muety/wakapi/config/config.go:114.73,115.33 1 0
github.com/muety/wakapi/config/config.go:115.33,123.17 5 0
github.com/muety/wakapi/config/config.go:127.3,128.13 2 0
github.com/muety/wakapi/config/config.go:123.17,125.4 1 0
github.com/muety/wakapi/config/config.go:132.50,133.19 1 0
github.com/muety/wakapi/config/config.go:146.2,146.12 1 0
github.com/muety/wakapi/config/config.go:134.23,138.5 1 0
github.com/muety/wakapi/config/config.go:139.26,142.5 1 0
github.com/muety/wakapi/config/config.go:143.24,144.48 1 0
github.com/muety/wakapi/config/config.go:149.53,159.2 1 1
github.com/muety/wakapi/config/config.go:161.56,169.2 1 1
github.com/muety/wakapi/config/config.go:171.54,173.2 1 1
github.com/muety/wakapi/config/config.go:175.29,177.2 1 1
github.com/muety/wakapi/config/config.go:179.27,181.16 2 0
github.com/muety/wakapi/config/config.go:184.2,187.16 3 0
github.com/muety/wakapi/config/config.go:191.2,191.22 1 0
github.com/muety/wakapi/config/config.go:181.16,183.3 1 0
github.com/muety/wakapi/config/config.go:187.16,189.3 1 0
github.com/muety/wakapi/config/config.go:194.45,204.16 4 0
github.com/muety/wakapi/config/config.go:208.2,208.57 1 0
github.com/muety/wakapi/config/config.go:212.2,212.30 1 0
github.com/muety/wakapi/config/config.go:216.2,216.15 1 0
github.com/muety/wakapi/config/config.go:204.16,206.3 1 0
github.com/muety/wakapi/config/config.go:208.57,210.3 1 0
github.com/muety/wakapi/config/config.go:212.30,214.3 1 0
github.com/muety/wakapi/config/config.go:219.38,220.43 1 0
github.com/muety/wakapi/config/config.go:224.2,224.15 1 0
github.com/muety/wakapi/config/config.go:220.43,222.3 1 0
github.com/muety/wakapi/config/config.go:227.26,229.2 1 0
github.com/muety/wakapi/config/config.go:231.20,233.2 1 0
github.com/muety/wakapi/config/config.go:235.21,242.96 4 0
github.com/muety/wakapi/config/config.go:246.2,253.52 4 0
github.com/muety/wakapi/config/config.go:257.2,257.47 1 0
github.com/muety/wakapi/config/config.go:263.2,264.14 2 0
github.com/muety/wakapi/config/config.go:242.96,244.3 1 0
github.com/muety/wakapi/config/config.go:253.52,255.3 1 0
github.com/muety/wakapi/config/config.go:257.47,258.14 1 0
github.com/muety/wakapi/config/config.go:258.14,260.4 1 0
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
github.com/muety/wakapi/services/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,47.18 2 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:50.67,54.40 3 0
github.com/muety/wakapi/services/aggregation.go:58.2,58.50 1 0
github.com/muety/wakapi/services/aggregation.go:63.2,63.60 1 0
github.com/muety/wakapi/services/aggregation.go:69.2,69.35 1 0
github.com/muety/wakapi/services/aggregation.go:54.40,56.3 1 0
github.com/muety/wakapi/services/aggregation.go:58.50,60.3 1 0
github.com/muety/wakapi/services/aggregation.go:63.60,67.3 3 0
github.com/muety/wakapi/services/aggregation.go:72.109,73.24 1 0
github.com/muety/wakapi/services/aggregation.go:73.24,74.111 1 0
github.com/muety/wakapi/services/aggregation.go:74.111,76.4 1 0
github.com/muety/wakapi/services/aggregation.go:76.9,79.4 2 0
github.com/muety/wakapi/services/aggregation.go:83.80,84.33 1 0
github.com/muety/wakapi/services/aggregation.go:84.33,85.60 1 0
github.com/muety/wakapi/services/aggregation.go:85.60,87.4 1 0
github.com/muety/wakapi/services/aggregation.go:91.100,95.59 3 0
github.com/muety/wakapi/services/aggregation.go:110.2,111.16 2 0
github.com/muety/wakapi/services/aggregation.go:117.2,118.16 2 0
github.com/muety/wakapi/services/aggregation.go:124.2,125.44 2 0
github.com/muety/wakapi/services/aggregation.go:130.2,130.41 1 0
github.com/muety/wakapi/services/aggregation.go:144.2,144.12 1 0
github.com/muety/wakapi/services/aggregation.go:95.59,98.3 2 0
github.com/muety/wakapi/services/aggregation.go:98.8,98.47 1 0
github.com/muety/wakapi/services/aggregation.go:98.47,100.30 2 0
github.com/muety/wakapi/services/aggregation.go:100.30,101.43 1 0
github.com/muety/wakapi/services/aggregation.go:101.43,103.5 1 0
github.com/muety/wakapi/services/aggregation.go:105.8,107.3 1 0
github.com/muety/wakapi/services/aggregation.go:111.16,114.3 2 0
github.com/muety/wakapi/services/aggregation.go:118.16,121.3 2 0
github.com/muety/wakapi/services/aggregation.go:125.44,127.3 1 0
github.com/muety/wakapi/services/aggregation.go:130.41,131.21 1 0
github.com/muety/wakapi/services/aggregation.go:131.21,135.4 1 0
github.com/muety/wakapi/services/aggregation.go:135.9,135.62 1 0
github.com/muety/wakapi/services/aggregation.go:135.62,139.4 1 0
github.com/muety/wakapi/services/aggregation.go:147.83,162.41 5 0
github.com/muety/wakapi/services/aggregation.go:162.41,172.3 3 0
github.com/muety/wakapi/services/aggregation.go:175.34,178.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.73,73.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

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: '3.7'
services:
wakapi:
build: .
ports:
- 3000:3000
restart: always
environment:
WAKAPI_DB_TYPE: "postgres"
WAKAPI_DB_NAME: "wakapi"
WAKAPI_DB_USER: "wakapi"
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
WAKAPI_DB_HOST: "db"
WAKAPI_DB_PORT: "5432"
ENVIRONMENT: "prod"
db:
image: postgres:12.3
environment:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "CHANGE_ME!!!"
POSTGRES_DB: "wakapi"

18
go.mod
View File

@ -3,20 +3,26 @@ 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/jinzhu/gorm v1.9.11
github.com/joho/godotenv v1.3.0
github.com/kr/pretty v0.2.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/t-tiger/gorm-bulk-insert v1.3.0
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
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
gopkg.in/yaml.v2 v2.2.5
gopkg.in/yaml.v2 v2.2.8
gorm.io/driver/mysql v1.0.3
gorm.io/driver/postgres v1.0.5
gorm.io/driver/sqlite v1.1.3
gorm.io/gorm v1.20.5
)

182
go.sum
View File

@ -1,6 +1,5 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
@ -32,21 +31,24 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 h1:epsH3lb7KVbXHYk7LYGN5EiE0MxcevHU85CKITJ0wUY=
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@ -57,13 +59,13 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
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=
@ -72,8 +74,9 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
@ -85,6 +88,8 @@ github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -95,7 +100,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -107,11 +111,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@ -153,16 +154,64 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
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/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
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/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
@ -170,7 +219,6 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@ -181,29 +229,38 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@ -226,6 +283,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
@ -255,6 +314,7 @@ github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -283,6 +343,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@ -292,8 +355,12 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
@ -315,20 +382,22 @@ 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 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20=
github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY=
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=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
@ -337,10 +406,14 @@ go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -348,12 +421,16 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -378,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=
@ -397,19 +475,24 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -419,24 +502,27 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
@ -451,14 +537,16 @@ google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
@ -467,11 +555,23 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
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=
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=

120
main.go
View File

@ -4,22 +4,22 @@ import (
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"github.com/muety/wakapi/repositories"
"log"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
_ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var (
@ -28,12 +28,22 @@ var (
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
aliasRepository repositories.IAliasRepository
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
var (
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
@ -46,53 +56,62 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Show data loss warning
if config.App.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
}
// Connect to database
var err error
db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config))
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
if config.Db.Dialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;")
db.Raw("PRAGMA foreign_keys = ON;")
}
db.LogMode(config.IsDev())
db.DB().SetMaxIdleConns(int(config.Db.MaxConn))
db.DB().SetMaxOpenConns(int(config.Db.MaxConn))
if config.IsDev() {
db = db.Debug()
}
sqlDb, _ := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
log.Println(err)
log.Fatal("could not connect to database")
}
defer db.Close()
defer sqlDb.Close()
// Migrate database schema
common.RunCustomPreMigrations(db, config)
runDatabaseMigrations()
runCustomMigrations()
common.RunCustomPostMigrations(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
// Services
aliasService = services.NewAliasService(db)
heartbeatService = services.NewHeartbeatService(db)
userService = services.NewUserService(db)
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(db)
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
// Aggregate heartbeats to summaries and persist them
go aggregationService.Schedule()
if config.App.CleanUp {
go heartbeatService.ScheduleCleanUp()
}
// TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler()
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
@ -123,12 +142,13 @@ func main() {
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
@ -136,8 +156,11 @@ func main() {
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -171,16 +194,3 @@ func runDatabaseMigrations() {
log.Fatal(err)
}
}
func runCustomMigrations() {
common.ApplyFixtures(db)
common.MigrateLanguages(db)
}
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
}
}

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

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

View File

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

View File

@ -0,0 +1,108 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
var customPreMigrations []migrationFunc
func init() {
customPreMigrations = []migrationFunc{
{
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
oldTableName, newTableName := "custom_rules", "language_mappings"
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
if migrator.HasTable(oldTableName) {
log.Printf("renaming '%s' table to '%s'\n", oldTableName, newTableName)
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
return err
}
log.Printf("renaming '%s' index to '%s'\n", oldIndexName, newIndexName)
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
}
return nil
},
name: "rename language mappings table",
},
{
f: func(db *gorm.DB, cfg *config.Config) error {
// drop all already existing foreign key constraints
// afterwards let them be re-created by auto migrate with the newly introduced cascade settings,
migrator := db.Migrator()
const lookupKey = "20201106-migration_cascade_constraints"
if cfg.Db.Dialect == config.SQLDialectSqlite {
// https://stackoverflow.com/a/1884893/3112139
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
// things like deleting all summaries won't work in those cases unless an entirely new db is created
log.Println("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
log.Println("key-value table not yet existing")
return nil
}
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, lookupKey).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
log.Println("no need to migrate cascade constraints")
return nil
}
// SELECT * FROM INFORMATION_SCHEMA.table_constraints;
constraints := map[string]interface{}{
"fk_summaries_editors": &models.SummaryItem{},
"fk_summaries_languages": &models.SummaryItem{},
"fk_summaries_machines": &models.SummaryItem{},
"fk_summaries_operating_systems": &models.SummaryItem{},
"fk_summaries_projects": &models.SummaryItem{},
"fk_summary_items_summary": &models.SummaryItem{},
"fk_summaries_user": &models.Summary{},
"fk_language_mappings_user": &models.LanguageMapping{},
"fk_heartbeats_user": &models.Heartbeat{},
"fk_aliases_user": &models.Alias{},
}
for name, table := range constraints {
if migrator.HasConstraint(table, name) {
log.Printf("dropping constraint '%s'", name)
if err := migrator.DropConstraint(table, name); err != nil {
return err
}
}
}
if err := db.Create(&models.KeyStringValue{
Key: lookupKey,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
name: "add cascade constraints",
},
}
}
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPreMigrations {
log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err)
}
}
}

View File

@ -1,15 +0,0 @@
package common
import (
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"log"
)
func ApplyFixtures(db *gorm.DB) {
cfg := config.Get()
if err := cfg.GetFixturesFunc(cfg.Db.Dialect)(db); err != nil {
log.Fatal(err)
}
}

View File

@ -1,9 +1,9 @@
package common
import (
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)

View File

@ -1,87 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
create table aliases
(
id integer primary key autoincrement,
type integer not null,
user_id varchar(255) not null,
key varchar(255) not null,
value varchar(255) not null
);
create index idx_alias_type_key
on aliases (type, key);
create index idx_alias_user
on aliases (user_id);
create table summaries
(
id integer primary key autoincrement,
user_id varchar(255) not null,
from_time timestamp default CURRENT_TIMESTAMP not null,
to_time timestamp default CURRENT_TIMESTAMP not null
);
create index idx_time_summary_user
on summaries (user_id, from_time, to_time);
create table summary_items
(
id integer primary key autoincrement,
summary_id integer REFERENCES summaries (id) ON DELETE CASCADE ON UPDATE CASCADE,
type integer,
key varchar(255),
total bigint
);
create table users
(
id varchar(255) primary key,
api_key varchar(255) unique,
password varchar(255)
);
create table heartbeats
(
id integer primary key autoincrement,
user_id varchar(255) not null REFERENCES users (id) ON DELETE RESTRICT ON UPDATE RESTRICT,
entity varchar(255) not null,
type varchar(255),
category varchar(255),
project varchar(255),
branch varchar(255),
language varchar(255),
is_write bool,
editor varchar(255),
operating_system varchar(255),
time timestamp default CURRENT_TIMESTAMP
);
create index idx_entity
on heartbeats (entity);
create index idx_language
on heartbeats (language);
create index idx_time
on heartbeats (time);
create index idx_time_user
on heartbeats (user_id, time);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP INDEX idx_alias_user;
DROP INDEX idx_alias_type_key;
DROP TABLE aliases;
DROP INDEX idx_time_summary_user;
DROP TABLE summaries;
DROP TABLE summary_items;
DROP TABLE heartbeats;
DROP INDEX idx_entity;
DROP INDEX idx_language;
DROP INDEX idx_time;
DROP INDEX idx_time_user;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
create table key_string_values
(
key varchar(255) primary key,
value text
);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
drop table key_string_value;

View File

@ -1,20 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
-- SQLite does not allow altering a table to add a new column with default of CURRENT_TIMESTAMP
-- See https://www.sqlite.org/lang_altertable.html
alter table users
add `created_at` timestamp default '2020-01-01T00:00:00.000' not null;
alter table users
add `last_logged_in_at` timestamp default '2020-01-01T00:00:00.000' not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `created_at`;
alter table users
drop column `last_logged_in_at`;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table heartbeats
add column `machine` varchar(255);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table heartbeats
drop column `machine`;

View File

@ -1,11 +0,0 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table users
add column `badges_enabled` tinyint(1) default 0 not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `badges_enabled`;

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

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

View File

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

View File

@ -21,7 +21,7 @@ type allTimeData struct {
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
var total time.Duration
if key := filters.Project; key != "" {
total = summary.TotalTimeByKey(models.SummaryProject, key)
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}

67
models/filters.go Normal file
View File

@ -0,0 +1,67 @@
package models
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
type FilterElement struct {
Type uint8
Key string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{OS: key}
case SummaryLanguage:
return &Filters{Language: key}
case SummaryEditor:
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
}
func (f *Filters) All() []*FilterElement {
all := make([]*FilterElement, 0)
if f.Project != "" {
all = append(all, &FilterElement{Type: SummaryProject, Key: f.Project})
}
if f.Editor != "" {
all = append(all, &FilterElement{Type: SummaryEditor, Key: f.Editor})
}
if f.Language != "" {
all = append(all, &FilterElement{Type: SummaryLanguage, Key: f.Language})
}
if f.Machine != "" {
all = append(all, &FilterElement{Type: SummaryMachine, Key: f.Machine})
}
if f.OS != "" {
all = append(all, &FilterElement{Type: SummaryOS, Key: f.OS})
}
return all
}

View File

@ -7,7 +7,7 @@ import (
type Heartbeat struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
@ -19,27 +19,46 @@ type Heartbeat struct {
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp
}
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(customLangs map[string]string) {
if h.Language == "" {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := customLangs[ending]; !ok {
return
}
h.Language, _ = customLangs[ending]
func (h *Heartbeat) Augment(languageMappings map[string]string) {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := languageMappings[ending]; !ok {
return
}
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

@ -0,0 +1,21 @@
package models
type LanguageMapping struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
Language string `json:"language" gorm:"type:varchar(64)"`
}
func (m *LanguageMapping) IsValid() bool {
return m.validateLanguage() && m.validateExtension()
}
func (m *LanguageMapping) validateLanguage() bool {
return len(m.Language) >= 1
}
func (m *LanguageMapping) validateExtension() bool {
return len(m.Extension) >= 1
}

View File

@ -4,7 +4,7 @@ import (
"database/sql/driver"
"errors"
"fmt"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"math"
"strconv"
"strings"
@ -24,43 +24,9 @@ type KeyStringValue struct {
Value string `gorm:"type:text"`
}
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
case SummaryLanguage:
return &Filters{Project: key}
case SummaryEditor:
return &Filters{Project: key}
case SummaryMachine:
return &Filters{Project: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time
@ -118,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,19 +35,23 @@ func Intervals() []string {
const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"`
OperatingSystems []*SummaryItem `json:"operating_systems"`
Machines []*SummaryItem `json:"machines"`
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"`
SummaryID uint `json:"-"`
Type uint8 `json:"-"`
Key string `json:"key"`
@ -73,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,
@ -147,31 +163,94 @@ func (s *Summary) TotalTime() time.Duration {
return timeSum * time.Second
}
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if item.Key != key {
continue
}
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
for _, f := range filter.All() {
timeSum += s.TotalTimeByKey(f.Type, f.Key)
}
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

@ -6,7 +6,7 @@ type User struct {
Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
}
type Login struct {
@ -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/home.go Normal file
View File

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

22
models/view/imprint.go Normal file
View File

@ -0,0 +1,22 @@
package view
type ImprintViewModel struct {
HtmlText string
Success string
Error string
}
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
s.Success = m
return s
}
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
s.Error = m
return s
}
func (s *ImprintViewModel) WithHtmlText(t string) *ImprintViewModel {
s.HtmlText = t
return s
}

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
}

20
models/view/settings.go Normal file
View File

@ -0,0 +1,20 @@
package view
import "github.com/muety/wakapi/models"
type SettingsViewModel struct {
User *models.User
LanguageMappings []*models.LanguageMapping
Success string
Error string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m
return s
}
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
s.Error = m
return s
}

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

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

24
repositories/alias.go Normal file
View File

@ -0,0 +1,24 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type AliasRepository struct {
db *gorm.DB
}
func NewAliasRepository(db *gorm.DB) *AliasRepository {
return &AliasRepository{db: db}
}
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.
Where(&models.Alias{UserID: userId}).
Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}

View File

@ -0,0 +1,54 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type HeartbeatRepository struct {
db *gorm.DB
}
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db}
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db.Create(&heartbeats).Error; err != nil {
return err
}
return nil
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time < ?", to).
Order("time asc").
Find(&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 {
if err := r.db.
Where("time <= ?", t).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

58
repositories/key_value.go Normal file
View File

@ -0,0 +1,58 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type KeyValueRepository struct {
db *gorm.DB
}
func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
return &KeyValueRepository{db: db}
}
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{}
if err := r.db.
Where(&models.KeyStringValue{Key: key}).
First(&kv).Error; err != nil {
return nil, err
}
return kv, nil
}
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
result := r.db.
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
FirstOrCreate(kv)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing updated")
}
return nil
}
func (r *KeyValueRepository) DeleteString(key string) error {
result := r.db.
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing deleted")
}
return nil
}

View File

@ -0,0 +1,52 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type LanguageMappingRepository struct {
config *config.Config
db *gorm.DB
}
func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
return &LanguageMappingRepository{config: config.Get(), db: db}
}
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
mapping := &models.LanguageMapping{}
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
return mapping, err
}
return mapping, nil
}
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if err := r.db.
Where(&models.LanguageMapping{UserID: userId}).
Find(&mappings).Error; err != nil {
return mappings, err
}
return mappings, nil
}
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
if !mapping.IsValid() {
return nil, errors.New("invalid mapping")
}
result := r.db.Create(mapping)
if err := result.Error; err != nil {
return nil, err
}
return mapping, nil
}
func (r *LanguageMappingRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.LanguageMapping{}).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)
}

59
repositories/summary.go Normal file
View File

@ -0,0 +1,59 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type SummaryRepository struct {
db *gorm.DB
}
func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
return &SummaryRepository{db: db}
}
func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil {
return err
}
return nil
}
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&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 {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}

80
repositories/user.go Normal file
View File

@ -0,0 +1,80 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetById(userId string) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return user, true, nil
}
return user, false, nil
}
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
result := r.db.Model(user).Updates(user)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}
func (r *UserRepository) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
result := r.db.Model(user).Update(key, value)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}

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,16 +18,16 @@ const (
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
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(),
}
}
@ -57,18 +57,20 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
interval = groups[1]
}
filters := &models.Filters{}
var filters *models.Filters
switch filterEntity {
case "project":
filters.Project = filterKey
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
filters.OS = filterKey
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
filters.Editor = filterKey
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
filters.Language = filterKey
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
filters.Machine = filterKey
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
default:
filters = &models.Filters{}
}
summary, err, status := h.loadUserSummary(user, interval)
@ -94,7 +96,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user,
}
summary, err := 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(),
}
}
@ -43,7 +43,7 @@ func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
utils.RespondJSON(w, http.StatusOK, vm)
}
@ -55,7 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false,
}
summary, err := 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,7 +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.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

@ -2,7 +2,7 @@ package routes
import (
"fmt"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"net/http"
)
@ -16,8 +16,10 @@ func NewHealthHandler(db *gorm.DB) *HealthHandler {
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if err := h.db.DB().Ping(); err == nil {
dbStatus = 1
if sqlDb, err := h.db.DB(); err == nil {
if err := sqlDb.Ping(); err == nil {
dbStatus = 1
}
}
w.Header().Set("Content-Type", "text/plain")

View File

@ -2,7 +2,7 @@ package routes
import (
"encoding/json"
config2 "github.com/muety/wakapi/config"
conf "github.com/muety/wakapi/config"
"net/http"
"os"
@ -13,14 +13,16 @@ import (
)
type HeartbeatHandler struct {
config *config2.Config
heartbeatSrvc *services.HeartbeatService
config *conf.Config
heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService
}
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{
config: config2.Get(),
heartbeatSrvc: heartbeatService,
config: conf.Get(),
heartbeatSrvc: heartbeatService,
languageMappingSrvc: languageMappingService,
}
}
@ -47,7 +49,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
hb.Machine = machineName
hb.User = user
hb.UserID = user.ID
hb.Augment(h.config.App.CustomLanguages)
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)

43
routes/home.go Normal file
View File

@ -0,0 +1,43 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"net/http"
)
type HomeHandler struct {
config *conf.Config
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewHomeHandler() *HomeHandler {
return &HomeHandler{
config: conf.Get(),
}
}
func (h *HomeHandler) 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.IndexTemplate].Execute(w, h.buildViewModel(r))
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
return &view.HomeViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

41
routes/imprint.go Normal file
View File

@ -0,0 +1,41 @@
package routes
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
)
type ImprintHandler struct {
config *conf.Config
keyValueSrvc services.IKeyValueService
}
func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
return &ImprintHandler{
config: conf.Get(),
keyValueSrvc: keyValueService,
}
}
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
text := "failed to load content"
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
text = data.Value
}
templates[conf.ImprintTemplate].Execute(w, h.buildViewModel(r).WithHtmlText(text))
}
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
return &view.ImprintViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

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

@ -1,180 +0,0 @@
package routes
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/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type IndexHandler struct {
config *conf.Config
userSrvc *services.UserService
keyValueSrvc *services.KeyValueService
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
return &IndexHandler{
config: conf.Get(),
userSrvc: userService,
keyValueSrvc: keyValueService,
}
}
func (h *IndexHandler) 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
}
if handleAlerts(w, r, "") {
return
}
templates[conf.IndexTemplate].Execute(w, nil)
}
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
text := "failed to load content"
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
text = data.Value
}
templates[conf.ImprintTemplate].Execute(w, &struct {
HtmlText string
}{HtmlText: text})
}
func (h *IndexHandler) 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 {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
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 *IndexHandler) 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 *IndexHandler) 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
}
if handleAlerts(w, r, conf.SignupTemplate) {
return
}
templates[conf.SignupTemplate].Execute(w, nil)
}
func (h *IndexHandler) 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 {
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return
}
if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
respondAlert(w, "failed to create new user", "", conf.SignupTemplate, http.StatusInternalServerError)
return
}
if !created {
respondAlert(w, "user already existing", "", conf.SignupTemplate, http.StatusConflict)
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
}

View File

@ -6,12 +6,11 @@ import (
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"net/http"
"path"
"strings"
)
func init() {
func Init() {
loadTemplates()
}
@ -30,6 +29,9 @@ func loadTemplates() {
"getVersion": func() string {
return config.Get().Version
},
"getDbType": func() string {
return strings.ToLower(config.Get().Db.Dialect)
},
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
},
@ -55,33 +57,3 @@ func loadTemplates() {
templates[tplName] = tpl
}
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
if tplName == "" {
tplName = config.IndexTemplate
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error, Success: success})
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
}

View File

@ -5,23 +5,31 @@ import (
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"net/url"
"strconv"
)
type SettingsHandler struct {
config *conf.Config
userSrvc *services.UserService
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
return &SettingsHandler{
config: conf.Get(),
userSrvc: userService,
config: conf.Get(),
summarySrvc: summaryService,
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
userSrvc: userService,
}
}
@ -30,16 +38,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
data := map[string]interface{}{
"User": user,
}
// TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, conf.SettingsTemplate) {
return
}
templates[conf.SettingsTemplate].Execute(w, data)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -51,34 +50,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
respondAlert(w, "invalid credentials", "", conf.SettingsTemplate, http.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
} else {
user.Password = hash
}
if _, err := h.userSrvc.Update(user); err != nil {
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
@ -88,21 +93,68 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
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"))
}
msg := url.QueryEscape("password was updated successfully")
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
}
mapping := &models.LanguageMapping{
ID: uint(id),
UserID: user.ID,
}
err = h.languageMappingSrvc.Delete(mapping)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
}
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
extension := r.PostFormValue("extension")
language := r.PostFormValue("language")
if extension[0] == '.' {
extension = extension[1:]
}
mapping := &models.LanguageMapping{
UserID: user.ID,
Extension: extension,
Language: language,
}
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
w.WriteHeader(http.StatusConflict)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
@ -112,12 +164,13 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
}
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
@ -126,11 +179,47 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
log.Printf("clearing summaries for user '%s'\n", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
log.Printf("failed to clear summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
return
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
log.Printf("failed to regenerate summaries: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated this may take a few seconds"))
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := r.Context().Value(models.UserKey).(*models.User)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -3,17 +3,18 @@ package routes
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
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(),
@ -44,13 +45,15 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r)
if err != nil {
respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
w.WriteHeader(status)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil {
respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
return
}
@ -69,10 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
return nil, err, http.StatusBadRequest
}
summary, err := 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
}
return summary, nil, http.StatusOK
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

3
scripts/docker_mysql.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5

View File

@ -0,0 +1,3 @@
#!/bin/bash
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres

View File

@ -6,8 +6,7 @@ import (
"runtime"
"time"
"github.com/jasonlvhit/gocron"
"github.com/jinzhu/gorm"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
@ -16,20 +15,18 @@ const (
)
type AggregationService struct {
Config *config.Config
Db *gorm.DB
UserService *UserService
SummaryService *SummaryService
HeartbeatService *HeartbeatService
config *config.Config
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
}
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
return &AggregationService{
Config: config.Get(),
Db: db,
UserService: userService,
SummaryService: summaryService,
HeartbeatService: heartbeatService,
config: config.Get(),
userService: userService,
summaryService: summaryService,
heartbeatService: heartbeatService,
}
}
@ -41,29 +38,41 @@ type AggregationJob struct {
// Schedule a job to (re-)generate summaries every day shortly after midnight
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
log.Fatalf("failed to run aggregation jobs: %v\n", err)
}
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 {
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
defer close(jobs)
defer close(summaries)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
}
for i := 0; i < int(srv.Config.Db.MaxConn); i++ {
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
go srv.persistWorker(summaries)
}
// Run once initially
srv.trigger(jobs)
// 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)
gocron.Every(1).Day().At(srv.Config.App.AggregationTime).Do(srv.trigger, jobs)
<-gocron.Start()
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)
@ -74,72 +83,83 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.SummaryService.Insert(summary); err != nil {
if err := srv.summaryService.Insert(summary); err != nil {
log.Printf("Failed to save summary (%v, %v, %s) %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
log.Println("Generating summaries.")
users, err := srv.UserService.GetAll()
if err != nil {
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
log.Println(err)
return err
}
latestSummaries, 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()
}
missingUserIDs := make([]string, 0)
for _, u := range users {
if _, ok := userSummaryTimes[u.ID]; !ok {
missingUserIDs = append(missingUserIDs, u.ID)
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, len(userIds))
for i, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users[i] = u
}
}
} else {
users = allUsers
}
firstHeartbeats, err := srv.HeartbeatService.GetFirstUserHeartbeats(missingUserIDs)
// 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
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
// 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
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
// 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)
}
// 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

@ -1,50 +1,49 @@
package services
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"sync"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
)
type AliasService struct {
Config *config.Config
Db *gorm.DB
config *config.Config
repository repositories.IAliasRepository
}
func NewAliasService(db *gorm.DB) *AliasService {
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
return &AliasService{
Config: config.Get(),
Db: db,
config: config.Get(),
repository: aliasRepo,
}
}
var userAliases sync.Map
func (srv *AliasService) LoadUserAliases(userId string) error {
var aliases []*models.Alias
if err := srv.Db.
Where(&models.Alias{UserID: userId}).
Find(&aliases).Error; err != nil {
return err
aliases, err := srv.repository.GetByUser(userId)
if err == nil {
userAliases.Store(userId, aliases)
}
userAliases.Store(userId, aliases)
return nil
return err
}
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
if ua, ok := userAliases.Load(userId); ok {
for _, a := range ua.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
if !srv.IsInitialized(userId) {
if err := srv.LoadUserAliases(userId); err != nil {
return "", err
}
return value, nil
}
return "", errors.New("user aliases not initialized")
aliases, _ := userAliases.Load(userId)
for _, a := range aliases.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
}
return value, nil
}
func (srv *AliasService) IsInitialized(userId string) bool {

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

@ -1,95 +1,56 @@
package services
import (
"github.com/jasonlvhit/gocron"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"log"
"github.com/muety/wakapi/repositories"
"time"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
gormbulk "github.com/t-tiger/gorm-bulk-insert"
)
const (
TableHeartbeat = "heartbeat"
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
)
type HeartbeatService struct {
Config *config.Config
Db *gorm.DB
config *config.Config
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
Config: config.Get(),
Db: db,
config: config.Get(),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
}
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
var batch []interface{}
for _, h := range heartbeats {
batch = append(batch, *h)
}
if err := gormbulk.BulkInsert(srv.Db, batch, 3000); err != nil {
return err
}
return nil
return srv.repository.InsertBatch(heartbeats)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := srv.Db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time < ?", to).
Order("time asc").
Find(&heartbeats).Error; err != nil {
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil {
return nil, err
}
return heartbeats, nil
return srv.augmented(heartbeats, user.ID)
}
// Will return *models.Heartbeat object with only user_id and time fields filled
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := srv.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 (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers()
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
if err := srv.Db.
Where("time <= ?", t).
Delete(models.Heartbeat{}).Error; err != nil {
return err
return srv.repository.DeleteBefore(t)
}
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
if err != nil {
return nil, err
}
return nil
}
func (srv *HeartbeatService) CleanUp() error {
refTime := utils.StartOfToday().Add(-cleanUpInterval)
if err := srv.DeleteBefore(refTime); err != nil {
log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err)
return err
for i := range heartbeats {
heartbeats[i].Augment(languageMapping)
}
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
return nil
}
func (srv *HeartbeatService) ScheduleCleanUp() {
srv.CleanUp()
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
<-gocron.Start()
return heartbeats, nil
}

View File

@ -1,63 +1,31 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
)
type KeyValueService struct {
Config *config.Config
Db *gorm.DB
config *config.Config
repository repositories.IKeyValueRepository
}
func NewKeyValueService(db *gorm.DB) *KeyValueService {
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
return &KeyValueService{
Config: config.Get(),
Db: db,
config: config.Get(),
repository: keyValueRepo,
}
}
func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{}
if err := srv.Db.
Where(&models.KeyStringValue{Key: key}).
First(&kv).Error; err != nil {
return nil, err
}
return kv, nil
return srv.repository.GetString(key)
}
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
result := srv.Db.
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
FirstOrCreate(kv)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing updated")
}
return nil
return srv.repository.PutString(kv)
}
func (srv *KeyValueService) DeleteString(key string) error {
result := srv.Db.
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
if err := result.Error; err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("nothing deleted")
}
return nil
return srv.repository.DeleteString(key)
}

View File

@ -0,0 +1,73 @@
package services
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type LanguageMappingService struct {
config *config.Config
cache *cache.Cache
repository repositories.ILanguageMappingRepository
}
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
return &LanguageMappingService{
config: config.Get(),
repository: languageMappingsRepo,
cache: cache.New(1*time.Hour, 2*time.Hour),
}
}
func (srv *LanguageMappingService) GetById(id uint) (*models.LanguageMapping, error) {
return srv.repository.GetById(id)
}
func (srv *LanguageMappingService) GetByUser(userId string) ([]*models.LanguageMapping, error) {
if mappings, found := srv.cache.Get(userId); found {
return mappings.([]*models.LanguageMapping), nil
}
mappings, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, mappings, cache.DefaultExpiration)
return mappings, nil
}
func (srv *LanguageMappingService) ResolveByUser(userId string) (map[string]string, error) {
mappings := srv.getServerMappings()
userMappings, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, m := range userMappings {
mappings[m.Extension] = m.Language
}
return mappings, nil
}
func (srv *LanguageMappingService) Create(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
result, err := srv.repository.Insert(mapping)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
return result, nil
}
func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error {
err := srv.repository.Delete(mapping.ID)
srv.cache.Delete(mapping.UserID)
return err
}
func (srv LanguageMappingService) getServerMappings() map[string]string {
return srv.config.App.CustomLanguages
}

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,90 +4,128 @@ 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/jinzhu/gorm"
"github.com/muety/wakapi/models"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
Config *config.Config
Cache *cache.Cache
Db *gorm.DB
HeartbeatService *HeartbeatService
AliasService *AliasService
config *config.Config
cache *cache.Cache
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
}
func NewSummaryService(db *gorm.DB, 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),
Db: db,
HeartbeatService: heartbeatService,
AliasService: aliasService,
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
}
}
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, "--aliased")
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,84 +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
}
return summary.Sorted(), nil
}
if cacheKey != "" {
srv.Cache.SetDefault(cacheKey, summary)
}
// CRUD methods
return summary, nil
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) Insert(summary *models.Summary) error {
if err := srv.Db.Create(summary).Error; err != nil {
return err
}
return nil
return srv.repository.Insert(summary)
}
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
// Private summary generation and utility methods
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.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 (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
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
}
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
key = aliasedKey
}
key := h.GetKey(summaryType)
if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0)
@ -248,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")
}
@ -314,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)
@ -327,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
@ -357,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

@ -1,50 +1,35 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
uuid "github.com/satori/go.uuid"
)
type UserService struct {
Config *config.Config
Db *gorm.DB
Config *config.Config
repository repositories.IUserRepository
}
func NewUserService(db *gorm.DB) *UserService {
func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{
Config: config.Get(),
Db: db,
Config: config.Get(),
repository: userRepo,
}
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
return u, err
}
return u, nil
return srv.repository.GetById(userId)
}
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
}
return u, nil
return srv.repository.GetByApiKey(key)
}
func (srv *UserService) GetAll() ([]*models.User, error) {
var users []*models.User
if err := srv.Db.
Table("users").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
return srv.repository.GetAll()
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
@ -60,29 +45,11 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
u.Password = hash
}
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return u, true, nil
}
return u, false, nil
return srv.repository.InsertOrGet(u)
}
func (srv *UserService) Update(user *models.User) (*models.User, error) {
result := srv.Db.Model(&models.User{}).Updates(user)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
return srv.repository.Update(user)
}
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
@ -91,16 +58,7 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
}
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
@ -110,13 +68,5 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi
} else {
user.Password = hash
}
result := srv.Db.Model(user).Update("password", user.Password)
if err := result.Error; err != nil {
return nil, err
} else if result.RowsAffected < 1 {
return nil, errors.New("nothing changes")
}
return user, nil
return srv.repository.UpdateField(user, "password", user.Password)
}

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

@ -47,7 +47,8 @@ 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'
}
}
}
@ -203,7 +204,7 @@ function togglePlaceholders(mask) {
}
function getPresentDataMask() {
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
}
function getContainer(chart) {
@ -303,4 +304,5 @@ window.addEventListener('load', function () {
setTopLabels()
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,8 +2,6 @@ package utils
import (
"errors"
"fmt"
"github.com/muety/wakapi/config"
"regexp"
"time"
)
@ -28,41 +26,3 @@ func ParseUserAgent(ua string) (string, string, error) {
}
return groups[0][1], groups[0][2], nil
}
func MakeConnectionString(config *config.Config) string {
switch config.Db.Dialect {
case "mysql":
return mysqlConnectionString(config)
case "postgres":
return postgresConnectionString(config)
case "sqlite3":
return sqliteConnectionString(config)
}
return ""
}
func mysqlConnectionString(config *config.Config) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.Db.User,
config.Db.Password,
config.Db.Host,
config.Db.Port,
config.Db.Name,
"Local",
)
}
func postgresConnectionString(config *config.Config) string {
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
config.Db.Host,
config.Db.Port,
config.Db.User,
config.Db.Name,
config.Db.Password,
)
}
func sqliteConnectionString(config *config.Config) string {
return config.Db.Name
}

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.12.5
1.17.3

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

@ -1,6 +1,6 @@
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-12">
<div class="text-xs font-mono">
v{{ getVersion }}
v{{ getVersion }} @ {{ getDbType }}
</div>
<div>
Made with &nbsp; 🤍 &nbsp; by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a

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>

Some files were not shown because too many files have changed in this diff Show More