Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
e8067bb13e | |||
219e969957 | |||
e610bb3ee3 | |||
889edd7a33 | |||
4161623c24 | |||
67fe6eea56 | |||
095fef4868 | |||
a0e64ca955 | |||
903defca99 | |||
16b9aa2282 | |||
4a78f66778 | |||
f4328c452f | |||
e806e5455e | |||
97e1fb27eb | |||
ad8168801c | |||
35cdc7b485 | |||
664714de8f | |||
7befb82814 | |||
2f12d8efde | |||
8ddd9904a0 | |||
78874566a4 | |||
e269b37b0e | |||
e6a04cc76d | |||
cb8f68df82 |
3
.gitignore
vendored
@ -6,5 +6,6 @@ wakapi
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
config.yml
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
config.ini
|
12
README.md
@ -1,6 +1,6 @@
|
||||
# 📈 wakapi
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@ -52,25 +52,31 @@ To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. Howe
|
||||
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
|
||||
|
||||
### Run with Docker
|
||||
```
|
||||
```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 |
|
||||
|
@ -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
|
@ -14,6 +14,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
@ -22,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:"-"`
|
||||
@ -40,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:"-"`
|
||||
}
|
||||
|
||||
@ -68,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 {
|
||||
@ -113,16 +131,16 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case "mysql":
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case "postgres":
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case "sqlite3":
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
@ -217,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 {
|
||||
@ -225,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
@ -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))
|
||||
}
|
@ -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{
|
||||
|
@ -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
@ -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
|
3
go.mod
@ -3,11 +3,11 @@ module github.com/muety/wakapi
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
github.com/gorilla/handlers v1.4.2
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
@ -16,6 +16,7 @@ require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
|
10
go.sum
@ -64,6 +64,8 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
|
||||
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
@ -204,8 +206,6 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
|
||||
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
|
||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@ -382,12 +382,15 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
@ -452,6 +455,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -554,6 +558,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
||||
|
70
main.go
@ -28,22 +28,22 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
aliasRepository *repositories.AliasRepository
|
||||
heartbeatRepository *repositories.HeartbeatRepository
|
||||
userRepository *repositories.UserRepository
|
||||
languageMappingRepository *repositories.LanguageMappingRepository
|
||||
summaryRepository *repositories.SummaryRepository
|
||||
keyValueRepository *repositories.KeyValueRepository
|
||||
aliasRepository repositories.IAliasRepository
|
||||
heartbeatRepository repositories.IHeartbeatRepository
|
||||
userRepository repositories.IUserRepository
|
||||
languageMappingRepository repositories.ILanguageMappingRepository
|
||||
summaryRepository repositories.ISummaryRepository
|
||||
keyValueRepository repositories.IKeyValueRepository
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
languageMappingService *services.LanguageMappingService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
keyValueService *services.KeyValueService
|
||||
aliasService services.IAliasService
|
||||
heartbeatService services.IHeartbeatService
|
||||
userService services.IUserService
|
||||
languageMappingService services.ILanguageMappingService
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
keyValueService services.IKeyValueService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
@ -56,11 +56,6 @@ 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.GetDialector(), &gorm.Config{})
|
||||
@ -68,6 +63,9 @@ func main() {
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, _ := db.DB()
|
||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
@ -102,18 +100,18 @@ func main() {
|
||||
// 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
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, languageMappingService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
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)
|
||||
@ -144,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)
|
||||
@ -157,10 +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.PostCreateLanguageMapping)
|
||||
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)
|
||||
@ -194,11 +194,3 @@ func runDatabaseMigrations() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
81
middlewares/authenticate_test.go
Normal 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
|
@ -22,7 +22,7 @@ func init() {
|
||||
|
||||
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range customPostMigrations {
|
||||
log.Printf("running migration '%s'\n", m.name)
|
||||
log.Printf("potentially running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
|
@ -30,12 +30,77 @@ func init() {
|
||||
},
|
||||
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("running migration '%s'\n", m.name)
|
||||
log.Printf("potentially running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
|
15
mocks/alias_repository.go
Normal file
@ -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
@ -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)
|
||||
}
|
31
mocks/heartbeat_service.go
Normal 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)
|
||||
}
|
31
mocks/summary_repository.go
Normal 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
@ -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)
|
||||
}
|
@ -2,7 +2,7 @@ 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"`
|
||||
|
@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{OS: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Language: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Machine: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
@ -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"`
|
||||
@ -24,7 +24,7 @@ type Heartbeat struct {
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Valid() bool {
|
||||
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
|
||||
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
}
|
||||
h.Language, _ = languageMappings[ending]
|
||||
}
|
||||
|
||||
func (h *Heartbeat) GetKey(t uint8) (key string) {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
key = h.Project
|
||||
case SummaryEditor:
|
||||
key = h.Editor
|
||||
case SummaryLanguage:
|
||||
key = h.Language
|
||||
case SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = UnknownSummaryKey
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
53
models/heartbeat_test.go
Normal file
@ -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
@ -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]
|
||||
}
|
@ -2,16 +2,20 @@ package models
|
||||
|
||||
type LanguageMapping struct {
|
||||
ID uint `json:"id" 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_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite"`
|
||||
Language string `json:"language"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
|
||||
Language string `json:"language" gorm:"type:varchar(64)"`
|
||||
}
|
||||
|
||||
func validateLanguage(language string) bool {
|
||||
return len(language) >= 1
|
||||
func (m *LanguageMapping) IsValid() bool {
|
||||
return m.validateLanguage() && m.validateExtension()
|
||||
}
|
||||
|
||||
func validateExtension(extension string) bool {
|
||||
return len(extension) >= 1
|
||||
func (m *LanguageMapping) validateLanguage() bool {
|
||||
return len(m.Language) >= 1
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateExtension() bool {
|
||||
return len(m.Extension) >= 1
|
||||
}
|
||||
|
@ -24,6 +24,11 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
|
||||
func (j CustomTime) T() time.Time {
|
||||
return time.Time(j)
|
||||
}
|
||||
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -34,21 +35,23 @@ func Intervals() []string {
|
||||
const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects"`
|
||||
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"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
SummaryID uint `json:"-"`
|
||||
Type uint8 `json:"-"`
|
||||
Key string `json:"key"`
|
||||
@ -75,16 +78,27 @@ type SummaryParams struct {
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Sorted() *Summary {
|
||||
sort.Sort(sort.Reverse(s.Projects))
|
||||
sort.Sort(sort.Reverse(s.Machines))
|
||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||
sort.Sort(sort.Reverse(s.Languages))
|
||||
sort.Sort(sort.Reverse(s.Editors))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
return map[uint8]*[]*SummaryItem{
|
||||
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
||||
return map[uint8]*SummaryItems{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
@ -178,3 +192,65 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
|
||||
}
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||
target := make([]*SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
s.Projects = processAliases(s.Projects)
|
||||
s.Editors = processAliases(s.Editors)
|
||||
s.Languages = processAliases(s.Languages)
|
||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||
s.Machines = processAliases(s.Machines)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s SummaryItems) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s SummaryItems) Less(i, j int) bool {
|
||||
return s[i].Total < s[j].Total
|
||||
}
|
||||
|
||||
func (s SummaryItems) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
195
models/summary_test.go
Normal file
@ -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)
|
||||
}
|
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
}
|
@ -34,18 +34,14 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
||||
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Table("heartbeats").
|
||||
Select("user_id, min(time) as time").
|
||||
Where("user_id IN (?)", userIds).
|
||||
Group("user_id").
|
||||
Scan(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, min(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
@ -34,6 +35,9 @@ func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.Language
|
||||
}
|
||||
|
||||
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
|
||||
|
46
repositories/repositories.go
Normal 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)
|
||||
}
|
@ -39,15 +39,21 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// Will return *models.Index objects with only user_id and to_time filled
|
||||
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Table("summaries").
|
||||
Select("user_id, max(to_time) as to_time").
|
||||
Group("user_id").
|
||||
Scan(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(to_time) as time").
|
||||
Joins("left join summaries on users.id = summaries.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Delete(models.Summary{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
result := r.db.Model(&models.User{}).Updates(user)
|
||||
result := r.db.Model(user).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -18,17 +18,16 @@ const (
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aliasSrvc *services.AliasService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
|
||||
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,9 +96,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -13,14 +13,14 @@ import (
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ package v1
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -14,14 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
|
||||
)
|
||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -14,11 +14,11 @@ import (
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
config *conf.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler {
|
||||
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
config: conf.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
@ -43,13 +43,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
/*languageMappings, err := h.languageMappingSrvc.ResolveByUser(user.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}*/
|
||||
|
||||
for _, hb := range heartbeats {
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
|
43
routes/home.go
Normal 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
@ -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
@ -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"),
|
||||
}
|
||||
}
|
180
routes/public.go
@ -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)
|
||||
}
|
@ -6,12 +6,11 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
func Init() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
@ -58,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
|
||||
}
|
||||
|
@ -5,24 +5,29 @@ 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
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
userSrvc: userService,
|
||||
}
|
||||
@ -33,16 +38,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
"LanguageMappings": mappings,
|
||||
"Success": r.FormValue("success"),
|
||||
"Error": r.FormValue("error"),
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, data)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -54,34 +50,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
|
||||
var credentials models.CredentialsReset
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
|
||||
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 {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
|
||||
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) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid credentials")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid parameters")), http.StatusFound)
|
||||
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 {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
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 {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -91,20 +93,13 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
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.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("password was updated successfully")), http.StatusFound)
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
@ -115,7 +110,8 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -126,14 +122,15 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
||||
|
||||
err = h.languageMappingSrvc.Delete(mapping)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping deleted successfully")), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -152,11 +149,12 @@ func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("mapping already exists")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping added successfully")), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
@ -166,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 {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
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) {
|
||||
@ -180,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 {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
@ -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,12 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
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
@ -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
|
3
scripts/docker_postgres.sh
Normal 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
|
@ -6,7 +6,7 @@ import (
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
@ -16,12 +16,12 @@ const (
|
||||
|
||||
type AggregationService struct {
|
||||
config *config.Config
|
||||
userService *UserService
|
||||
summaryService *SummaryService
|
||||
heartbeatService *HeartbeatService
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
}
|
||||
|
||||
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
return &AggregationService{
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
@ -38,10 +38,19 @@ 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)
|
||||
@ -51,16 +60,19 @@ func (srv *AggregationService) Schedule() {
|
||||
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)
|
||||
@ -77,66 +89,77 @@ func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
|
@ -10,10 +10,10 @@ import (
|
||||
|
||||
type AliasService struct {
|
||||
config *config.Config
|
||||
repository *repositories.AliasRepository
|
||||
repository repositories.IAliasRepository
|
||||
}
|
||||
|
||||
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
|
||||
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
|
||||
return &AliasService{
|
||||
config: config.Get(),
|
||||
repository: aliasRepo,
|
||||
|
63
services/alias_test.go
Normal file
@ -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)
|
||||
}
|
@ -1,27 +1,20 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const (
|
||||
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type HeartbeatService struct {
|
||||
config *config.Config
|
||||
repository *repositories.HeartbeatRepository
|
||||
languageMappingSrvc *LanguageMappingService
|
||||
repository repositories.IHeartbeatRepository
|
||||
languageMappingSrvc ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService {
|
||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
config: config.Get(),
|
||||
repository: heartbeatRepo,
|
||||
@ -41,31 +34,14 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
||||
return srv.repository.GetFirstByUsers(userIds)
|
||||
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetFirstByUsers()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
|
@ -8,10 +8,10 @@ import (
|
||||
|
||||
type KeyValueService struct {
|
||||
config *config.Config
|
||||
repository *repositories.KeyValueRepository
|
||||
repository repositories.IKeyValueRepository
|
||||
}
|
||||
|
||||
func NewKeyValueService(keyValueRepo *repositories.KeyValueRepository) *KeyValueService {
|
||||
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
|
||||
return &KeyValueService{
|
||||
config: config.Get(),
|
||||
repository: keyValueRepo,
|
||||
|
@ -10,11 +10,11 @@ import (
|
||||
|
||||
type LanguageMappingService struct {
|
||||
config *config.Config
|
||||
repository *repositories.LanguageMappingRepository
|
||||
cache *cache.Cache
|
||||
repository repositories.ILanguageMappingRepository
|
||||
}
|
||||
|
||||
func NewLanguageMappingService(languageMappingsRepo *repositories.LanguageMappingRepository) *LanguageMappingService {
|
||||
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
|
||||
return &LanguageMappingService{
|
||||
config: config.Get(),
|
||||
repository: languageMappingsRepo,
|
||||
|
58
services/services.go
Normal 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)
|
||||
}
|
@ -4,14 +4,12 @@ import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
@ -19,12 +17,14 @@ const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
type SummaryService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository *repositories.SummaryRepository
|
||||
heartbeatService *HeartbeatService
|
||||
aliasService *AliasService
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
aliasService IAliasService
|
||||
}
|
||||
|
||||
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
|
||||
}
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
// Public summary generation methods
|
||||
|
||||
// TODO: simplify!
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
|
||||
if recompute {
|
||||
existingSummaries = make([]*models.Summary, 0)
|
||||
} else {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
if result, ok := srv.cache.Get(cacheKey); ok {
|
||||
return result.(*models.Summary), nil
|
||||
}
|
||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingSummaries = summaries
|
||||
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
missingIntervals := getMissingIntervals(from, to, existingSummaries)
|
||||
// Wrap alias resolution
|
||||
resolve := func(t uint8, k string) string {
|
||||
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
|
||||
return s
|
||||
}
|
||||
|
||||
heartbeats := make([]*models.Heartbeat, 0)
|
||||
// Initialize alias resolver service
|
||||
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get actual summary
|
||||
s, err := f(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--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,119 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
allSummaries = append(allSummaries, existingSummaries...)
|
||||
//summary.FillUnknown()
|
||||
|
||||
summary, err := mergeSummaries(allSummaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheKey != "" {
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srv.PostProcess(summary), nil
|
||||
// CRUD methods
|
||||
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetLastByUser()
|
||||
}
|
||||
|
||||
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary {
|
||||
updatedSummary := &models.Summary{
|
||||
ID: summary.ID,
|
||||
UserID: summary.UserID,
|
||||
FromTime: summary.FromTime,
|
||||
ToTime: summary.ToTime,
|
||||
}
|
||||
|
||||
processAliases := func(origin []*models.SummaryItem) []*models.SummaryItem {
|
||||
target := make([]*models.SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *models.SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &models.SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
updatedSummary.Projects = processAliases(summary.Projects)
|
||||
updatedSummary.Editors = processAliases(summary.Editors)
|
||||
updatedSummary.Languages = processAliases(summary.Languages)
|
||||
updatedSummary.OperatingSystems = processAliases(summary.OperatingSystems)
|
||||
updatedSummary.Machines = processAliases(summary.Machines)
|
||||
|
||||
return updatedSummary
|
||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
return srv.repository.DeleteByUser(userId)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||
return srv.repository.Insert(summary)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
return srv.repository.GetByUserWithin(user, from, to)
|
||||
}
|
||||
// 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) {
|
||||
return srv.repository.GetLatestByUser()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
key := h.GetKey(summaryType)
|
||||
|
||||
if _, ok := durations[key]; !ok {
|
||||
durations[key] = time.Duration(0)
|
||||
@ -283,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")
|
||||
}
|
||||
@ -349,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)
|
||||
@ -362,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
|
||||
@ -392,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
@ -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)
|
||||
}
|
||||
}
|
@ -10,10 +10,10 @@ import (
|
||||
|
||||
type UserService struct {
|
||||
Config *config.Config
|
||||
repository *repositories.UserRepository
|
||||
repository repositories.IUserRepository
|
||||
}
|
||||
|
||||
func NewUserService(userRepo *repositories.UserRepository) *UserService {
|
||||
func NewUserService(userRepo repositories.IUserRepository) *UserService {
|
||||
return &UserService{
|
||||
Config: config.Get(),
|
||||
repository: userRepo,
|
||||
|
3
sonar-project.properties
Normal file
@ -0,0 +1,3 @@
|
||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**
|
||||
sonar.tests=.
|
||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -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 ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0)
|
||||
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
|
BIN
static/assets/images/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
static/assets/images/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/assets/images/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
static/assets/images/favicon-16x16.png
Normal file
After Width: | Height: | Size: 457 B |
BIN
static/assets/images/favicon-32x32.png
Normal file
After Width: | Height: | Size: 710 B |
BIN
static/assets/images/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
1
static/assets/images/ghicon.svg
Normal 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 |
BIN
static/assets/images/screenshot.png
Normal file
After Width: | Height: | Size: 38 KiB |
19
static/assets/site.webmanifest
Normal 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"
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
1.14.1
|
||||
1.17.3
|
||||
|
@ -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>
|
@ -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
@ -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>📊</span>
|
||||
<span>Wakapi</span>
|
||||
</a>
|
||||
</header>
|
@ -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">← Go back</a></div>
|
||||
|
@ -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">🔑 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>✅ 100 % free and open-source</li>
|
||||
<li>✅ Built by developers for developers</li>
|
||||
<li>✅ Fancy statistics and plots</li>
|
||||
<li>✅ Cool badges for readmes</li>
|
||||
<li>✅ Intuitive REST API</li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
|
||||
<li>✅ 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
@ -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>
|
@ -4,18 +4,21 @@
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div class="flex items-center justify-between max-w-xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
|
||||
<div></div>
|
||||
<div> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||
<div class="flex flex-col flex-grow max-w-lg mt-8">
|
||||
<div class="flex flex-col flex-grow max-w-xl mt-8">
|
||||
<div class="w-full my-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Change Password
|
||||
@ -48,7 +51,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Reset API Key
|
||||
</div>
|
||||
@ -66,13 +69,13 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Language Mappings
|
||||
</div>
|
||||
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.)
|
||||
</div>
|
||||
|
||||
{{ if .LanguageMappings }}
|
||||
@ -119,7 +122,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Badges
|
||||
</div>
|
||||
@ -169,6 +172,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
⚠️ Danger Zone
|
||||
</div>
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||
Regenerate summaries
|
||||
</h3>
|
||||
<p>
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to summaries on a per-day basis.
|
||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the database in a static fashion afterwards, unless you pass <span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span> with your request.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is case heartbeats were deleted after the respective summaries had been generated.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-10 flex justify-center">
|
||||
<form action="settings/regenerate" method="post" id="form-regenerate-summaries">
|
||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm" id="btn-regenerate-summaries">
|
||||
Clear & Regenerate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -182,6 +213,14 @@
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const btnRegenerate = document.querySelector("#btn-regenerate-summaries")
|
||||
const formRegenerate = document.querySelector('#form-regenerate-summaries')
|
||||
btnRegenerate.addEventListener('click', () => {
|
||||
if (confirm('Are you sure?')) {
|
||||
formRegenerate.submit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
@ -5,6 +5,8 @@
|
||||
|
||||
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
|
||||
id="api-key-popup">
|
||||
<div class="flex-grow flex flex-col px-2">
|
||||
@ -133,6 +135,19 @@
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
|
||||
const wakapiData = {}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|