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

Compare commits

..

53 Commits

Author SHA1 Message Date
2f12d8efde refactor: simplify summary generation (resolve #68) 2020-11-07 12:01:35 +01:00
8ddd9904a0 refactor: alert handling 2020-11-06 21:19:54 +01:00
78874566a4 chore: introduce constants for db dialects
chore: go fmt
2020-11-06 17:20:26 +01:00
e269b37b0e feat: add ability to regenerate summaries
fix: database cascade settings
chore: debug log mode for gorm queries is back
2020-11-06 17:09:41 +01:00
e6a04cc76d chore: remove cleanup functionality
chore: minor code changes
2020-11-06 14:07:07 +01:00
cb8f68df82 chore: add quick start scripts for spinning up dev database container 2020-11-03 10:32:18 +01:00
b4d2ee7d16 fix: not creating language mappings table due to broken type definition in users model (resolve #69)
chore: introduce foreign key constraints
2020-11-03 10:26:32 +01:00
1224024913 fix: postgres connection (resolve #70) 2020-11-03 10:02:59 +01:00
8efc3854ab refactor: migrate to latest gorm version
refactor: language mappings implementation
2020-11-01 20:14:10 +01:00
755cabb5f4 refactor: introduce repositories as an additional layer of abstraction to allow for better testability 2020-11-01 16:56:36 +01:00
96ff490d8d fix: crash when calling badge endpoint with no filters applied (resolve #66) 2020-11-01 16:04:30 +01:00
68e66298b8 fix: postprocessing for alias keys not corresponding to an actual entity 2020-11-01 13:09:39 +01:00
c2d30826f6 fix: critical fixes related to alias resolution 2020-11-01 12:50:59 +01:00
861c81e414 docs: fix endpoint paths in readme (thanks @mlunax) 2020-10-27 21:06:41 +01:00
892d265c4d chore: update version 2020-10-26 23:10:15 +01:00
e19761337f Merge branch 'notarock/62' 2020-10-26 22:34:50 +01:00
3f973a28ea Fixed custom rule settings CSS 2020-10-26 00:27:07 -04:00
86fc751e58 Cache rules per user, invalidate cache on update 2020-10-25 23:19:16 -04:00
178c417757 Keep the old configuration rules behaviour 2020-10-25 23:02:28 -04:00
395d039d41 General cleanup and fixed PR comments 2020-10-25 22:18:33 -04:00
fdf2289f8e MVP for custom rules support 2020-10-25 21:51:06 -04:00
06b3fdd17c Improved Dockerfile and docker-compose for dev 2020-10-25 02:21:41 -04:00
4506493353 docs: fix badges 2020-10-18 13:42:59 +02:00
11728b80ac docs: update url 2020-10-17 13:37:43 +02:00
b7c7817923 docs: display latest release as badge 2020-10-16 17:07:35 +02:00
c78ee5465c chore: display database type 2020-10-16 16:58:16 +02:00
4336d732c9 docs: update readme 2020-10-16 16:51:11 +02:00
177cbb12fc chore: make aggregation time configurable (resolve #60) 2020-10-16 16:21:19 +02:00
a4c344aaa1 chore: minor code restyling 2020-10-16 16:11:14 +02:00
c575b2fd5c fix: json serialization error when percentage is nan 2020-10-16 15:16:43 +02:00
67a59561c8 fix: use custom date for summary model to support sqlite deserialization 2020-10-16 14:49:22 +02:00
f7520b2b4a fix: timestamp precision 2020-10-16 12:49:36 +02:00
54a944ec41 fix: critical summary computation bug (faulty intervals)
fix: doubly included heartbeats
fix: cross-day heartbeats are ignored for consistency
2020-10-16 12:00:20 +02:00
44b6efb6ee chore: add random seed to generator script 2020-10-16 11:59:08 +02:00
efd4764728 fix: sample data script 2020-10-11 09:55:01 +02:00
dd50b4076f docs: include sonarcloud badges 2020-10-09 21:47:18 +02:00
21b822de42 chore: minor code enhancements 2020-10-09 21:37:20 +02:00
4d22756b8a fix: stop tracking legacy config file 2020-10-04 12:20:15 +02:00
c54f2743fd fix: add legacy config again for backwards-compatibility 2020-10-04 12:16:42 +02:00
a8d5d69629 chore: update build workflow to exclude certain directories 2020-10-04 12:08:58 +02:00
0111aa7543 Merge branch 'master' into stable 2020-10-04 11:54:02 +02:00
3bafde7ab1 fix: adapt docker image to new config format 2020-10-04 11:52:52 +02:00
b378597594 fix: define flags on init
chore: remove deprecated config files
2020-10-04 11:47:31 +02:00
29619f09ed docs: update docs for new config format 2020-10-04 11:35:23 +02:00
ff3fea0359 feat: introduce legacy config migration 2020-10-04 11:14:44 +02:00
660fefcca9 refactor: migrate to new config (resolve #54) 2020-10-04 10:37:38 +02:00
2ecbb3ea02 fix: user agent strign parsing (fix #53) 2020-09-29 18:58:10 +02:00
f843be8d12 refactor: move config to separate package
chore: load config from main method
2020-09-29 18:55:07 +02:00
062a9c6f57 Merge remote-tracking branch 'origin/master' 2020-09-12 16:58:31 +02:00
1c0e63e125 chore: restrict badge access by user agent 2020-09-12 16:58:22 +02:00
45f372168d docs: readme 2020-09-12 16:50:51 +02:00
0760be86ff Merge branch 'master' into stable 2020-09-12 16:40:50 +02:00
a059c637a7 Merge branch 'master' into stable 2020-08-30 01:43:05 +02:00
80 changed files with 2660 additions and 1482 deletions

View File

@ -1,8 +0,0 @@
ENV=dev
WAKAPI_DB_TYPE=sqlite3 # mysql, postgres, sqlite3
WAKAPI_DB_NAME=wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
WAKAPI_DB_USER=myuser # ignored when using sqlite
WAKAPI_DB_PASSWORD=shhh # ignored when using sqlite
WAKAPI_DB_HOST=localhost # ignored when using sqlite
WAKAPI_DB_PORT=3306 # ignored when using sqlite
WAKAPI_PASSWORD_SALT=shhh # CHANGE !

View File

@ -31,6 +31,7 @@ jobs:
uses: TheDoctor0/zip-release@v0.3.0
with:
filename: release.zip
exclusions: '*.git*'
- name: Upload built executable to Release
uses: actions/upload-release-asset@v1.0.2

4
.gitignore vendored
View File

@ -5,4 +5,6 @@ wakapi
.idea
build
*.exe
*.db
*.db
config.yml
config.ini

View File

@ -1,14 +1,17 @@
# Build Stage
FROM golang:1.13 AS build-env
ADD . /src
RUN cd /src && go build -o wakapi
WORKDIR /src
ADD ./go.mod .
RUN go mod download
ADD . .
RUN go build -o wakapi
# Final Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values from .env using `-e` syntax.
# to override config values using `-e` syntax.
# Available options are:
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
@ -22,7 +25,7 @@ RUN cd /src && go build -o wakapi
FROM debian
WORKDIR /app
ENV ENV prod
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD ''
@ -31,12 +34,11 @@ ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.ini /app/
COPY --from=build-env /src/config.default.yml /app/config.yml
COPY --from=build-env /src/version.txt /app/
COPY --from=build-env /src/.env.example /app/.env
RUN sed -i 's/listen = 127.0.0.1/listen = 0.0.0.0/g' /app/config.ini
RUN sed -i 's/insecure_cookies = false/insecure_cookies = true/g' /app/config.ini
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
ADD static /app/static
ADD data /app/data
@ -46,4 +48,4 @@ ADD wait-for-it.sh .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh
ENTRYPOINT ./wait-for-it.sh

View File

@ -1,10 +1,20 @@
# 📈 wakapi
[![](http://img.shields.io/liberapay/receives/muety.svg?logo=liberapay&style=flat-square)](https://liberapay.com/muety/)
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
![](https://img.shields.io/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
![GitHub issues](https://img.shields.io/github/issues/muety/wakapi)
![GitHub last commit](https://img.shields.io/github/last-commit/muety/wakapi)
[![Say thanks](https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg)](https://saythanks.io/to/n1try)
[![](https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay)](https://liberapay.com/muety/)
![GitHub go.mod Go version](https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc)](https://sonarcloud.io/dashboard?id=muety_wakapi)
---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
@ -13,12 +23,12 @@
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## Demo
🔥 **New:** There is hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
## 👀 Hosted Service
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day
## Prerequisites
## ⚙️ Prerequisites
**On the server side:**
* Go >= 1.13 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
@ -30,26 +40,46 @@ To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartb
**On your local machine:**
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
## Server Setup
## ⌨️ Server Setup
### Run from source
1. Clone the project
1. Copy `.env.example` to `.env` and set database credentials
1. Adapt `config.ini` to your needs
1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
1. Build executable: `GO111MODULE=on go build`
1. Run server: `./wakapi`
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` in `.env` 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](i#best-practices)) or set `insecure_cookies = true` in `config.ini`.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
### Run with Docker
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
```
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 [.env.example](https://github.com/muety/wakapi/blob/master/.env.example) for further options.
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.
## Client Setup
## 🔧 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 |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of sqlite3, mysql, postgres) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
## 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
@ -62,7 +92,7 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server`
You can view your API Key after logging in to the web interface.
## Customization
## 🔵 Customization
### Aliases
There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects `myapp-frontend` and `myapp-backend` two a common project name `myapp-web` in your statistics, you can add project aliases.
@ -80,17 +110,17 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
* OS ~ type **3**
* Machine ~ type **4**
## API Endpoints
## 🔧 API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
* `POST /api/heartbeat`
* `GET /api/summary`
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/compat/wakatime/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health`
## Prometheus Export
## ⤴️ Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
@ -99,15 +129,15 @@ It is a standalone webserver that connects to your Wakapi instance and exposes t
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
## Badges
## 🏷 Badges
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
## Best Practices
## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `listen = 0.0.0.0` in `config.ini`
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## Important Note
## ⚠️ Important Note
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
## License
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

26
config.default.yml Normal file
View File

@ -0,0 +1,26 @@
env: development
server:
listen_ipv4: 127.0.0.1
port: 3000
base_path: /
app:
cleanup: false # only edit, if you know what you're doing
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
custom_languages:
vue: Vue
jsx: JSX
db:
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
user: # leave blank when using sqlite3
password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
dialect: sqlite3 # mysql, postgres, sqlite3
max_conn: 2
security:
password_salt: # CHANGE !
insecure_cookies: false

View File

@ -1,15 +0,0 @@
[server]
listen = 127.0.0.1
port = 3000
base_path = /
insecure_cookies = false
[app]
cleanup = false
[database]
max_connections = 2
[languages]
vue = Vue
jsx = JSX

249
config/config.go Normal file
View File

@ -0,0 +1,249 @@
package config
import (
"encoding/json"
"flag"
"fmt"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/models"
migrate "github.com/rubenv/sql-migrate"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"io/ioutil"
"log"
"os"
"strings"
)
const (
defaultConfigPath = "config.yml"
defaultConfigPathLegacy = "config.ini"
defaultEnvConfigPathLegacy = ".env"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3"
)
var (
cfg *Config
cFlag *string
)
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:"-"`
}
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"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
}
type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"`
Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
}
type Config struct {
Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"`
App appConfig
Security securityConfig
Db dbConfig
Server serverConfig
}
func init() {
cFlag = flag.String("c", defaultConfigPath, "config file location")
flag.Parse()
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&models.User{})
db.AutoMigrate(&models.KeyStringValue{})
db.AutoMigrate(&models.Alias{})
db.AutoMigrate(&models.Heartbeat{})
db.AutoMigrate(&models.Summary{})
db.AutoMigrate(&models.SummaryItem{})
db.AutoMigrate(&models.LanguageMapping{})
return nil
}
}
}
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/common/fixtures",
}
migrate.SetIgnoreUnknown(true)
sqlDb, _ := db.DB()
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d fixtures\n", n)
return nil
}
}
func (c *dbConfig) GetDialector() gorm.Dialector {
switch c.Dialect {
case SQLDialectMysql:
return mysql.New(mysql.Config{
DriverName: c.Dialect,
DSN: mysqlConnectionString(c),
})
case SQLDialectPostgres:
return postgres.New(postgres.Config{
DSN: postgresConnectionString(c),
})
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
}
return nil
}
func mysqlConnectionString(config *dbConfig) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func readVersion() string {
file, err := os.Open("version.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
return string(bytes)
}
func readLanguageColors() map[string]string {
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
data, err := ioutil.ReadFile("data/colors.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
return colors
}
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalf("failed to find config file at '%s'\n", *cFlag)
}
return *cFlag
}
func Set(config *Config) {
cfg = config
}
func Get() *Config {
return cfg
}
func Load() *Config {
config := &Config{}
maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
log.Fatalf("failed to read config: %v\n", err)
}
config.Version = readVersion()
config.App.LanguageColors = readLanguageColors()
config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
}
for k, v := range config.App.CustomLanguages {
if v == "" {
config.App.CustomLanguages[k] = "unknown"
}
}
Set(config)
return Get()
}

124
config/legacy.go Normal file
View File

@ -0,0 +1,124 @@
package config
import (
"github.com/joho/godotenv"
"gopkg.in/ini.v1"
"gopkg.in/yaml.v2"
"io/ioutil"
"log"
"os"
"strconv"
)
func maybeMigrateLegacyConfig() {
if yes, err := shouldMigrateLegacyConfig(); err != nil {
log.Fatalf("failed to determine whether to migrate legacy config: %v\n", err)
} else if yes {
log.Printf("migrating legacy config (%s, %s) to new format (%s); see https://github.com/muety/wakapi/issues/54\n", defaultConfigPathLegacy, defaultEnvConfigPathLegacy, defaultConfigPath)
if err := migrateLegacyConfig(); err != nil {
log.Fatalf("failed to migrate legacy config: %v\n", err)
}
log.Printf("config migration successful; please delete %s and %s now\n", defaultConfigPathLegacy, defaultEnvConfigPathLegacy)
}
}
func shouldMigrateLegacyConfig() (bool, error) {
if _, err := os.Stat(defaultConfigPath); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return true, err
}
return true, nil
}
func migrateLegacyConfig() error {
// step 1: read envVars file parameters
envFile, err := os.Open(defaultEnvConfigPathLegacy)
if err != nil {
return err
}
envVars, err := godotenv.Parse(envFile)
if err != nil {
return err
}
env := envVars["ENV"]
dbType := envVars["WAKAPI_DB_TYPE"]
dbUser := envVars["WAKAPI_DB_USER"]
dbPassword := envVars["WAKAPI_DB_PASSWORD"]
dbHost := envVars["WAKAPI_DB_HOST"]
dbName := envVars["WAKAPI_DB_NAME"]
dbPortStr := envVars["WAKAPI_DB_PORT"]
passwordSalt := envVars["WAKAPI_PASSWORD_SALT"]
dbPort, _ := strconv.Atoi(dbPortStr)
// step 2: read ini file
cfg, err := ini.Load(defaultConfigPathLegacy)
if err != nil {
return err
}
if dbType == "" {
dbType = SQLDialectSqlite
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
insecureCookies := cfg.Section("server").Key("insecure_cookies").MustBool(false)
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// step 3: instantiate config
config := &Config{
Env: env,
App: appConfig{
CustomLanguages: customLangs,
},
Security: securityConfig{
PasswordSalt: passwordSalt,
InsecureCookies: insecureCookies,
},
Db: dbConfig{
Host: dbHost,
Port: uint(dbPort),
User: dbUser,
Password: dbPassword,
Name: dbName,
Dialect: dbType,
MaxConn: dbMaxConn,
},
Server: serverConfig{
Port: port,
ListenIpV4: addr,
BasePath: basePath,
},
}
// step 4: serialize to yaml
yamlConfig, err := yaml.Marshal(config)
if err != nil {
return err
}
// step 5: write file
if err := ioutil.WriteFile(defaultConfigPath, yamlConfig, 0600); err != nil {
return err
}
return nil
}

9
config/templates.go Normal file
View File

@ -0,0 +1,9 @@
package config
const (
IndexTemplate = "index.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html"
)

23
docker-compose.yml Normal file
View File

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

15
go.mod
View File

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

175
go.sum
View File

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

149
main.go
View File

@ -2,100 +2,113 @@ package main
import (
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"github.com/muety/wakapi/repositories"
"log"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
_ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var (
db *gorm.DB
config *models.Config
config *conf.Config
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
aliasRepository *repositories.AliasRepository
heartbeatRepository *repositories.HeartbeatRepository
userRepository *repositories.UserRepository
languageMappingRepository *repositories.LanguageMappingRepository
summaryRepository *repositories.SummaryRepository
keyValueRepository *repositories.KeyValueRepository
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
languageMappingService *services.LanguageMappingService
summaryService *services.SummaryService
aggregationService *services.AggregationService
keyValueService *services.KeyValueService
)
// TODO: Refactor entire project to be structured after business domains
func main() {
config = models.GetConfig()
config = conf.Load()
// Enable line numbers in logging
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Show data loss warning
if config.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.DbDialect, utils.MakeConnectionString(config))
if config.DbDialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;")
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
}
db.LogMode(config.IsDev())
db.DB().SetMaxIdleConns(int(config.DbMaxConn))
db.DB().SetMaxOpenConns(int(config.DbMaxConn))
if config.IsDev() {
db = db.Debug()
}
sqlDb, _ := db.DB()
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil {
log.Println(err)
log.Fatal("could not connect to database")
}
// TODO: Graceful shutdown
defer db.Close()
defer sqlDb.Close()
// Migrate database schema
common.RunCustomPreMigrations(db, config)
runDatabaseMigrations()
applyFixtures()
common.RunCustomPostMigrations(db, config)
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
// Services
aliasService = services.NewAliasService(db)
heartbeatService = services.NewHeartbeatService(db)
userService = services.NewUserService(db)
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(db)
// Custom migrations and initial data
migrateLanguages()
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
// Aggregate heartbeats to summaries and persist them
go aggregationService.Schedule()
if config.CleanUp {
go heartbeatService.ScheduleCleanUp()
}
// TODO: move endpoint registration to the respective routes files
// Handlers
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
@ -126,12 +139,12 @@ 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.MethodPost).HandlerFunc(homeHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
@ -139,8 +152,11 @@ func main() {
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -158,7 +174,7 @@ func main() {
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
// Listen HTTP
portString := config.Addr + ":" + strconv.Itoa(config.Port)
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s := &http.Server{
Handler: router,
Addr: portString,
@ -170,36 +186,7 @@ func main() {
}
func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.DbDialect)(db); err != nil {
if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
log.Fatal(err)
}
}
func applyFixtures() {
if err := config.GetFixturesFunc(config.DbDialect)(db); err != nil {
log.Fatal(err)
}
}
func migrateLanguages() {
for k, v := range config.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
Updates(models.Heartbeat{Language: v})
if result.Error != nil {
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
}
}
}
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"log"
"net/http"
@ -17,7 +18,7 @@ import (
)
type AuthenticateMiddleware struct {
config *models.Config
config *conf.Config
userSrvc *services.UserService
cache *cache.Cache
whitelistPaths []string
@ -25,7 +26,7 @@ type AuthenticateMiddleware struct {
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: models.GetConfig(),
config: conf.Get(),
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
@ -57,8 +58,8 @@ 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.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound)
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
}
return
}
@ -106,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return nil, err
}
if !CheckAndMigratePassword(user, login, m.config.PasswordSalt, m.userSrvc) {
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) {
return nil, errors.New("invalid password")
}
@ -116,12 +117,12 @@ 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 {
if utils.IsMd5(user.Password) {
if utils.CheckPasswordMd5(user, login.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)
return true
}
return false
}
return utils.CheckPasswordBcrypt(user, login.Password, salt)
return utils.CompareBcrypt(user.Password, login.Password, salt)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
package common
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log"
)
func MigrateLanguages(db *gorm.DB) {
cfg := config.Get()
for k, v := range cfg.App.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
Updates(models.Heartbeat{Language: v})
if result.Error != nil {
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,11 +64,11 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
for i, s := range summaries {
data[i] = newDataFrom(s)
if s.FromTime.Before(minDate) {
minDate = s.FromTime
if s.FromTime.T().Before(minDate) {
minDate = s.FromTime.T()
}
if s.ToTime.After(maxDate) {
maxDate = s.ToTime
if s.ToTime.T().After(maxDate) {
maxDate = s.ToTime.T()
}
}
@ -101,8 +101,8 @@ func newDataFrom(s *models.Summary) *summariesData {
},
Range: &summariesRange{
Date: time.Now().Format(time.RFC3339),
End: s.ToTime,
Start: s.FromTime,
End: s.ToTime.T(),
Start: s.FromTime.T(),
Text: "",
Timezone: zone,
},
@ -158,13 +158,17 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
hrs := int(total.Hours())
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
percentage := math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100
if math.IsNaN(percentage) || math.IsInf(percentage, 0) {
percentage = 0
}
return &summariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs,
Minutes: mins,
Name: e.Key,
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
Percent: percentage,
Seconds: secs,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),

View File

@ -1,229 +0,0 @@
package models
import (
"encoding/json"
"github.com/gorilla/securecookie"
"github.com/jinzhu/gorm"
"github.com/joho/godotenv"
migrate "github.com/rubenv/sql-migrate"
"gopkg.in/ini.v1"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
)
var cfg *Config
type Config struct {
Env string
Version string
Port int
Addr string
BasePath string
DbHost string
DbPort uint
DbUser string
DbPassword string
DbName string
DbDialect string
DbMaxConn uint
CleanUp bool
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string
SecureCookieHashKey string
SecureCookieBlockKey string
InsecureCookies bool
CustomLanguages map[string]string
LanguageColors map[string]string
SecureCookie *securecookie.SecureCookie
}
func (c *Config) IsDev() bool {
return IsDev(c.Env)
}
func (c *Config) GetMigrationFunc(dbDialect string) MigrationFunc {
switch dbDialect {
case "sqlite3":
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/sqlite3",
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d migrations\n", n)
return nil
}
default:
return func(db *gorm.DB) error {
db.AutoMigrate(&Alias{})
db.AutoMigrate(&Summary{})
db.AutoMigrate(&SummaryItem{})
db.AutoMigrate(&User{})
db.AutoMigrate(&Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
db.AutoMigrate(&SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
db.AutoMigrate(&KeyStringValue{})
return nil
}
}
}
func (c *Config) GetFixturesFunc(dbDialect string) MigrationFunc {
return func(db *gorm.DB) error {
migrations := &migrate.FileMigrationSource{
Dir: "migrations/common/fixtures",
}
migrate.SetIgnoreUnknown(true)
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
if err != nil {
return err
}
log.Printf("applied %d fixtures\n", n)
return nil
}
}
func IsDev(env string) bool {
return env == "dev" || env == "development"
}
func SetConfig(config *Config) {
cfg = config
}
func GetConfig() *Config {
return cfg
}
func LookupFatal(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("missing env variable '%s'", key)
}
return v
}
func readVersion() string {
file, err := os.Open("version.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
return string(bytes)
}
func readConfig() *Config {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
version := readVersion()
env := LookupFatal("ENV")
dbType := LookupFatal("WAKAPI_DB_TYPE")
dbUser := LookupFatal("WAKAPI_DB_USER")
dbPassword := LookupFatal("WAKAPI_DB_PASSWORD")
dbHost := LookupFatal("WAKAPI_DB_HOST")
dbName := LookupFatal("WAKAPI_DB_NAME")
dbPortStr := LookupFatal("WAKAPI_DB_PORT")
passwordSalt := LookupFatal("WAKAPI_PASSWORD_SALT")
dbPort, err := strconv.Atoi(dbPortStr)
cfg, err := ini.Load("config.ini")
if err != nil {
log.Fatalf("Fail to read file: %v", err)
}
if dbType == "" {
dbType = "mysql"
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(1)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
insecureCookies := IsDev(env) || cfg.Section("server").Key("insecure_cookies").MustBool(false)
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
if strings.HasSuffix(basePath, "/") {
basePath = basePath[:len(basePath)-1]
}
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
data, err := ioutil.ReadFile("data/colors.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
secureCookie := securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
return &Config{
Env: env,
Version: version,
Port: port,
Addr: addr,
BasePath: basePath,
DbHost: dbHost,
DbPort: uint(dbPort),
DbUser: dbUser,
DbPassword: dbPassword,
DbName: dbName,
DbDialect: dbType,
DbMaxConn: dbMaxConn,
CleanUp: cleanUp,
InsecureCookies: insecureCookies,
SecureCookie: secureCookie,
PasswordSalt: passwordSalt,
CustomLanguages: customLangs,
LanguageColors: colors,
}
}

67
models/filters.go Normal file
View File

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

View File

@ -7,7 +7,7 @@ import (
type Heartbeat struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Type string `json:"type"`
@ -19,7 +19,7 @@ type Heartbeat struct {
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp
}
@ -27,19 +27,38 @@ func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
}
func (h *Heartbeat) Augment(customLangs map[string]string) {
if h.Language == "" {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := customLangs[ending]; !ok {
return
}
h.Language, _ = customLangs[ending]
func (h *Heartbeat) Augment(languageMappings map[string]string) {
if h.languageRegex == nil {
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
}
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
if len(groups) == 0 || len(groups[0]) != 2 {
return
}
ending := groups[0][1]
if _, ok := languageMappings[ending]; !ok {
return
}
h.Language, _ = languageMappings[ending]
}
func (h *Heartbeat) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = h.Project
case SummaryEditor:
key = h.Editor
case SummaryLanguage:
key = h.Language
case SummaryOS:
key = h.OperatingSystem
case SummaryMachine:
key = h.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

38
models/heartbeats.go Normal file
View File

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

View File

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

View File

@ -1,5 +1,5 @@
package models
func init() {
SetConfig(readConfig())
// nothing no init here, yet
}

View File

@ -4,7 +4,7 @@ import (
"database/sql/driver"
"errors"
"fmt"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"math"
"strconv"
"strings"
@ -24,43 +24,9 @@ type KeyStringValue struct {
Value string `gorm:"type:text"`
}
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
case SummaryLanguage:
return &Filters{Project: key}
case SummaryEditor:
return &Filters{Project: key}
case SummaryMachine:
return &Filters{Project: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time
@ -76,28 +42,38 @@ func (j *CustomTime) UnmarshalJSON(b []byte) error {
return nil
}
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
func (j *CustomTime) Scan(value interface{}) error {
var (
t time.Time
err error
)
switch value.(type) {
case string:
t, err := time.Parse("2006-01-02 15:04:05-07:00", value.(string))
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
}
*j = CustomTime(t)
case int64:
*j = CustomTime(time.Unix(value.(int64), 0))
t = time.Unix(0, value.(int64))
break
case time.Time:
*j = CustomTime(value.(time.Time))
t = value.(time.Time)
break
default:
return errors.New(fmt.Sprintf("unsupported type: %T", value))
}
t = time.Unix(0, (t.UnixNano()/int64(time.Millisecond))*int64(time.Millisecond)) // round to millisecond precision
*j = CustomTime(t)
return nil
}
func (j CustomTime) Value() (driver.Value, error) {
return time.Time(j), nil
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil
}
func (j CustomTime) String() string {
@ -105,6 +81,10 @@ func (j CustomTime) String() string {
return t.Format("2006-01-02 15:04:05.000")
}
func (j CustomTime) Time() time.Time {
func (j CustomTime) T() time.Time {
return time.Time(j)
}
func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0
}

View File

@ -35,18 +35,20 @@ const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"`
OperatingSystems []*SummaryItem `json:"operating_systems"`
Machines []*SummaryItem `json:"machines"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-"`
Type uint8 `json:"-"`
Key string `json:"key"`
@ -73,6 +75,8 @@ type SummaryParams struct {
Recompute bool
}
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
@ -147,31 +151,82 @@ func (s *Summary) TotalTime() time.Duration {
return timeSum * time.Second
}
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
var timeSum time.Duration
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if item.Key != key {
continue
}
timeSum += item.Total
timeSum = timeSum + item.Total*time.Second
}
}
return timeSum * time.Second
return timeSum
}
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
for _, f := range filter.All() {
timeSum += s.TotalTimeByKey(f.Type, f.Key)
}
return timeSum
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
s.Projects = processAliases(s.Projects)
s.Editors = processAliases(s.Editors)
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
return s
}

View File

@ -6,7 +6,7 @@ type User struct {
Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
}
type Login struct {
@ -26,6 +26,11 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"`
}
type TimeByUser struct {
User string
Time CustomTime
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat

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

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

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

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

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

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

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

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

24
repositories/alias.go Normal file
View File

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

View File

@ -0,0 +1,54 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type HeartbeatRepository struct {
db *gorm.DB
}
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db}
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db.Create(&heartbeats).Error; err != nil {
return err
}
return nil
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from).
Where("time < ?", to).
Order("time asc").
Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, min(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db.
Where("time <= ?", t).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

58
repositories/key_value.go Normal file
View File

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

View File

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

59
repositories/summary.go Normal file
View File

@ -0,0 +1,59 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"time"
)
type SummaryRepository struct {
db *gorm.DB
}
func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
return &SummaryRepository{db: db}
}
func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil {
return err
}
return nil
}
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(to_time) as time").
Joins("left join summaries on users.id = summaries.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *SummaryRepository) DeleteByUser(userId string) error {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}

80
repositories/user.go Normal file
View File

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

View File

@ -2,12 +2,14 @@ package v1
import (
"github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
"strings"
)
const (
@ -18,14 +20,15 @@ const (
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
config *models.Config
aliasSrvc *services.AliasService
config *config2.Config
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: models.GetConfig(),
config: config2.Get(),
}
}
@ -33,6 +36,11 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
if userAgent := r.Header.Get("user-agent"); !strings.HasPrefix(userAgent, "Shields.io/") && !h.config.IsDev() {
w.WriteHeader(http.StatusForbidden)
return
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled {
@ -50,18 +58,20 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
interval = groups[1]
}
filters := &models.Filters{}
var filters *models.Filters
switch filterEntity {
case "project":
filters.Project = filterKey
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
filters.OS = filterKey
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
filters.Editor = filterKey
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
filters.Language = filterKey
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
filters.Machine = filterKey
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
default:
filters = &models.Filters{}
}
summary, err, status := h.loadUserSummary(user, interval)
@ -87,7 +97,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,6 +2,7 @@ package v1
import (
"github.com/gorilla/mux"
config2 "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,13 +14,13 @@ import (
type AllTimeHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *config2.Config
}
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
return &AllTimeHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: config2.Get(),
}
}
@ -42,7 +43,7 @@ func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
utils.RespondJSON(w, http.StatusOK, vm)
}
@ -54,7 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -3,6 +3,7 @@ package v1
import (
"errors"
"github.com/gorilla/mux"
config2 "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,13 +15,13 @@ import (
type SummariesHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *config2.Config
}
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
return &SummariesHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: config2.Get(),
}
}
@ -85,7 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

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

View File

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

173
routes/home.go Normal file
View File

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

41
routes/imprint.go Normal file
View File

@ -0,0 +1,41 @@
package routes
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
)
type ImprintHandler struct {
config *conf.Config
keyValueSrvc *services.KeyValueService
}
func NewImprintHandler(keyValueService *services.KeyValueService) *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"),
}
}

