mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
177cbb12fc | |||
a4c344aaa1 | |||
c575b2fd5c | |||
67a59561c8 | |||
f7520b2b4a | |||
54a944ec41 | |||
44b6efb6ee | |||
efd4764728 | |||
dd50b4076f | |||
21b822de42 | |||
4d22756b8a | |||
c54f2743fd | |||
a8d5d69629 | |||
0111aa7543 | |||
3bafde7ab1 | |||
b378597594 | |||
29619f09ed | |||
ff3fea0359 | |||
660fefcca9 | |||
2ecbb3ea02 | |||
f843be8d12 | |||
062a9c6f57 | |||
1c0e63e125 | |||
45f372168d | |||
0760be86ff | |||
6e2f3e6731 | |||
d60dddb550 | |||
19a8c61f77 | |||
fde8c35362 | |||
8dca9f5cc0 | |||
570aeebe01 | |||
21567e7601 | |||
a8009e107d | |||
84e9559860 | |||
7c8ea86d4e | |||
587ac6a330 | |||
97cb29ee4d | |||
cecb5e113c | |||
a059c637a7 | |||
75b33d5e42 | |||
50b7a9ec3d | |||
82ed386359 | |||
12cc4cd9cf | |||
2eccb7a468 | |||
08a83af8da | |||
c0d6855546 | |||
0af7d2f8ef | |||
11d1d5bc99 | |||
ada0863f7c | |||
7818f6b094 | |||
f86eb7668d | |||
24469e4922 |
@ -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 !
|
1
.github/workflows/linux-build-on-release.yml
vendored
1
.github/workflows/linux-build-on-release.yml
vendored
@ -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
4
.gitignore
vendored
@ -5,4 +5,6 @@ wakapi
|
||||
.idea
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
*.db
|
||||
config.yml
|
||||
config.ini
|
18
Dockerfile
18
Dockerfile
@ -8,8 +8,8 @@ RUN cd /src && 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.
|
||||
# Available options are:
|
||||
# to override config values using `-e` syntax.
|
||||
# Available options are:
|
||||
# – WAKAPI_DB_TYPE
|
||||
# – WAKAPI_DB_USER
|
||||
# – WAKAPI_DB_PASSWORD
|
||||
@ -17,29 +17,25 @@ RUN cd /src && go build -o wakapi
|
||||
# – WAKAPI_DB_PORT
|
||||
# – WAKAPI_DB_NAME
|
||||
# – WAKAPI_PASSWORD_SALT
|
||||
# – WAKAPI_DEFAULT_USER_NAME
|
||||
# – WAKAPI_DEFAULT_USER_PASSWORD
|
||||
# – WAKAPI_BASE_PATH
|
||||
|
||||
FROM debian
|
||||
WORKDIR /app
|
||||
|
||||
ENV ENV prod
|
||||
ENV ENVIRONMENT prod
|
||||
ENV WAKAPI_DB_TYPE sqlite3
|
||||
ENV WAKAPI_DB_USER ''
|
||||
ENV WAKAPI_DB_PASSWORD ''
|
||||
ENV WAKAPI_DB_HOST ''
|
||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||
ENV WAKAPI_PASSWORD_SALT ''
|
||||
ENV WAKAPI_DEFAULT_USER_NAME admin
|
||||
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
|
||||
|
||||
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
|
||||
|
78
README.md
78
README.md
@ -4,9 +4,12 @@
|
||||
[](https://saythanks.io/to/n1try)
|
||||

|
||||
[](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||

|
||||
|
||||
[](https://buymeacoff.ee/n1try)
|
||||
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
---
|
||||
|
||||
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
|
||||
@ -15,12 +18,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.
|
||||
## 👀 Demo
|
||||
🔥 **New:** There is a hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! 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 ❕
|
||||
|
||||
## Prerequisites
|
||||
## ⚙️ Prerequisites
|
||||
**On the server side:**
|
||||
* Go >= 1.13 (with `$GOPATH` properly set)
|
||||
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||
@ -32,26 +35,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)
|
||||
@ -64,7 +87,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,15 +103,36 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
|
||||
* Language ~ type **1**
|
||||
* Editor ~ type **2**
|
||||
* OS ~ type **3**
|
||||
* Machine ~ type **4**
|
||||
|
||||
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
||||
## 🔧 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)).
|
||||
|
||||
## Best Practices
|
||||
* `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/health`
|
||||
|
||||
## ⤴️ 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.com/MacroPower/wakatime_exporter)
|
||||
|
||||
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
|
||||
|
||||
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
|
||||
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
|
||||
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
26
config.default.yml
Normal 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
|
14
config.ini
14
config.ini
@ -1,14 +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
|
213
config/config.go
Normal file
213
config/config.go
Normal file
@ -0,0 +1,213 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "config.yml"
|
||||
defaultConfigPathLegacy = "config.ini"
|
||||
defaultEnvConfigPathLegacy = ".env"
|
||||
)
|
||||
|
||||
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 {
|
||||
case "sqlite3":
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.FileMigrationSource{
|
||||
Dir: "migrations/sqlite3",
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied %d migrations\n", n)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
db.AutoMigrate(&models.Alias{})
|
||||
db.AutoMigrate(&models.Summary{})
|
||||
db.AutoMigrate(&models.SummaryItem{})
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
|
||||
db.AutoMigrate(&models.SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
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)
|
||||
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 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()
|
||||
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
127
config/legacy.go
Normal file
127
config/legacy.go
Normal file
@ -0,0 +1,127 @@
|
||||
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 = "sqlite3"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// step 3: instantiate config
|
||||
config := &Config{
|
||||
Env: env,
|
||||
App: appConfig{
|
||||
CleanUp: cleanUp,
|
||||
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
9
config/templates.go
Normal 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"
|
||||
)
|
4
go.mod
4
go.mod
@ -8,13 +8,15 @@ 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/configor v1.2.0
|
||||
github.com/jinzhu/gorm v1.9.11
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/pretty v0.2.0 // indirect
|
||||
github.com/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 v0.0.0-20191014134946-beb77b81825f
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
gopkg.in/yaml.v2 v2.2.5
|
||||
)
|
||||
|
5
go.sum
5
go.sum
@ -1,6 +1,7 @@
|
||||
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=
|
||||
@ -154,6 +155,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
|
||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
|
||||
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@ -318,6 +321,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
|
||||
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/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=
|
||||
|
76
main.go
76
main.go
@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations/common"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -13,15 +15,16 @@ import (
|
||||
_ "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"
|
||||
)
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
config *models.Config
|
||||
config *conf.Config
|
||||
)
|
||||
|
||||
var (
|
||||
@ -36,32 +39,36 @@ var (
|
||||
// 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.App.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||
if config.DbDialect == "sqlite3" {
|
||||
db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config))
|
||||
if config.Db.Dialect == "sqlite3" {
|
||||
db.DB().Exec("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
db.LogMode(config.IsDev())
|
||||
db.DB().SetMaxIdleConns(int(config.DbMaxConn))
|
||||
db.DB().SetMaxOpenConns(int(config.DbMaxConn))
|
||||
db.DB().SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
db.DB().SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
log.Fatal("could not connect to database")
|
||||
}
|
||||
// TODO: Graceful shutdown
|
||||
defer db.Close()
|
||||
|
||||
// Migrate database schema
|
||||
runDatabaseMigrations()
|
||||
applyFixtures()
|
||||
runCustomMigrations()
|
||||
|
||||
// Services
|
||||
aliasService = services.NewAliasService(db)
|
||||
@ -71,22 +78,24 @@ func main() {
|
||||
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(db)
|
||||
|
||||
// Custom migrations and initial data
|
||||
migrateLanguages()
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
go aggregationService.Schedule()
|
||||
|
||||
if config.CleanUp {
|
||||
if config.App.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)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
@ -94,6 +103,9 @@ func main() {
|
||||
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
|
||||
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
|
||||
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
|
||||
|
||||
// Middlewares
|
||||
recoveryMiddleware := handlers.RecoveryHandler()
|
||||
@ -101,7 +113,7 @@ func main() {
|
||||
corsMiddleware := handlers.CORS()
|
||||
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
|
||||
userService,
|
||||
[]string{"/api/health"},
|
||||
[]string{"/api/health", "/api/compat/shields/v1"},
|
||||
).Handler
|
||||
|
||||
// Router configs
|
||||
@ -125,17 +137,25 @@ func main() {
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
|
||||
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
|
||||
|
||||
// Wakatime compat V1 API Routes
|
||||
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
|
||||
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
|
||||
|
||||
// Shields.io compat API Routes
|
||||
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
|
||||
|
||||
// Static Routes
|
||||
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,
|
||||
@ -147,28 +167,20 @@ 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 runCustomMigrations() {
|
||||
common.ApplyFixtures(db)
|
||||
common.MigrateLanguages(db)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
15
migrations/common/fixtures.go
Normal file
15
migrations/common/fixtures.go
Normal file
@ -0,0 +1,15 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"log"
|
||||
)
|
||||
|
||||
func ApplyFixtures(db *gorm.DB) {
|
||||
cfg := config.Get()
|
||||
|
||||
if err := cfg.GetFixturesFunc(cfg.Db.Dialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values (`key`, `value`) values ('imprint', 'no content here');
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
delete from key_string_values where `key` = 'imprint';
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
25
migrations/common/languages.go
Normal file
25
migrations/common/languages.go
Normal file
@ -0,0 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
11
migrations/sqlite3/4_machine_column.sql
Normal file
11
migrations/sqlite3/4_machine_column.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +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`;
|
11
migrations/sqlite3/5_badges_column.sql
Normal file
11
migrations/sqlite3/5_badges_column.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +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`;
|
37
models/compat/shields/v1/badge.go
Normal file
37
models/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,37 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://shields.io/endpoint
|
||||
|
||||
const (
|
||||
defaultLabel = "coding time"
|
||||
defaultColor = "#2D3748" // not working
|
||||
)
|
||||
|
||||
type BadgeData struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &BadgeData{
|
||||
SchemaVersion: 1,
|
||||
Label: defaultLabel,
|
||||
Message: utils.FmtWakatimeDuration(total),
|
||||
Color: defaultColor,
|
||||
}
|
||||
}
|
36
models/compat/wakatime/v1/all_time.go
Normal file
36
models/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,36 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
|
||||
type AllTimeViewModel struct {
|
||||
Data *allTimeData `json:"data"`
|
||||
}
|
||||
|
||||
type allTimeData struct {
|
||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||
}
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByKey(models.SummaryProject, key)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &AllTimeViewModel{
|
||||
Data: &allTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
},
|
||||
}
|
||||
}
|
176
models/compat/wakatime/v1/summaries.go
Normal file
176
models/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,176 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#summaries
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
Data []*summariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
}
|
||||
|
||||
type summariesData struct {
|
||||
Categories []*summariesEntry `json:"categories"`
|
||||
Dependencies []*summariesEntry `json:"dependencies"`
|
||||
Editors []*summariesEntry `json:"editors"`
|
||||
Languages []*summariesEntry `json:"languages"`
|
||||
Machines []*summariesEntry `json:"machines"`
|
||||
OperatingSystems []*summariesEntry `json:"operating_systems"`
|
||||
Projects []*summariesEntry `json:"projects"`
|
||||
GrandTotal *summariesGrandTotal `json:"grand_total"`
|
||||
Range *summariesRange `json:"range"`
|
||||
}
|
||||
|
||||
type summariesEntry struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Name string `json:"name"`
|
||||
Percent float64 `json:"percent"`
|
||||
Seconds int `json:"seconds"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesGrandTotal struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesRange struct {
|
||||
Date string `json:"date"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
Text string `json:"text"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||
data := make([]*summariesData, len(summaries))
|
||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||
|
||||
for i, s := range summaries {
|
||||
data[i] = newDataFrom(s)
|
||||
|
||||
if s.FromTime.T().Before(minDate) {
|
||||
minDate = s.FromTime.T()
|
||||
}
|
||||
if s.ToTime.T().After(maxDate) {
|
||||
maxDate = s.ToTime.T()
|
||||
}
|
||||
}
|
||||
|
||||
return &SummariesViewModel{
|
||||
Data: data,
|
||||
End: maxDate,
|
||||
Start: minDate,
|
||||
}
|
||||
}
|
||||
|
||||
func newDataFrom(s *models.Summary) *summariesData {
|
||||
zone, _ := time.Now().Zone()
|
||||
total := s.TotalTime()
|
||||
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
||||
|
||||
data := &summariesData{
|
||||
Categories: make([]*summariesEntry, 0),
|
||||
Dependencies: make([]*summariesEntry, 0),
|
||||
Editors: make([]*summariesEntry, len(s.Editors)),
|
||||
Languages: make([]*summariesEntry, len(s.Languages)),
|
||||
Machines: make([]*summariesEntry, len(s.Machines)),
|
||||
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
|
||||
Projects: make([]*summariesEntry, len(s.Projects)),
|
||||
GrandTotal: &summariesGrandTotal{
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime.T(),
|
||||
Start: s.FromTime.T(),
|
||||
Text: "",
|
||||
Timezone: zone,
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Projects {
|
||||
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Editors {
|
||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.OperatingSystems {
|
||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Machines {
|
||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||
}
|
||||
}(data)
|
||||
|
||||
wg.Wait()
|
||||
return data
|
||||
}
|
||||
|
||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
|
||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||
// TODO: fix some day, while migrating persisted summary items
|
||||
total := e.Total * time.Second
|
||||
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: percentage,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
}
|
||||
}
|
225
models/config.go
225
models/config.go
@ -1,225 +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()
|
||||
}
|
||||
|
||||
basePath := cfg.Section("server").Key("base_path").MustString("/")
|
||||
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,
|
||||
}
|
||||
}
|
@ -5,8 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
@ -20,7 +18,8 @@ type Heartbeat struct {
|
||||
IsWrite bool `json:"is_write"`
|
||||
Editor string `json:"editor"`
|
||||
OperatingSystem string `json:"operating_system"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||
Machine string `json:"machine"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
|
||||
languageRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
package models
|
||||
|
||||
func init() {
|
||||
SetConfig(readConfig())
|
||||
// nothing no init here, yet
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -23,46 +24,97 @@ 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 CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Split(strings.Trim(string(b), "\""), ".")[0]
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.Unix(i, 0)
|
||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
||||
*j = CustomTime(t)
|
||||
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 {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
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)
|
||||
}
|
||||
|
@ -5,22 +5,44 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
NSummaryTypes uint8 = 4
|
||||
NSummaryTypes uint8 = 99
|
||||
SummaryProject uint8 = 0
|
||||
SummaryLanguage uint8 = 1
|
||||
SummaryEditor uint8 = 2
|
||||
SummaryOS uint8 = 3
|
||||
SummaryMachine uint8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalYesterday string = "day"
|
||||
IntervalThisWeek string = "week"
|
||||
IntervalThisMonth string = "month"
|
||||
IntervalThisYear string = "year"
|
||||
IntervalPast7Days string = "7_days"
|
||||
IntervalPast30Days string = "30_days"
|
||||
IntervalPast12Months string = "12_months"
|
||||
IntervalAny string = "any"
|
||||
)
|
||||
|
||||
func Intervals() []string {
|
||||
return []string{
|
||||
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
|
||||
}
|
||||
}
|
||||
|
||||
const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime time.Time `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime time.Time `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects"`
|
||||
Languages []*SummaryItem `json:"languages"`
|
||||
Editors []*SummaryItem `json:"editors"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
||||
Machines []*SummaryItem `json:"machines"`
|
||||
}
|
||||
|
||||
type SummaryItem struct {
|
||||
@ -43,3 +65,113 @@ type SummaryViewModel struct {
|
||||
Success string
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type SummaryParams struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
User *User
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
return map[uint8]*[]*SummaryItem{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
}
|
||||
|
||||
/* Augments the summary in a way that at least one item is present for every type.
|
||||
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||
for the missing type.
|
||||
For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in
|
||||
the data for old heartbeats and summaries. If a user has two years of data without machine information and
|
||||
one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount
|
||||
of time than the other ones.
|
||||
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||
*/
|
||||
func (s *Summary) FillUnknown() {
|
||||
types := s.Types()
|
||||
typeItems := s.MappedItems()
|
||||
missingTypes := make([]uint8, 0)
|
||||
|
||||
for _, t := range types {
|
||||
if len(*typeItems[t]) == 0 {
|
||||
missingTypes = append(missingTypes, t)
|
||||
}
|
||||
}
|
||||
|
||||
// can't proceed if entire summary is empty
|
||||
if len(missingTypes) == len(types) {
|
||||
return
|
||||
}
|
||||
|
||||
timeSum := s.TotalTime()
|
||||
|
||||
// construct dummy item for all missing types
|
||||
for _, t := range missingTypes {
|
||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
||||
Type: t,
|
||||
Key: UnknownSummaryKey,
|
||||
Total: timeSum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTime() time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
// calculate total duration from any of the present sets of items
|
||||
for _, t := range s.Types() {
|
||||
if items := mappedItems[t]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
|
||||
var 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
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
@ -6,6 +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"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -37,7 +38,7 @@ func (s *Signup) IsValid() bool {
|
||||
}
|
||||
|
||||
func validateUsername(username string) bool {
|
||||
return len(username) >= 3
|
||||
return len(username) >= 3 && username != "current"
|
||||
}
|
||||
|
||||
func validatePassword(password string) bool {
|
||||
|
103
routes/compat/shields/v1/badge.go
Normal file
103
routes/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,103 @@
|
||||
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 (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: config2.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
}
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
interval = groups[1]
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
filters.Project = filterKey
|
||||
case "os":
|
||||
filters.OS = filterKey
|
||||
case "editor":
|
||||
filters.Editor = filterKey
|
||||
case "language":
|
||||
filters.Language = filterKey
|
||||
case "machine":
|
||||
filters.Machine = filterKey
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
64
routes/compat/wakatime/v1/all_time.go
Normal file
64
routes/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,64 @@
|
||||
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"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(authorizedUser)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: time.Time{},
|
||||
To: time.Now(),
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
97
routes/compat/wakatime/v1/summaries.go
Normal file
97
routes/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,97 @@
|
||||
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"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: support parameters: project, branches, timeout, writes_only, timezone
|
||||
https://wakatime.com/developers#summaries
|
||||
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
|
||||
*/
|
||||
|
||||
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err, status := h.loadUserSummaries(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var start, end time.Time
|
||||
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
|
||||
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
|
||||
start = utils.StartOfToday()
|
||||
end = time.Now()
|
||||
} else {
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||
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
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
summaries[i] = summary
|
||||
}
|
||||
|
||||
return summaries, nil, http.StatusOK
|
||||
}
|
@ -2,6 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@ -12,21 +13,26 @@ import (
|
||||
)
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
config *models.Config
|
||||
config *config2.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
config: models.GetConfig(),
|
||||
config: config2.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
}
|
||||
}
|
||||
|
||||
type heartbeatResponseVm struct {
|
||||
Responses [][]interface{} `json:"responses"`
|
||||
}
|
||||
|
||||
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if err := dec.Decode(&heartbeats); err != nil {
|
||||
@ -38,9 +44,10 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
for _, hb := range heartbeats {
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
hb.Machine = machineName
|
||||
hb.User = user
|
||||
hb.UserID = user.ID
|
||||
hb.Augment(h.config.CustomLanguages)
|
||||
hb.Augment(h.config.App.CustomLanguages)
|
||||
|
||||
if !hb.Valid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@ -55,5 +62,23 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
// to make the cli consider all heartbeats to having been successfully saved
|
||||
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
|
||||
func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||
responses := make([][]interface{}, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
r := make([]interface{}, 2)
|
||||
r[0] = nil
|
||||
r[1] = http.StatusCreated
|
||||
responses[i] = r
|
||||
}
|
||||
|
||||
return &heartbeatResponseVm{
|
||||
Responses: responses,
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -13,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
type IndexHandler struct {
|
||||
config *models.Config
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
keyValueSrvc *services.KeyValueService
|
||||
}
|
||||
@ -23,7 +24,7 @@ var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
|
||||
return &IndexHandler{
|
||||
config: models.GetConfig(),
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
@ -35,7 +36,7 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -43,7 +44,7 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates["index.tpl.html"].Execute(w, nil)
|
||||
templates[conf.IndexTemplate].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
@ -56,7 +57,7 @@ func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates["imprint.tpl.html"].Execute(w, &struct {
|
||||
templates[conf.ImprintTemplate].Execute(w, &struct {
|
||||
HtmlText string
|
||||
}{HtmlText: text})
|
||||
}
|
||||
@ -67,7 +68,7 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -88,12 +89,12 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.PasswordSalt, h.userSrvc) {
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
|
||||
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
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", "", "", http.StatusInternalServerError)
|
||||
return
|
||||
@ -106,11 +107,11 @@ func (h *IndexHandler) PostLogin(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)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
@ -118,8 +119,8 @@ func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !h.config.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
@ -128,15 +129,15 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "signup.tpl.html") {
|
||||
if handleAlerts(w, r, conf.SignupTemplate) {
|
||||
return
|
||||
}
|
||||
|
||||
templates["signup.tpl.html"].Execute(w, nil)
|
||||
templates[conf.SignupTemplate].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
@ -145,35 +146,35 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
respondAlert(w, "invalid parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
|
||||
respondAlert(w, "failed to create new user", "", conf.SignupTemplate, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
|
||||
respondAlert(w, "user already existing", "", conf.SignupTemplate, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
@ -25,10 +25,10 @@ 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
|
||||
},
|
||||
"htmlSafe": func(html string) template.HTML {
|
||||
return template.HTML(html)
|
||||
@ -59,7 +59,7 @@ func loadTemplates() {
|
||||
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
|
||||
w.WriteHeader(status)
|
||||
if tplName == "" {
|
||||
tplName = "index.tpl.html"
|
||||
tplName = config.IndexTemplate
|
||||
}
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
|
@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -11,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *models.Config
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
}
|
||||
|
||||
@ -19,7 +20,7 @@ var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: models.GetConfig(),
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
@ -29,10 +30,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "settings.tpl.html") {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
}
|
||||
|
||||
// TODO: when alerts are present, other data will not be passed to the template
|
||||
if handleAlerts(w, r, conf.SettingsTemplate) {
|
||||
return
|
||||
}
|
||||
templates["settings.tpl.html"].Execute(w, nil)
|
||||
templates[conf.SettingsTemplate].Execute(w, data)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -44,32 +51,34 @@ 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)
|
||||
respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
|
||||
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) {
|
||||
respondAlert(w, "invalid credentials", "", conf.SettingsTemplate, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
respondAlert(w, "invalid parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
|
||||
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 {
|
||||
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@ -77,9 +86,9 @@ 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)
|
||||
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@ -87,13 +96,13 @@ 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)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
@ -103,10 +112,25 @@ 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)
|
||||
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
|
||||
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)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
@ -1,38 +1,27 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalLastDay string = "day"
|
||||
IntervalLastWeek string = "week"
|
||||
IntervalLastMonth string = "month"
|
||||
IntervalLastYear string = "year"
|
||||
IntervalAny string = "any"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -53,60 +42,34 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
|
||||
respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil {
|
||||
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized)
|
||||
respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
|
||||
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 loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
interval := params.Get("interval")
|
||||
from, err := utils.ParseDate(params.Get("from"))
|
||||
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
switch interval {
|
||||
case IntervalToday:
|
||||
from = utils.StartOfDay()
|
||||
case IntervalLastDay:
|
||||
from = utils.StartOfDay().Add(-24 * time.Hour)
|
||||
case IntervalLastWeek:
|
||||
from = utils.StartOfWeek()
|
||||
case IntervalLastMonth:
|
||||
from = utils.StartOfMonth()
|
||||
case IntervalLastYear:
|
||||
from = utils.StartOfYear()
|
||||
case IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
|
||||
}
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
to := utils.StartOfDay()
|
||||
if live {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
var summary *models.Summary
|
||||
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"log"
|
||||
"runtime"
|
||||
"time"
|
||||
@ -11,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
aggregateIntervalDays int = 1 // TODO: Make configurable
|
||||
aggregateIntervalDays int = 1
|
||||
)
|
||||
|
||||
type AggregationService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
UserService *UserService
|
||||
SummaryService *SummaryService
|
||||
@ -24,7 +25,7 @@ type AggregationService struct {
|
||||
|
||||
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
return &AggregationService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
UserService: userService,
|
||||
SummaryService: summaryService,
|
||||
@ -39,7 +40,6 @@ type AggregationJob struct {
|
||||
}
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
// TODO: Make configurable
|
||||
func (srv *AggregationService) Schedule() {
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
@ -50,14 +50,14 @@ func (srv *AggregationService) Schedule() {
|
||||
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)
|
||||
|
||||
gocron.Every(1).Day().At("02:15").Do(srv.trigger, jobs)
|
||||
gocron.Every(1).Day().At(srv.Config.App.AggregationTime).Do(srv.trigger, jobs)
|
||||
<-gocron.Start()
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
|
||||
|
||||
userSummaryTimes := make(map[string]time.Time)
|
||||
for _, s := range latestSummaries {
|
||||
userSummaryTimes[s.UserID] = s.ToTime
|
||||
userSummaryTimes[s.UserID] = s.ToTime.T()
|
||||
}
|
||||
|
||||
missingUserIDs := make([]string, 0)
|
||||
|
@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"sync"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
@ -9,13 +10,13 @@ import (
|
||||
)
|
||||
|
||||
type AliasService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAliasService(db *gorm.DB) *AliasService {
|
||||
return &AliasService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"time"
|
||||
@ -17,13 +18,13 @@ const (
|
||||
)
|
||||
|
||||
type HeartbeatService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
}
|
||||
}
|
||||
@ -45,7 +46,7 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
||||
if err := srv.Db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time <= ?", to).
|
||||
Where("time < ?", to).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
@ -77,7 +78,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfDay().Add(-cleanUpInterval)
|
||||
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
|
||||
|
@ -3,17 +3,18 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type KeyValueService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKeyValueService(db *gorm.DB) *KeyValueService {
|
||||
return &KeyValueService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
@ -13,8 +14,10 @@ import (
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
|
||||
type SummaryService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Cache *cache.Cache
|
||||
Db *gorm.DB
|
||||
HeartbeatService *HeartbeatService
|
||||
@ -23,7 +26,7 @@ type SummaryService struct {
|
||||
|
||||
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
Db: db,
|
||||
HeartbeatService: heartbeatService,
|
||||
@ -36,6 +39,7 @@ type Interval struct {
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// TODO: simplify!
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
@ -65,12 +69,13 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS}
|
||||
types := models.SummaryTypes()
|
||||
|
||||
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
|
||||
@ -92,18 +97,40 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
editorItems = item.Items
|
||||
case models.SummaryOS:
|
||||
osItems = item.Items
|
||||
case models.SummaryMachine:
|
||||
machineItems = item.Items
|
||||
}
|
||||
}
|
||||
close(c)
|
||||
|
||||
realFrom, realTo := from, to
|
||||
if len(existingSummaries) > 0 {
|
||||
realFrom = existingSummaries[0].FromTime.T()
|
||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
|
||||
|
||||
for _, summary := range existingSummaries {
|
||||
summary.FillUnknown()
|
||||
}
|
||||
}
|
||||
if len(heartbeats) > 0 {
|
||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
||||
realFrom = t1
|
||||
}
|
||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
||||
realTo = t2
|
||||
}
|
||||
}
|
||||
|
||||
aggregatedSummary := &models.Summary{
|
||||
UserID: user.ID,
|
||||
FromTime: from,
|
||||
ToTime: to,
|
||||
FromTime: models.CustomTime(realFrom),
|
||||
ToTime: models.CustomTime(realTo),
|
||||
Projects: projectItems,
|
||||
Languages: languageItems,
|
||||
Editors: editorItems,
|
||||
OperatingSystems: osItems,
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
@ -134,10 +161,12 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
|
||||
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
|
||||
}
|
||||
@ -171,10 +200,12 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
key = h.Language
|
||||
case models.SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case models.SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
key = models.UnknownSummaryKey
|
||||
}
|
||||
|
||||
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
||||
@ -189,9 +220,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)
|
||||
@ -218,20 +256,29 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
|
||||
intervals := make([]*Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(existingSummaries[0].FromTime) {
|
||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime})
|
||||
if from.Before(existingSummaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||
if existingSummaries[i].ToTime.Before(existingSummaries[i+1].FromTime) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
|
||||
t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) {
|
||||
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime})
|
||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
@ -251,6 +298,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
Languages: make([]*models.SummaryItem, 0),
|
||||
Editors: make([]*models.SummaryItem, 0),
|
||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||
Machines: make([]*models.SummaryItem, 0),
|
||||
}
|
||||
|
||||
for _, s := range summaries {
|
||||
@ -258,22 +306,23 @@ 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.FromTime = minTime
|
||||
finalSummary.ToTime = maxTime
|
||||
finalSummary.FromTime = models.CustomTime(minTime)
|
||||
finalSummary.ToTime = models.CustomTime(maxTime)
|
||||
|
||||
return finalSummary, nil
|
||||
}
|
||||
|
@ -3,19 +3,20 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
Config *models.Config
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
return &UserService{
|
||||
Config: models.GetConfig(),
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
}
|
||||
}
|
||||
@ -53,8 +54,10 @@ 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})
|
||||
@ -87,10 +90,25 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -1,10 +1,21 @@
|
||||
const SHOW_TOP_N = 10
|
||||
const CHART_TARGET_SIZE = 170
|
||||
const CHART_TARGET_SIZE = 200
|
||||
|
||||
const projectsCanvas = document.getElementById('chart-projects')
|
||||
const osCanvas = document.getElementById('chart-os')
|
||||
const editorsCanvas = document.getElementById('chart-editor')
|
||||
const languagesCanvas = document.getElementById('chart-language')
|
||||
const machinesCanvas = document.getElementById('chart-machine')
|
||||
|
||||
const projectContainer = document.getElementById('project-container')
|
||||
const osContainer = document.getElementById('os-container')
|
||||
const editorContainer = document.getElementById('editor-container')
|
||||
const languageContainer = document.getElementById('language-container')
|
||||
const machineContainer = document.getElementById('machine-container')
|
||||
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
|
||||
let charts = []
|
||||
let resizeCount = 0
|
||||
@ -28,11 +39,6 @@ String.prototype.toHHMMSS = function () {
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
@ -48,102 +54,158 @@ function draw() {
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total)],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden')
|
||||
? new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total) / 60],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
options: {
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Minutes'
|
||||
}
|
||||
}]
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
let osChart = !osCanvas.classList.contains('hidden')
|
||||
? new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden')
|
||||
? new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden')
|
||||
? new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden')
|
||||
? new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('machines', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible'
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart]
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
}
|
||||
|
||||
function setTopLabels() {
|
||||
[...document.getElementsByClassName('top-label')]
|
||||
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
|
||||
}
|
||||
|
||||
function togglePlaceholders(mask) {
|
||||
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
if (!mask[i]) {
|
||||
canvases[i].classList.add('hidden')
|
||||
placeholderElements[i].classList.remove('hidden')
|
||||
} else {
|
||||
canvases[i].classList.remove('hidden')
|
||||
placeholderElements[i].classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
return chart.canvas.parentNode
|
||||
}
|
||||
@ -180,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()
|
||||
}
|
||||
|
||||
@ -227,7 +289,7 @@ if (favicon) {
|
||||
}
|
||||
|
||||
// Click outside
|
||||
window.addEventListener('click', function(event) {
|
||||
window.addEventListener('click', function (event) {
|
||||
if (event.target.classList.contains('popup')) {
|
||||
return
|
||||
}
|
||||
@ -238,5 +300,7 @@ window.addEventListener('click', function(event) {
|
||||
})
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
setTopLabels()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
1
static/assets/images/no_data.svg
Normal file
1
static/assets/images/no_data.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.9 KiB |
@ -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
|
||||
}
|
||||
|
@ -3,10 +3,9 @@ package utils
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/config"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
func ParseDate(date string) (time.Time, error) {
|
||||
@ -22,7 +21,7 @@ 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")
|
||||
@ -30,10 +29,10 @@ func ParseUserAgent(ua string) (string, string, error) {
|
||||
return groups[0][1], groups[0][2], nil
|
||||
}
|
||||
|
||||
func MakeConnectionString(config *models.Config) string {
|
||||
switch config.DbDialect {
|
||||
func MakeConnectionString(config *config.Config) string {
|
||||
switch config.Db.Dialect {
|
||||
case "mysql":
|
||||
return mySqlConnectionString(config)
|
||||
return mysqlConnectionString(config)
|
||||
case "postgres":
|
||||
return postgresConnectionString(config)
|
||||
case "sqlite3":
|
||||
@ -42,29 +41,28 @@ func MakeConnectionString(config *models.Config) string {
|
||||
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",
|
||||
config.DbUser,
|
||||
config.DbPassword,
|
||||
config.DbHost,
|
||||
config.DbPort,
|
||||
config.DbName,
|
||||
location.String(),
|
||||
func mysqlConnectionString(config *config.Config) string {
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.Db.User,
|
||||
config.Db.Password,
|
||||
config.Db.Host,
|
||||
config.Db.Port,
|
||||
config.Db.Name,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *models.Config) string {
|
||||
func postgresConnectionString(config *config.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,
|
||||
config.Db.Host,
|
||||
config.Db.Port,
|
||||
config.Db.User,
|
||||
config.Db.Name,
|
||||
config.Db.Password,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *models.Config) string {
|
||||
return config.DbName
|
||||
func sqliteConnectionString(config *config.Config) string {
|
||||
return config.Db.Name
|
||||
}
|
||||
|
50
utils/common_test.go
Normal file
50
utils/common_test.go
Normal 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)
|
||||
}
|
@ -1,10 +1,16 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartOfDay() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location())
|
||||
func StartOfToday() time.Time {
|
||||
return StartOfDay(time.Now())
|
||||
}
|
||||
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func StartOfWeek() time.Time {
|
||||
@ -15,12 +21,35 @@ func StartOfWeek() time.Time {
|
||||
|
||||
func StartOfMonth() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), 0, 0, 0, 0, 0, ref.Location())
|
||||
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
||||
}
|
||||
|
||||
func StartOfYear() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), time.January, 0, 0, 0, 0, 0, ref.Location())
|
||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||
}
|
||||
|
||||
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
||||
intervals := make([][]time.Time, 0)
|
||||
|
||||
for t1 := from; t1.Before(to); {
|
||||
t2 := StartOfDay(t1).Add(24 * time.Hour)
|
||||
if t2.After(to) {
|
||||
t2 = to
|
||||
}
|
||||
intervals = append(intervals, []time.Time{t1, t2})
|
||||
t1 = t2
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func FmtWakatimeDuration(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
return fmt.Sprintf("%d hrs %d mins", h, m)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/18632496
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,3 +8,12 @@ import (
|
||||
func Capitalize(s string) string {
|
||||
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
|
||||
}
|
||||
|
||||
func FindString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
69
utils/summary.go
Normal file
69
utils/summary.go
Normal file
@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ResolveInterval(interval string) (err error, from, to time.Time) {
|
||||
to = time.Now()
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = StartOfToday()
|
||||
case models.IntervalYesterday:
|
||||
from = StartOfToday().Add(-24 * time.Hour)
|
||||
to = StartOfToday()
|
||||
case models.IntervalThisWeek:
|
||||
from = StartOfWeek()
|
||||
case models.IntervalThisMonth:
|
||||
from = StartOfMonth()
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfYear()
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday().AddDate(0, 0, -7)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday().AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday().AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
err = errors.New("invalid interval")
|
||||
}
|
||||
|
||||
return err, from, to
|
||||
}
|
||||
|
||||
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var err error
|
||||
var from, to time.Time
|
||||
|
||||
if interval := params.Get("interval"); interval != "" {
|
||||
err, from, to = ResolveInterval(interval)
|
||||
} else {
|
||||
from, err = ParseDate(params.Get("from"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'from' parameter")
|
||||
}
|
||||
|
||||
to, err = ParseDate(params.Get("to"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'to' parameter")
|
||||
}
|
||||
}
|
||||
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
|
||||
return &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
Recompute: recompute,
|
||||
}, nil
|
||||
}
|
@ -1 +1 @@
|
||||
1.7.1
|
||||
1.12.5
|
@ -2,11 +2,13 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
|
||||
let wakapiData = {}
|
||||
let languageColors = {{ .LanguageColors | json }}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
@ -1,11 +1,12 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
|
||||
</div>
|
||||
<div></div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
@ -64,9 +65,72 @@
|
||||
</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">
|
||||
Badges
|
||||
</div>
|
||||
|
||||
<form class="mt-6" action="settings/badges" method="post">
|
||||
<div class="text-gray-300 text-sm mb-4">
|
||||
{{ if .User.BadgesEnabled }}
|
||||
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API 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-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
|
||||
Status: public
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
||||
<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" 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
|
||||
</span>
|
||||
</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" 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
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" 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">
|
||||
Status: protected
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="text/javascript">
|
||||
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
|
||||
document.querySelectorAll('.with-url-src').forEach(e => {
|
||||
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
document.querySelectorAll('.with-url-inner').forEach(e => {
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
|
@ -1,11 +1,12 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
|
||||
<div></div>
|
||||
</div>
|
||||
@ -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>
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
@ -36,13 +37,16 @@
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
|
||||
</div>
|
||||
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4">
|
||||
<a href="summary?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
|
||||
<a href="summary?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="m-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="m-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="m-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=any" class="m-1 border-b border-green-700">All Time</a>
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
|
||||
<a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
|
||||
<a href="summary?interval=day" class="mx-2 my-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a>
|
||||
<a href="summary?interval=30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a>
|
||||
<a href="summary?interval=12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a>
|
||||
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
@ -51,31 +55,76 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Projects</span>
|
||||
<span id="project-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<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" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Operating Systems</span>
|
||||
<span id="os-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<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" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Languages</span>
|
||||
<span id="language-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<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" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Editors</span>
|
||||
<span id="editor-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<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" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Machines</span>
|
||||
<span id="machine-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<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" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user