View File

@ -1,179 +0,0 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"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 *models.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: models.GetConfig(),
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.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "") {
return
}
templates["index.tpl.html"].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["imprint.tpl.html"].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.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.PasswordSalt, h.userSrvc) {
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return
}
encoded, err := h.config.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.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.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.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.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.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "signup.tpl.html") {
return
}
templates["signup.tpl.html"].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.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
return
}
if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.BasePath, msg), http.StatusFound)
}

View File

@ -2,11 +2,10 @@ package routes
import (
"fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"net/http"
"path"
"strings"
)
@ -25,10 +24,13 @@ func loadTemplates() {
"title": strings.Title,
"capitalize": utils.Capitalize,
"getBasePath": func() string {
return models.GetConfig().BasePath
return config.Get().Server.BasePath
},
"getVersion": func() string {
return models.GetConfig().Version
return config.Get().Version
},
"getDbType": func() string {
return strings.ToLower(config.Get().Db.Dialect)
},
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
@ -55,33 +57,3 @@ func loadTemplates() {
templates[tplName] = tpl
}
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
if tplName == "" {
tplName = "index.tpl.html"
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error, Success: success})
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
}

View File

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

View File

@ -1,7 +1,9 @@
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"
@ -9,13 +11,13 @@ import (
type SummaryHandler struct {
summarySrvc *services.SummaryService
config *models.Config
config *conf.Config
}
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
config: conf.Get(),
}
}
@ -43,23 +45,25 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r)
if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", 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", "", "summary.tpl.html", http.StatusUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
return
}
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary),
ApiKey: user.ApiKey,
}
templates["summary.tpl.html"].Execute(w, vm)
templates[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
@ -68,10 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
return nil, err, http.StatusBadRequest
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

3
scripts/docker_mysql.sh Normal file
View File

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

View File

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

View File

@ -1,15 +1,17 @@
#!/usr/bin/python3
import argparse
import base64
import random
import string
import sys
from datetime import datetime, timedelta
from typing import List
from typing import List, Union
import requests
from tqdm import tqdm
N_PROJECTS = 5
N_PAST_HOURS = 24
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 vscode/1.42.1 vscode-wakatime/4.0.0'
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0'
LANGUAGES = {
'Go': 'go',
'Java': 'java',
@ -36,23 +38,25 @@ class Heartbeat:
self.is_write: bool = is_write
self.branch: str = branch
self.type: str = type
self.category: str = None
self.category: Union[str, None] = None
def generate_data(n: int) -> List[Heartbeat]:
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
data: List[Heartbeat] = []
now: datetime = datetime.today()
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(5)]
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
languages: List[str] = list(LANGUAGES.keys())
for i in range(n):
for _ in range(n):
p: str = random.choice(projects)
l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8))
delta: timedelta = timedelta(
hours=random.randint(0, N_PAST_HOURS - 1),
hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59),
seconds=random.randint(0, 59)
seconds=random.randint(0, 59),
milliseconds=random.randint(0, 999),
microseconds=random.randint(0, 999)
)
data.append(Heartbeat(
@ -65,29 +69,43 @@ def generate_data(n: int) -> List[Heartbeat]:
return data
def post_data_sync(data: List[Heartbeat], url: str):
for h in data:
def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
for h in tqdm(data):
r = requests.post(url, json=[h.__dict__], headers={
'User-Agent': UA
'User-Agent': UA,
'Authorization': f'Basic {encoded_key}'
})
if r.status_code != 200:
if r.status_code != 201:
print(r.text)
sys.exit(1)
def randomword(length: int) -> str:
letters = string.ascii_lowercase
return ''.join(random.choice(letters) for i in range(length))
return ''.join(random.choice(letters) for _ in range(length))
def parse_arguments():
parser = argparse.ArgumentParser(description='Wakapi test data insertion script.')
parser.add_argument('-n', type=int, default=20, help='total number of random heartbeats to generate and insert')
parser.add_argument('-u', '--url', type=str, default='http://localhost:3000/api/heartbeat',
help='url of your api\'s heartbeats endpoint')
parser.add_argument('-k', '--apikey', type=str, required=True,
help='your api key (to get one, go to the web interface, create a new user, log in and copy the key)')
parser.add_argument('-p', '--projects', type=int, default=5, help='number of different fake projects to generate')
parser.add_argument('-o', '--offset', type=int, default=24,
help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for')
parser.add_argument('-s', '--seed', type=int, default=2020,
help='a seed for initializing the pseudo-random number generator')
return parser.parse_args()
if __name__ == '__main__':
n: int = 10
url: str = 'http://admin:admin@localhost:3000/api/heartbeat'
args = parse_arguments()
if len(sys.argv) > 1:
n = int(sys.argv[1])
if len(sys.argv) > 2:
url = sys.argv[2]
random.seed(args.seed)
data: List[Heartbeat] = generate_data(n)
post_data_sync(data, url)
data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
post_data_sync(data, args.url, args.apikey)

View File

@ -1,34 +1,32 @@
package services
import (
"github.com/muety/wakapi/config"
"log"
"runtime"
"time"
"github.com/jasonlvhit/gocron"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
)
const (
aggregateIntervalDays int = 1 // TODO: Make configurable
aggregateIntervalDays int = 1
)
type AggregationService struct {
Config *models.Config
Db *gorm.DB
UserService *UserService
SummaryService *SummaryService
HeartbeatService *HeartbeatService
config *config.Config
userService *UserService
summaryService *SummaryService
heartbeatService *HeartbeatService
}
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
return &AggregationService{
Config: models.GetConfig(),
Db: db,
UserService: userService,
SummaryService: summaryService,
HeartbeatService: heartbeatService,
config: config.Get(),
userService: userService,
summaryService: summaryService,
heartbeatService: heartbeatService,
}
}
@ -39,31 +37,41 @@ type AggregationJob struct {
}
// Schedule a job to (re-)generate summaries every day shortly after midnight
// TODO: Make configurable
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
log.Fatalf("failed to run aggregation jobs: %v\n", err)
}
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil)
<-gocron.Start()
}
func (srv *AggregationService) Run(userIds map[string]bool) error {
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
defer close(jobs)
defer close(summaries)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
}
for i := 0; i < int(srv.Config.DbMaxConn); i++ {
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
go srv.persistWorker(summaries)
}
// Run once initially
srv.trigger(jobs)
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
gocron.Every(1).Day().At("02:15").Do(srv.trigger, jobs)
<-gocron.Start()
return srv.trigger(jobs, userIds)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.SummaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
} else {
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
@ -74,72 +82,83 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.SummaryService.Insert(summary); err != nil {
if err := srv.summaryService.Insert(summary); err != nil {
log.Printf("Failed to save summary (%v, %v, %s) %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
log.Println("Generating summaries.")
users, err := srv.UserService.GetAll()
if err != nil {
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
log.Println(err)
return err
}
latestSummaries, err := srv.SummaryService.GetLatestByUser()
if err != nil {
log.Println(err)
return err
}
userSummaryTimes := make(map[string]time.Time)
for _, s := range latestSummaries {
userSummaryTimes[s.UserID] = s.ToTime
}
missingUserIDs := make([]string, 0)
for _, u := range users {
if _, ok := userSummaryTimes[u.ID]; !ok {
missingUserIDs = append(missingUserIDs, u.ID)
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, len(userIds))
for i, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users[i] = u
}
}
} else {
users = allUsers
}
firstHeartbeats, err := srv.HeartbeatService.GetFirstUserHeartbeats(missingUserIDs)
// Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil {
log.Println(err)
return err
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
log.Println(err)
return err
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
// Build actual lookup table from it
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
for _, e := range firstUserHeartbeatTimes {
firstUserHeartbeatLookup[e.User] = e.Time
}
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
generateUserJobs(e.User, e.Time.T(), jobs)
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
// Case 2: User has no aggregated summaries, yet, but has heartbeats
// -> Spawn jobs to create summaries from their first heartbeat to now
generateUserJobs(e.User, t.T(), jobs)
}
// Case 3: User doesn't have heartbeats at all
// -> Nothing to do
}
return nil
}
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
var from, to time.Time
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var to time.Time
// Go to next day of either user's first heartbeat or latest aggregation
from.Add(-1 * time.Second)
from = time.Date(
from.Year(),
from.Month(),
from.Day()+aggregateIntervalDays,
0, 0, 0, 0,
from.Location(),
)
// Iteratively aggregate per-day summaries until end of yesterday is reached
end := getStartOfToday().Add(-1 * time.Second)
if lastAggregation.Hour() == 0 {
from = lastAggregation
} else {
from = time.Date(
lastAggregation.Year(),
lastAggregation.Month(),
lastAggregation.Day()+aggregateIntervalDays,
0, 0, 0, 0,
lastAggregation.Location(),
)
}
for from.Before(end) && to.Before(end) {
to = time.Date(
from.Year(),

View File

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

View File

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

View File

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

View File

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

View File

@ -3,87 +3,129 @@ package services
import (
"crypto/md5"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"math"
"sort"
"strconv"
"time"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
Config *models.Config
Cache *cache.Cache
Db *gorm.DB
HeartbeatService *HeartbeatService
AliasService *AliasService
config *config.Config
cache *cache.Cache
repository *repositories.SummaryRepository
heartbeatService *HeartbeatService
aliasService *AliasService
}
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
return &SummaryService{
Config: models.GetConfig(),
Cache: cache.New(24*time.Hour, 24*time.Hour),
Db: db,
HeartbeatService: heartbeatService,
AliasService: aliasService,
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
}
}
type Interval struct {
Start time.Time
End time.Time
}
// Public summary generation methods
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, 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, 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
@ -97,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
realTo = existingSummaries[len(existingSummaries)-1].ToTime
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: realFrom,
ToTime: realTo,
FromTime: models.CustomTime(from),
ToTime: models.CustomTime(to),
Projects: projectItems,
Languages: languageItems,
Editors: editorItems,
@ -129,84 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
Machines: machineItems,
}
allSummaries := []*models.Summary{aggregatedSummary}
allSummaries = append(allSummaries, existingSummaries...)
summary, err := mergeSummaries(allSummaries)
if err != nil {
return nil, err
}
if cacheKey != "" {
srv.Cache.SetDefault(cacheKey, summary)
}
summary.FillUnknown()
return summary, nil
}
// CRUD methods
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
return srv.repository.GetLastByUser()
}
func (srv *SummaryService) DeleteByUser(userId string) error {
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
if err := srv.Db.Create(summary).Error; err != nil {
return err
}
return nil
return srv.repository.Insert(summary)
}
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
// Private summary generation and utility methods
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.
Table("summaries").
Select("user_id, max(to_time) as to_time").
Group("user_id").
Scan(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
durations := make(map[string]time.Duration)
for i, h := range heartbeats {
var key string
switch summaryType {
case models.SummaryProject:
key = h.Project
case models.SummaryEditor:
key = h.Editor
case models.SummaryLanguage:
key = h.Language
case models.SummaryOS:
key = h.OperatingSystem
case models.SummaryMachine:
key = h.Machine
}
if key == "" {
key = models.UnknownSummaryKey
}
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
key = aliasedKey
}
key := h.GetKey(summaryType)
if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0)
@ -216,9 +191,16 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
continue
}
timePassed := h.Time.Time().Sub(heartbeats[i-1].Time.Time())
timeThresholded := math.Min(float64(timePassed), float64(time.Duration(2)*time.Minute))
durations[key] += time.Duration(int64(timeThresholded))
t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
// This is a hack. The time difference between two heartbeats from two subsequent day (e.g. 23:59:59 and 00:00:01) are ignored.
// This is to prevent a discrepancy between summaries computed solely from heartbeats and summaries involving pre-aggregated per-day summaries.
// For the latter, a duration is already pre-computed and information about individual heartbeats is lost, so there can be no cross-day overflow.
// Essentially, we simply ignore such edge-case heartbeats here, which makes the eventual total duration potentially a bit shorter.
if t1.Day() == t2.Day() {
timePassed := t1.Sub(t2)
tdiff = time.Duration(int64(math.Min(float64(timePassed), float64(HeartbeatDiffThreshold))))
}
durations[key] += tdiff
}
items := make([]*models.SummaryItem, 0)
@ -237,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) {
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime})
}
// Between
for i := 0; i < len(existingSummaries)-1; i++ {
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
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, existingSummaries[i+1].FromTime})
}
}
// Post
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) {
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime})
}
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")
}
@ -295,28 +241,28 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
return nil, errors.New("users don't match")
}
if s.FromTime.Before(minTime) {
minTime = s.FromTime
if s.FromTime.T().Before(minTime) {
minTime = s.FromTime.T()
}
if s.ToTime.After(maxTime) {
maxTime = s.ToTime
if s.ToTime.T().After(maxTime) {
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 = minTime
finalSummary.ToTime = maxTime
finalSummary.FromTime = models.CustomTime(minTime)
finalSummary.ToTime = models.CustomTime(maxTime)
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
@ -346,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))
}

View File

@ -1,49 +1,35 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
uuid "github.com/satori/go.uuid"
)
type UserService struct {
Config *models.Config
Db *gorm.DB
Config *config.Config
repository *repositories.UserRepository
}
func NewUserService(db *gorm.DB) *UserService {
func NewUserService(userRepo *repositories.UserRepository) *UserService {
return &UserService{
Config: models.GetConfig(),
Db: db,
Config: config.Get(),
repository: userRepo,
}
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
return u, err
}
return u, nil
return srv.repository.GetById(userId)
}
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
u := &models.User{}
if err := srv.Db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
}
return u, nil
return srv.repository.GetByApiKey(key)
}
func (srv *UserService) GetAll() ([]*models.User, error) {
var users []*models.User
if err := srv.Db.
Table("users").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
return srv.repository.GetAll()
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
@ -53,33 +39,17 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
Password: signup.Password,
}
if err := utils.HashPassword(u, srv.Config.PasswordSalt); err != nil {
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {
return nil, false, err
} else {
u.Password = hash
}
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return u, true, nil
}
return u, false, nil
return srv.repository.InsertOrGet(u)
}
func (srv *UserService) Update(user *models.User) (*models.User, error) {
result := srv.Db.Model(&models.User{}).Updates(user)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
return srv.repository.Update(user)
}
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
@ -88,30 +58,15 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
}
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
if hash, err := utils.HashBcrypt(user.Password, srv.Config.Security.PasswordSalt); err != nil {
return nil, err
} else {
user.Password = hash
}
result := srv.Db.Model(user).Update("password", user.Password)
if err := result.Error; err != nil {
return nil, err
} else if result.RowsAffected < 1 {
return nil, errors.New("nothing changes")
}
return user, nil
return srv.repository.UpdateField(user, "password", user.Password)
}

View File

@ -203,7 +203,7 @@ function togglePlaceholders(mask) {
}
function getPresentDataMask() {
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
return data.map(list => list ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0)
}
function getContainer(chart) {
@ -242,8 +242,8 @@ function equalizeHeights() {
})
}
function getTotal(data) {
let total = data.reduce((acc, d) => acc + d.total, 0)
function getTotal(items) {
let total = items.reduce((acc, d) => acc + d.total, 0)
document.getElementById('total-span').innerText = total.toString().toHHMMSS()
}
@ -303,4 +303,5 @@ window.addEventListener('load', function () {
setTopLabels()
togglePlaceholders(getPresentDataMask())
draw()
})
})

View File

@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"golang.org/x/crypto/bcrypt"
"net/http"
@ -45,13 +46,13 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) {
return string(keyBytes), err
}
func ExtractCookieAuth(r *http.Request, config *models.Config) (login *models.Login, err error) {
func ExtractCookieAuth(r *http.Request, config *config.Config) (login *models.Login, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil {
if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil {
return nil, errors.New("invalid parameters")
}
@ -62,28 +63,29 @@ func IsMd5(hash string) bool {
return md5Regex.Match([]byte(hash))
}
func CheckPasswordBcrypt(user *models.User, password, salt string) bool {
plainPassword := []byte(strings.TrimSpace(password) + salt)
err := bcrypt.CompareHashAndPassword([]byte(user.Password), plainPassword)
func CompareBcrypt(wanted, actual, pepper string) bool {
plainPassword := []byte(strings.TrimSpace(actual) + pepper)
err := bcrypt.CompareHashAndPassword([]byte(wanted), plainPassword)
return err == nil
}
// deprecated, only here for backwards compatibility
func CheckPasswordMd5(user *models.User, password string) bool {
hash := md5.Sum([]byte(password))
hashStr := hex.EncodeToString(hash[:])
if hashStr == user.Password {
return true
}
return false
func CompareMd5(wanted, actual, pepper string) bool {
return HashMd5(actual, pepper) == wanted
}
// inplace
func HashPassword(u *models.User, salt string) error {
plainSaltedPassword := []byte(strings.TrimSpace(u.Password) + salt)
bytes, err := bcrypt.GenerateFromPassword(plainSaltedPassword, bcrypt.DefaultCost)
func HashBcrypt(plain, pepper string) (string, error) {
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
bytes, err := bcrypt.GenerateFromPassword(plainPepperedPassword, bcrypt.DefaultCost)
if err == nil {
u.Password = string(bytes)
return string(bytes), nil
}
return err
return "", err
}
func HashMd5(plain, pepper string) string {
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
hash := md5.Sum(plainPepperedPassword)
hashStr := hex.EncodeToString(hash[:])
return hashStr
}

View File

@ -2,11 +2,8 @@ package utils
import (
"errors"
"fmt"
"regexp"
"time"
"github.com/muety/wakapi/models"
)
func ParseDate(date string) (time.Time, error) {
@ -22,48 +19,10 @@ func FormatDateHuman(date time.Time) string {
}
func ParseUserAgent(ua string) (string, string, error) {
re := regexp.MustCompile(`^wakatime\/[\d+.]+\s\((\w+).*\)\s.+\s(\w+)\/.+$`)
re := regexp.MustCompile(`(?iU)^wakatime\/[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
groups := re.FindAllStringSubmatch(ua, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string")
}
return groups[0][1], groups[0][2], nil
}
func MakeConnectionString(config *models.Config) string {
switch config.DbDialect {
case "mysql":
return mySqlConnectionString(config)
case "postgres":
return postgresConnectionString(config)
case "sqlite3":
return sqliteConnectionString(config)
}
return ""
}
func mySqlConnectionString(config *models.Config) string {
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.DbUser,
config.DbPassword,
config.DbHost,
config.DbPort,
config.DbName,
"Local",
)
}
func postgresConnectionString(config *models.Config) string {
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
config.DbHost,
config.DbPort,
config.DbUser,
config.DbName,
config.DbPassword,
)
}
func sqliteConnectionString(config *models.Config) string {
return config.DbName
}

50
utils/common_test.go Normal file
View File

@ -0,0 +1,50 @@
package utils
import (
"errors"
"testing"
)
func TestParseUserAgent(t *testing.T) {
tests := []struct {
in string
outOs string
outEditor string
outError error
}{
{
"wakatime/13.0.7 (Linux-4.15.0-96-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 GoLand/2019.3.4 GoLand-wakatime/11.0.1",
"Linux",
"GoLand",
nil,
},
{
"wakatime/13.0.4 (Linux-5.4.64-x86_64-with-glibc2.2.5) Python3.7.6.final.0 emacs-wakatime/1.0.2",
"Linux",
"emacs",
nil,
},
{
"",
"",
"",
errors.New(""),
},
{
"wakatime/13.0.7 Python3.8.0.final.0 GoLand/2019.3.4 GoLand-wakatime/11.0.1",
"",
"",
errors.New(""),
},
}
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)
}
}
}
func checkErr(expected, actual error) bool {
return (expected == nil && actual == nil) || (expected != nil && actual != nil)
}

View File

@ -2,6 +2,7 @@ package utils
import (
"encoding/json"
"log"
"net/http"
)
@ -9,7 +10,7 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(object); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("error while writing json response: %v", err)
}
}

View File

@ -1 +1 @@
1.11.1
1.16.0

View File

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

View File

@ -1,4 +1,5 @@
<html>
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}

View File

@ -1,4 +1,5 @@
<html>
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}

View File

@ -1,4 +1,5 @@
<html>
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
@ -47,7 +48,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>
@ -65,7 +66,60 @@
</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>.
</div>
{{ if .LanguageMappings }}
{{ range $i, $mapping := .LanguageMappings }}
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1 text-align">
<label class="inline-block text-sm mb-1 text-gray-500" >When filename ends in:</label>
{{ $mapping.Extension }}
<label class="inline-block text-sm mb-1 text-gray-500" >Change the language to:</label>
{{ $mapping.Language }}
<form class="float-right" action="settings/language_mappings/delete" method="post">
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Remove
</button>
</form>
</div>
{{end}}
{{else}}
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1">
No rules.
</div>
{{end}}
<form action="settings/language_mappings" method="post">
<div class="inline-block justify-around mt-4 w-full">
<label class="inline-block text-sm mb-1 text-gray-500" for="extension">When filename ends in:</label>
<input class="shadow appearance-nonshadow 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="extension"
name="extension" placeholder=".py" minlength="1" required>
</div>
<div class="inline-block justify-around mt-4 w-full">
<label class="inline-block text-sm mb-1 text-gray-500" for="language">Change the language to:</label>
<input class="shadow appearance-nonshadow 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="language"
name="language" placeholder="Python" minlength="1" required>
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</form>
</div>
<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>
@ -86,7 +140,7 @@
<div class="flex flex-col mb-4">
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" alt="Shields.io badge"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
@ -94,7 +148,7 @@
</div>
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d" alt="Shields.io badge"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
@ -104,7 +158,7 @@
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
{{ else }}
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800" rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
@ -115,6 +169,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>
@ -128,6 +210,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" . }}
@ -135,4 +225,5 @@
{{ template "foot.tpl.html" . }}
</body>
</html>
</html>

View File

@ -1,4 +1,5 @@
<html>
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
@ -19,9 +20,11 @@
<p class="text-sm text-gray-300">
💡 In order to use Wakapi, you need to create an account.
After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">WakaTime</a>
client tools.
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">this readme section</a> for instructions.
You will be able to view you <strong>API Key</strong> once you log in.
</p>

View File

@ -1,4 +1,5 @@
<html>
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
@ -54,8 +55,8 @@
<div class="flex justify-center">
<div class="p-1">
<div class="flex justify-center p-4 bg-white rounded shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime | date }}</span></p>
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime.T | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime.T | date }}</span></p>
<p class="mx-2"><strong></strong> <span id="total-span" title="Total Hours"></span></p>
</div>
</div>
@ -69,7 +70,7 @@
</div>
<canvas id="chart-projects"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div>
@ -82,7 +83,7 @@
</div>
<canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div>
@ -95,7 +96,7 @@
</div>
<canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div>
@ -108,7 +109,7 @@
</div>
<canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div>
@ -121,7 +122,7 @@
</div>
<canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div>