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

Compare commits

..

26 Commits

Author SHA1 Message Date
177cbb12fc chore: make aggregation time configurable (resolve #60) 2020-10-16 16:21:19 +02:00
a4c344aaa1 chore: minor code restyling 2020-10-16 16:11:14 +02:00
c575b2fd5c fix: json serialization error when percentage is nan 2020-10-16 15:16:43 +02:00
67a59561c8 fix: use custom date for summary model to support sqlite deserialization 2020-10-16 14:49:22 +02:00
f7520b2b4a fix: timestamp precision 2020-10-16 12:49:36 +02:00
54a944ec41 fix: critical summary computation bug (faulty intervals)
fix: doubly included heartbeats
fix: cross-day heartbeats are ignored for consistency
2020-10-16 12:00:20 +02:00
44b6efb6ee chore: add random seed to generator script 2020-10-16 11:59:08 +02:00
efd4764728 fix: sample data script 2020-10-11 09:55:01 +02:00
dd50b4076f docs: include sonarcloud badges 2020-10-09 21:47:18 +02:00
21b822de42 chore: minor code enhancements 2020-10-09 21:37:20 +02:00
4d22756b8a fix: stop tracking legacy config file 2020-10-04 12:20:15 +02:00
c54f2743fd fix: add legacy config again for backwards-compatibility 2020-10-04 12:16:42 +02:00
a8d5d69629 chore: update build workflow to exclude certain directories 2020-10-04 12:08:58 +02:00
0111aa7543 Merge branch 'master' into stable 2020-10-04 11:54:02 +02:00
3bafde7ab1 fix: adapt docker image to new config format 2020-10-04 11:52:52 +02:00
b378597594 fix: define flags on init
chore: remove deprecated config files
2020-10-04 11:47:31 +02:00
29619f09ed docs: update docs for new config format 2020-10-04 11:35:23 +02:00
ff3fea0359 feat: introduce legacy config migration 2020-10-04 11:14:44 +02:00
660fefcca9 refactor: migrate to new config (resolve #54) 2020-10-04 10:37:38 +02:00
2ecbb3ea02 fix: user agent strign parsing (fix #53) 2020-09-29 18:58:10 +02:00
f843be8d12 refactor: move config to separate package
chore: load config from main method
2020-09-29 18:55:07 +02:00
062a9c6f57 Merge remote-tracking branch 'origin/master' 2020-09-12 16:58:31 +02:00
1c0e63e125 chore: restrict badge access by user agent 2020-09-12 16:58:22 +02:00
45f372168d docs: readme 2020-09-12 16:50:51 +02:00
0760be86ff Merge branch 'master' into stable 2020-09-12 16:40:50 +02:00
a059c637a7 Merge branch 'master' into stable 2020-08-30 01:43:05 +02:00
47 changed files with 817 additions and 515 deletions

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

@ -8,7 +8,7 @@ RUN cd /src && go build -o wakapi
# Final Stage # Final Stage
# When running the application using `docker run`, you can pass environment variables # When running the application using `docker run`, you can pass environment variables
# to override config values from .env using `-e` syntax. # to override config values using `-e` syntax.
# Available options are: # Available options are:
# WAKAPI_DB_TYPE # WAKAPI_DB_TYPE
# WAKAPI_DB_USER # WAKAPI_DB_USER
@ -22,7 +22,7 @@ RUN cd /src && go build -o wakapi
FROM debian FROM debian
WORKDIR /app WORKDIR /app
ENV ENV prod ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3 ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER '' ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD '' ENV WAKAPI_DB_PASSWORD ''
@ -31,12 +31,11 @@ ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT '' ENV WAKAPI_PASSWORD_SALT ''
COPY --from=build-env /src/wakapi /app/ 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/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/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.ini RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
ADD static /app/static ADD static /app/static
ADD data /app/data ADD data /app/data

View File

@ -5,6 +5,11 @@
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue) ![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=security_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index)](https://sonarcloud.io/dashboard?id=muety_wakapi)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc)](https://sonarcloud.io/dashboard?id=muety_wakapi)
--- ---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics** **A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
@ -13,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! 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 ## 👀 Demo
🔥 **New:** There is hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly. 🔥 **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 ❕ 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:** **On the server side:**
* Go >= 1.13 (with `$GOPATH` properly set) * Go >= 1.13 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3)) * gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
@ -30,26 +35,46 @@ To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartb
**On your local machine:** **On your local machine:**
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE * [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
## Server Setup ## ⌨️ Server Setup
### Run from source ### Run from source
1. Clone the project 1. Clone the project
1. Copy `.env.example` to `.env` and set database credentials 1. Copy `config.default.yml` to `config.yml` and adapt it to your needs
1. Adapt `config.ini` to your needs
1. Build executable: `GO111MODULE=on go build` 1. Build executable: `GO111MODULE=on go build`
1. Run server: `./wakapi` 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. **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 ### Run with Docker
``` ```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi 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. 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) 1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
@ -62,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. You can view your API Key after logging in to the web interface.
## Customization ## 🔵 Customization
### Aliases ### 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. 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,7 +105,7 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
* OS ~ type **3** * OS ~ type **3**
* Machine ~ type **4** * Machine ~ type **4**
## API Endpoints ## 🔧 API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)). The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
* `POST /api/heartbeat` * `POST /api/heartbeat`
@ -90,7 +115,7 @@ The following API endpoints are available. A more detailed Swagger documentation
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) * `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health` * `GET /api/health`
## Prometheus Export ## ⤴️ Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter) [![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
@ -99,15 +124,15 @@ It is a standalone webserver that connects to your Wakapi instance and exposes t
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
## Badges ## 🏷 Badges
We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details.
## 👍 Best Practices
## Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). 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!** **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) GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

26
config.default.yml Normal file
View File

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

View File

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

213
config/config.go Normal file
View 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
View 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
View File

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

2
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/gorilla/schema v1.1.0 github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 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/jinzhu/gorm v1.9.11
github.com/joho/godotenv v1.3.0 github.com/joho/godotenv v1.3.0
github.com/kr/pretty v0.2.0 // indirect github.com/kr/pretty v0.2.0 // indirect
@ -17,4 +18,5 @@ require (
github.com/t-tiger/gorm-bulk-insert v1.3.0 github.com/t-tiger/gorm-bulk-insert v1.3.0
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
gopkg.in/ini.v1 v1.50.0 gopkg.in/ini.v1 v1.50.0
gopkg.in/yaml.v2 v2.2.5
) )

3
go.sum
View File

@ -1,6 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= 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/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/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= 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/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 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0= 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 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw= github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=

51
main.go
View File

@ -2,6 +2,8 @@ package main
import ( import (
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@ -13,7 +15,6 @@ import (
_ "github.com/jinzhu/gorm/dialects/postgres" _ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1" shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1" wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
@ -23,7 +24,7 @@ import (
var ( var (
db *gorm.DB db *gorm.DB
config *models.Config config *conf.Config
) )
var ( var (
@ -38,7 +39,7 @@ var (
// TODO: Refactor entire project to be structured after business domains // TODO: Refactor entire project to be structured after business domains
func main() { func main() {
config = models.GetConfig() config = conf.Load()
// Enable line numbers in logging // Enable line numbers in logging
if config.IsDev() { if config.IsDev() {
@ -46,29 +47,28 @@ func main() {
} }
// Show data loss warning // Show data loss warning
if config.CleanUp { if config.App.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5) promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
} }
// Connect to database // Connect to database
var err error var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config)) db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config))
if config.DbDialect == "sqlite3" { if config.Db.Dialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;") db.DB().Exec("PRAGMA foreign_keys = ON;")
} }
db.LogMode(config.IsDev()) db.LogMode(config.IsDev())
db.DB().SetMaxIdleConns(int(config.DbMaxConn)) db.DB().SetMaxIdleConns(int(config.Db.MaxConn))
db.DB().SetMaxOpenConns(int(config.DbMaxConn)) db.DB().SetMaxOpenConns(int(config.Db.MaxConn))
if err != nil { if err != nil {
log.Println(err) log.Println(err)
log.Fatal("could not connect to database") log.Fatal("could not connect to database")
} }
// TODO: Graceful shutdown
defer db.Close() defer db.Close()
// Migrate database schema // Migrate database schema
runDatabaseMigrations() runDatabaseMigrations()
applyFixtures() runCustomMigrations()
// Services // Services
aliasService = services.NewAliasService(db) aliasService = services.NewAliasService(db)
@ -78,13 +78,10 @@ func main() {
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService) aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(db) keyValueService = services.NewKeyValueService(db)
// Custom migrations and initial data
migrateLanguages()
// Aggregate heartbeats to summaries and persist them // Aggregate heartbeats to summaries and persist them
go aggregationService.Schedule() go aggregationService.Schedule()
if config.CleanUp { if config.App.CleanUp {
go heartbeatService.ScheduleCleanUp() go heartbeatService.ScheduleCleanUp()
} }
@ -158,7 +155,7 @@ func main() {
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
// Listen HTTP // Listen HTTP
portString := config.Addr + ":" + strconv.Itoa(config.Port) portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s := &http.Server{ s := &http.Server{
Handler: router, Handler: router,
Addr: portString, Addr: portString,
@ -170,30 +167,14 @@ func main() {
} }
func runDatabaseMigrations() { func runDatabaseMigrations() {
if err := config.GetMigrationFunc(config.DbDialect)(db); err != nil { if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func applyFixtures() { func runCustomMigrations() {
if err := config.GetFixturesFunc(config.DbDialect)(db); err != nil { common.ApplyFixtures(db)
log.Fatal(err) 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) { func promptAbort(message string, timeoutSec int) {

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"log" "log"
"net/http" "net/http"
@ -17,7 +18,7 @@ import (
) )
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
config *models.Config config *conf.Config
userSrvc *services.UserService userSrvc *services.UserService
cache *cache.Cache cache *cache.Cache
whitelistPaths []string whitelistPaths []string
@ -25,7 +26,7 @@ type AuthenticateMiddleware struct {
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware { func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{ return &AuthenticateMiddleware{
config: models.GetConfig(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour), cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths, whitelistPaths: whitelistPaths,
@ -57,8 +58,8 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
if strings.HasPrefix(r.URL.Path, "/api") { if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} else { } else {
utils.ClearCookie(w, models.AuthCookieKey, !m.config.InsecureCookies) utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
} }
return return
} }
@ -106,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return nil, err 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") 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 // migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool { func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
if utils.IsMd5(user.Password) { 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) log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login) userServiceRef.MigrateMd5Password(user, login)
return true return true
} }
return false return false
} }
return utils.CheckPasswordBcrypt(user, login.Password, salt) return utils.CompareBcrypt(user.Password, login.Password, salt)
} }

View 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)
}
}

View 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)
}
}
}

View File

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

View File

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

View File

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

View File

@ -76,28 +76,38 @@ func (j *CustomTime) UnmarshalJSON(b []byte) error {
return nil return nil
} }
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
func (j *CustomTime) Scan(value interface{}) error { func (j *CustomTime) Scan(value interface{}) error {
var (
t time.Time
err error
)
switch value.(type) { switch value.(type) {
case string: 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 { if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value)) return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
} }
*j = CustomTime(t)
case int64: case int64:
*j = CustomTime(time.Unix(value.(int64), 0)) t = time.Unix(0, value.(int64))
break break
case time.Time: case time.Time:
*j = CustomTime(value.(time.Time)) t = value.(time.Time)
break break
default: default:
return errors.New(fmt.Sprintf("unsupported type: %T", value)) 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 return nil
} }
func (j CustomTime) Value() (driver.Value, error) { 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 { func (j CustomTime) String() string {
@ -105,6 +115,6 @@ func (j CustomTime) String() string {
return t.Format("2006-01-02 15:04:05.000") 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) return time.Time(j)
} }

View File

@ -36,8 +36,8 @@ const UnknownSummaryKey = "unknown"
type Summary struct { type Summary struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"` UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"` FromTime CustomTime `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"` ToTime CustomTime `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"` Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"` Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"` Editors []*SummaryItem `json:"editors"`

View File

@ -2,12 +2,14 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1" v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"regexp" "regexp"
"strings"
) )
const ( const (
@ -18,14 +20,14 @@ const (
type BadgeHandler struct { type BadgeHandler struct {
userSrvc *services.UserService userSrvc *services.UserService
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *config2.Config
} }
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler { func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
return &BadgeHandler{ return &BadgeHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
userSrvc: userService, userSrvc: userService,
config: models.GetConfig(), config: config2.Get(),
} }
} }
@ -33,6 +35,11 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern) intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern) 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"] requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId) user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled { if err != nil || !user.BadgesEnabled {

View File

@ -2,6 +2,7 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -13,13 +14,13 @@ import (
type AllTimeHandler struct { type AllTimeHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *config2.Config
} }
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler { func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
return &AllTimeHandler{ return &AllTimeHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: models.GetConfig(), config: config2.Get(),
} }
} }

View File

@ -3,6 +3,7 @@ package v1
import ( import (
"errors" "errors"
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -14,13 +15,13 @@ import (
type SummariesHandler struct { type SummariesHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *config2.Config
} }
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler { func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
return &SummariesHandler{ return &SummariesHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: models.GetConfig(), config: config2.Get(),
} }
} }

View File

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

View File

@ -3,6 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"github.com/gorilla/schema" "github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -13,7 +14,7 @@ import (
) )
type IndexHandler struct { type IndexHandler struct {
config *models.Config config *conf.Config
userSrvc *services.UserService userSrvc *services.UserService
keyValueSrvc *services.KeyValueService keyValueSrvc *services.KeyValueService
} }
@ -23,7 +24,7 @@ var signupDecoder = schema.NewDecoder()
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler { func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
return &IndexHandler{ return &IndexHandler{
config: models.GetConfig(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
keyValueSrvc: keyValueService, 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 != "" { 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 return
} }
@ -43,7 +44,7 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
return return
} }
templates["index.tpl.html"].Execute(w, nil) templates[conf.IndexTemplate].Execute(w, nil)
} }
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) { 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 text = data.Value
} }
templates["imprint.tpl.html"].Execute(w, &struct { templates[conf.ImprintTemplate].Execute(w, &struct {
HtmlText string HtmlText string
}{HtmlText: text}) }{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 != "" { 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 return
} }
@ -88,12 +89,12 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
} }
// TODO: depending on middleware package here is a hack // 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) respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return return
} }
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login) encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil { if err != nil {
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError) respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
return return
@ -106,11 +107,11 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
Name: models.AuthCookieKey, Name: models.AuthCookieKey,
Value: encoded, Value: encoded,
Path: "/", Path: "/",
Secure: !h.config.InsecureCookies, Secure: !h.config.Security.InsecureCookies,
HttpOnly: true, HttpOnly: true,
} }
http.SetCookie(w, cookie) 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) { 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() loadTemplates()
} }
utils.ClearCookie(w, models.AuthCookieKey, !h.config.InsecureCookies) utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
} }
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) { 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 != "" { 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 return
} }
if handleAlerts(w, r, "signup.tpl.html") { if handleAlerts(w, r, conf.SignupTemplate) {
return return
} }
templates["signup.tpl.html"].Execute(w, nil) templates[conf.SignupTemplate].Execute(w, nil)
} }
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) { 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 != "" { 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 return
} }
var signup models.Signup var signup models.Signup
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return return
} }
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil { 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 return
} }
if !signup.IsValid() { if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest) respondAlert(w, "invalid parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return return
} }
_, created, err := h.userSrvc.CreateOrGet(&signup) _, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil { 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 return
} }
if !created { if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict) respondAlert(w, "user already existing", "", conf.SignupTemplate, http.StatusConflict)
return return
} }
msg := url.QueryEscape("account created successfully") 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)
} }

View File

@ -2,7 +2,7 @@ package routes
import ( import (
"fmt" "fmt"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
@ -25,10 +25,10 @@ func loadTemplates() {
"title": strings.Title, "title": strings.Title,
"capitalize": utils.Capitalize, "capitalize": utils.Capitalize,
"getBasePath": func() string { "getBasePath": func() string {
return models.GetConfig().BasePath return config.Get().Server.BasePath
}, },
"getVersion": func() string { "getVersion": func() string {
return models.GetConfig().Version return config.Get().Version
}, },
"htmlSafe": func(html string) template.HTML { "htmlSafe": func(html string) template.HTML {
return template.HTML(html) return template.HTML(html)
@ -59,7 +59,7 @@ func loadTemplates() {
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) { func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status) w.WriteHeader(status)
if tplName == "" { if tplName == "" {
tplName = "index.tpl.html" tplName = config.IndexTemplate
} }
templates[tplName].Execute(w, struct { templates[tplName].Execute(w, struct {
Error string Error string

View File

@ -3,6 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"github.com/gorilla/schema" "github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -11,7 +12,7 @@ import (
) )
type SettingsHandler struct { type SettingsHandler struct {
config *models.Config config *conf.Config
userSrvc *services.UserService userSrvc *services.UserService
} }
@ -19,7 +20,7 @@ var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler { func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
return &SettingsHandler{ return &SettingsHandler{
config: models.GetConfig(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
} }
} }
@ -35,10 +36,10 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
} }
// TODO: when alerts are present, other data will not be passed to the template // TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, "settings.tpl.html") { if handleAlerts(w, r, conf.SettingsTemplate) {
return return
} }
templates["settings.tpl.html"].Execute(w, data) templates[conf.SettingsTemplate].Execute(w, data)
} }
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -50,32 +51,34 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
return return
} }
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil { 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 return
} }
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) { if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized) respondAlert(w, "invalid credentials", "", conf.SettingsTemplate, http.StatusUnauthorized)
return return
} }
if !credentials.IsValid() { if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest) respondAlert(w, "invalid parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
return return
} }
user.Password = credentials.PasswordNew user.Password = credentials.PasswordNew
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil { if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} else {
user.Password = hash
} }
if _, err := h.userSrvc.Update(user); err != nil { 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 return
} }
@ -83,9 +86,9 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
Username: user.ID, Username: user.ID,
Password: user.Password, 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 { if err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
@ -93,13 +96,13 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
Name: models.AuthCookieKey, Name: models.AuthCookieKey,
Value: encoded, Value: encoded,
Path: "/", Path: "/",
Secure: !h.config.InsecureCookies, Secure: !h.config.Security.InsecureCookies,
HttpOnly: true, HttpOnly: true,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
msg := url.QueryEscape("password was updated successfully") 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) { func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
@ -109,12 +112,12 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil { 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 return
} }
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey)) 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) { func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
@ -125,9 +128,9 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil { if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
} }

View File

@ -1,6 +1,7 @@
package routes package routes
import ( import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -9,13 +10,13 @@ import (
type SummaryHandler struct { type SummaryHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *models.Config config *conf.Config
} }
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler { func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
return &SummaryHandler{ return &SummaryHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: models.GetConfig(), config: conf.Get(),
} }
} }
@ -43,23 +44,23 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status) respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
return return
} }
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if user == nil { if user == nil {
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized) respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
return return
} }
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary), LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary),
ApiKey: user.ApiKey, ApiKey: user.ApiKey,
} }
templates["summary.tpl.html"].Execute(w, vm) templates[conf.SummaryTemplate].Execute(w, vm)
} }
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) { func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {

View File

@ -1,15 +1,17 @@
#!/usr/bin/python3 #!/usr/bin/python3
import argparse
import base64
import random import random
import string import string
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List from typing import List, Union
import requests import requests
from tqdm import tqdm
N_PROJECTS = 5 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'
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'
LANGUAGES = { LANGUAGES = {
'Go': 'go', 'Go': 'go',
'Java': 'java', 'Java': 'java',
@ -36,23 +38,25 @@ class Heartbeat:
self.is_write: bool = is_write self.is_write: bool = is_write
self.branch: str = branch self.branch: str = branch
self.type: str = type 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] = [] data: List[Heartbeat] = []
now: datetime = datetime.today() 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()) languages: List[str] = list(LANGUAGES.keys())
for i in range(n): for _ in range(n):
p: str = random.choice(projects) p: str = random.choice(projects)
l: str = random.choice(languages) l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8)) f: str = randomword(random.randint(2, 8))
delta: timedelta = timedelta( delta: timedelta = timedelta(
hours=random.randint(0, N_PAST_HOURS - 1), hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59), 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( data.append(Heartbeat(
@ -65,29 +69,43 @@ def generate_data(n: int) -> List[Heartbeat]:
return data return data
def post_data_sync(data: List[Heartbeat], url: str): def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
for h in data: 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={ 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) print(r.text)
sys.exit(1) sys.exit(1)
def randomword(length: int) -> str: def randomword(length: int) -> str:
letters = string.ascii_lowercase 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__': if __name__ == '__main__':
n: int = 10 args = parse_arguments()
url: str = 'http://admin:admin@localhost:3000/api/heartbeat'
if len(sys.argv) > 1: random.seed(args.seed)
n = int(sys.argv[1])
if len(sys.argv) > 2:
url = sys.argv[2]
data: List[Heartbeat] = generate_data(n) data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
post_data_sync(data, url) post_data_sync(data, args.url, args.apikey)

View File

@ -1,6 +1,7 @@
package services package services
import ( import (
"github.com/muety/wakapi/config"
"log" "log"
"runtime" "runtime"
"time" "time"
@ -11,11 +12,11 @@ import (
) )
const ( const (
aggregateIntervalDays int = 1 // TODO: Make configurable aggregateIntervalDays int = 1
) )
type AggregationService struct { type AggregationService struct {
Config *models.Config Config *config.Config
Db *gorm.DB Db *gorm.DB
UserService *UserService UserService *UserService
SummaryService *SummaryService SummaryService *SummaryService
@ -24,7 +25,7 @@ type AggregationService struct {
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService { func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
return &AggregationService{ return &AggregationService{
Config: models.GetConfig(), Config: config.Get(),
Db: db, Db: db,
UserService: userService, UserService: userService,
SummaryService: summaryService, SummaryService: summaryService,
@ -39,7 +40,6 @@ type AggregationJob struct {
} }
// Schedule a job to (re-)generate summaries every day shortly after midnight // Schedule a job to (re-)generate summaries every day shortly after midnight
// TODO: Make configurable
func (srv *AggregationService) Schedule() { func (srv *AggregationService) Schedule() {
jobs := make(chan *AggregationJob) jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary) summaries := make(chan *models.Summary)
@ -50,14 +50,14 @@ func (srv *AggregationService) Schedule() {
go srv.summaryWorker(jobs, summaries) 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) go srv.persistWorker(summaries)
} }
// Run once initially // Run once initially
srv.trigger(jobs) 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() <-gocron.Start()
} }
@ -97,7 +97,7 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
userSummaryTimes := make(map[string]time.Time) userSummaryTimes := make(map[string]time.Time)
for _, s := range latestSummaries { for _, s := range latestSummaries {
userSummaryTimes[s.UserID] = s.ToTime userSummaryTimes[s.UserID] = s.ToTime.T()
} }
missingUserIDs := make([]string, 0) missingUserIDs := make([]string, 0)

View File

@ -2,6 +2,7 @@ package services
import ( import (
"errors" "errors"
"github.com/muety/wakapi/config"
"sync" "sync"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
@ -9,13 +10,13 @@ import (
) )
type AliasService struct { type AliasService struct {
Config *models.Config Config *config.Config
Db *gorm.DB Db *gorm.DB
} }
func NewAliasService(db *gorm.DB) *AliasService { func NewAliasService(db *gorm.DB) *AliasService {
return &AliasService{ return &AliasService{
Config: models.GetConfig(), Config: config.Get(),
Db: db, Db: db,
} }
} }

View File

@ -2,6 +2,7 @@ package services
import ( import (
"github.com/jasonlvhit/gocron" "github.com/jasonlvhit/gocron"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"log" "log"
"time" "time"
@ -17,13 +18,13 @@ const (
) )
type HeartbeatService struct { type HeartbeatService struct {
Config *models.Config Config *config.Config
Db *gorm.DB Db *gorm.DB
} }
func NewHeartbeatService(db *gorm.DB) *HeartbeatService { func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
return &HeartbeatService{ return &HeartbeatService{
Config: models.GetConfig(), Config: config.Get(),
Db: db, Db: db,
} }
} }
@ -45,7 +46,7 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
if err := srv.Db. if err := srv.Db.
Where(&models.Heartbeat{UserID: user.ID}). Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from). Where("time >= ?", from).
Where("time <= ?", to). Where("time < ?", to).
Order("time asc"). Order("time asc").
Find(&heartbeats).Error; err != nil { Find(&heartbeats).Error; err != nil {
return nil, err return nil, err

View File

@ -3,17 +3,18 @@ package services
import ( import (
"errors" "errors"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
type KeyValueService struct { type KeyValueService struct {
Config *models.Config Config *config.Config
Db *gorm.DB Db *gorm.DB
} }
func NewKeyValueService(db *gorm.DB) *KeyValueService { func NewKeyValueService(db *gorm.DB) *KeyValueService {
return &KeyValueService{ return &KeyValueService{
Config: models.GetConfig(), Config: config.Get(),
Db: db, Db: db,
} }
} }

View File

@ -3,6 +3,7 @@ package services
import ( import (
"crypto/md5" "crypto/md5"
"errors" "errors"
"github.com/muety/wakapi/config"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"math" "math"
"sort" "sort"
@ -13,8 +14,10 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct { type SummaryService struct {
Config *models.Config Config *config.Config
Cache *cache.Cache Cache *cache.Cache
Db *gorm.DB Db *gorm.DB
HeartbeatService *HeartbeatService HeartbeatService *HeartbeatService
@ -23,7 +26,7 @@ type SummaryService struct {
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService { func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
return &SummaryService{ return &SummaryService{
Config: models.GetConfig(), Config: config.Get(),
Cache: cache.New(24*time.Hour, 24*time.Hour), Cache: cache.New(24*time.Hour, 24*time.Hour),
Db: db, Db: db,
HeartbeatService: heartbeatService, HeartbeatService: heartbeatService,
@ -36,6 +39,7 @@ type Interval struct {
End time.Time End time.Time
} }
// TODO: simplify!
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) { func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary var existingSummaries []*models.Summary
var cacheKey string var cacheKey string
@ -101,8 +105,8 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
realFrom, realTo := from, to realFrom, realTo := from, to
if len(existingSummaries) > 0 { if len(existingSummaries) > 0 {
realFrom = existingSummaries[0].FromTime realFrom = existingSummaries[0].FromTime.T()
realTo = existingSummaries[len(existingSummaries)-1].ToTime realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
for _, summary := range existingSummaries { for _, summary := range existingSummaries {
summary.FillUnknown() summary.FillUnknown()
@ -120,8 +124,8 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
aggregatedSummary := &models.Summary{ aggregatedSummary := &models.Summary{
UserID: user.ID, UserID: user.ID,
FromTime: realFrom, FromTime: models.CustomTime(realFrom),
ToTime: realTo, ToTime: models.CustomTime(realTo),
Projects: projectItems, Projects: projectItems,
Languages: languageItems, Languages: languageItems,
Editors: editorItems, Editors: editorItems,
@ -216,9 +220,16 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
continue continue
} }
timePassed := h.Time.Time().Sub(heartbeats[i-1].Time.Time()) t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
timeThresholded := math.Min(float64(timePassed), float64(time.Duration(2)*time.Minute)) // 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.
durations[key] += time.Duration(int64(timeThresholded)) // 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) items := make([]*models.SummaryItem, 0)
@ -245,13 +256,13 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
intervals := make([]*Interval, 0) intervals := make([]*Interval, 0)
// Pre // Pre
if from.Before(existingSummaries[0].FromTime) { if from.Before(existingSummaries[0].FromTime.T()) {
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime}) intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
} }
// Between // Between
for i := 0; i < len(existingSummaries)-1; i++ { for i := 0; i < len(existingSummaries)-1; i++ {
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
if t1.Equal(t2) { if t1.Equal(t2) {
continue continue
} }
@ -261,13 +272,13 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location()) td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between? // one or more day missing in between?
if td1.Before(td2) { if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime}) intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
} }
} }
// Post // Post
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) { if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime}) intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
} }
return intervals return intervals
@ -295,12 +306,12 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
return nil, errors.New("users don't match") return nil, errors.New("users don't match")
} }
if s.FromTime.Before(minTime) { if s.FromTime.T().Before(minTime) {
minTime = s.FromTime minTime = s.FromTime.T()
} }
if s.ToTime.After(maxTime) { if s.ToTime.T().After(maxTime) {
maxTime = s.ToTime maxTime = s.ToTime.T()
} }
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects) finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
@ -310,8 +321,8 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines) finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
} }
finalSummary.FromTime = minTime finalSummary.FromTime = models.CustomTime(minTime)
finalSummary.ToTime = maxTime finalSummary.ToTime = models.CustomTime(maxTime)
return finalSummary, nil return finalSummary, nil
} }

View File

@ -3,19 +3,20 @@ package services
import ( import (
"errors" "errors"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
uuid "github.com/satori/go.uuid" uuid "github.com/satori/go.uuid"
) )
type UserService struct { type UserService struct {
Config *models.Config Config *config.Config
Db *gorm.DB Db *gorm.DB
} }
func NewUserService(db *gorm.DB) *UserService { func NewUserService(db *gorm.DB) *UserService {
return &UserService{ return &UserService{
Config: models.GetConfig(), Config: config.Get(),
Db: db, Db: db,
} }
} }
@ -53,8 +54,10 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
Password: signup.Password, 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 return nil, false, err
} else {
u.Password = hash
} }
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID}) result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
@ -102,8 +105,10 @@ func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password 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 return nil, err
} else {
user.Password = hash
} }
result := srv.Db.Model(user).Update("password", user.Password) result := srv.Db.Model(user).Update("password", user.Password)

View File

@ -242,8 +242,8 @@ function equalizeHeights() {
}) })
} }
function getTotal(data) { function getTotal(items) {
let total = data.reduce((acc, d) => acc + d.total, 0) let total = items.reduce((acc, d) => acc + d.total, 0)
document.getElementById('total-span').innerText = total.toString().toHHMMSS() document.getElementById('total-span').innerText = total.toString().toHHMMSS()
} }

View File

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

View File

@ -3,10 +3,9 @@ package utils
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/muety/wakapi/config"
"regexp" "regexp"
"time" "time"
"github.com/muety/wakapi/models"
) )
func ParseDate(date string) (time.Time, error) { func ParseDate(date string) (time.Time, error) {
@ -22,7 +21,7 @@ func FormatDateHuman(date time.Time) string {
} }
func ParseUserAgent(ua string) (string, string, error) { 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) groups := re.FindAllStringSubmatch(ua, -1)
if len(groups) == 0 || len(groups[0]) != 3 { if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string") 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 return groups[0][1], groups[0][2], nil
} }
func MakeConnectionString(config *models.Config) string { func MakeConnectionString(config *config.Config) string {
switch config.DbDialect { switch config.Db.Dialect {
case "mysql": case "mysql":
return mySqlConnectionString(config) return mysqlConnectionString(config)
case "postgres": case "postgres":
return postgresConnectionString(config) return postgresConnectionString(config)
case "sqlite3": case "sqlite3":
@ -42,28 +41,28 @@ func MakeConnectionString(config *models.Config) string {
return "" return ""
} }
func mySqlConnectionString(config *models.Config) string { func mysqlConnectionString(config *config.Config) string {
//location, _ := time.LoadLocation("Local") //location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES", return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.DbUser, config.Db.User,
config.DbPassword, config.Db.Password,
config.DbHost, config.Db.Host,
config.DbPort, config.Db.Port,
config.DbName, config.Db.Name,
"Local", "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", return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
config.DbHost, config.Db.Host,
config.DbPort, config.Db.Port,
config.DbUser, config.Db.User,
config.DbName, config.Db.Name,
config.DbPassword, config.Db.Password,
) )
} }
func sqliteConnectionString(config *models.Config) string { func sqliteConnectionString(config *config.Config) string {
return config.DbName return config.Db.Name
} }

50
utils/common_test.go Normal file
View File

@ -0,0 +1,50 @@
package utils
import (
"errors"
"testing"
)
func TestParseUserAgent(t *testing.T) {
tests := []struct {
in string
outOs string
outEditor string
outError error
}{
{
"wakatime/13.0.7 (Linux-4.15.0-96-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 GoLand/2019.3.4 GoLand-wakatime/11.0.1",
"Linux",
"GoLand",
nil,
},
{
"wakatime/13.0.4 (Linux-5.4.64-x86_64-with-glibc2.2.5) Python3.7.6.final.0 emacs-wakatime/1.0.2",
"Linux",
"emacs",
nil,
},
{
"",
"",
"",
errors.New(""),
},
{
"wakatime/13.0.7 Python3.8.0.final.0 GoLand/2019.3.4 GoLand-wakatime/11.0.1",
"",
"",
errors.New(""),
},
}
for i, test := range tests {
if os, editor, err := ParseUserAgent(test.in); os != test.outOs || editor != test.outEditor || !checkErr(test.outError, err) {
t.Errorf("[%d] Unexpected result of parsing '%s'; got '%v', '%v', '%v'", i, test.in, os, editor, err)
}
}
}
func checkErr(expected, actual error) bool {
return (expected == nil && actual == nil) || (expected != nil && actual != nil)
}

View File

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

View File

@ -1 +1 @@
1.11.1 1.12.5

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
@ -86,7 +87,7 @@
<div class="flex flex-col mb-4"> <div class="flex flex-col mb-4">
<div class="flex justify-between my-2"> <div class="flex justify-between my-2">
<div> <div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/> <img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" alt="Shields.io badge"/>
</div> </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;"> <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 https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
@ -94,7 +95,7 @@
</div> </div>
<div class="flex justify-between my-2"> <div class="flex justify-between my-2">
<div> <div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/> <img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d" alt="Shields.io badge"/>
</div> </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;"> <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 https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
@ -104,7 +105,7 @@
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p> <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 }} {{ else }}
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p> <p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800" rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<div class="flex justify-around mt-4"> <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> <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"> <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">

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
@ -19,9 +20,11 @@
<p class="text-sm text-gray-300"> <p class="text-sm text-gray-300">
💡 In order to use Wakapi, you need to create an account. 💡 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" 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> class="border-b border-green-700">WakaTime</a>
client tools. client tools.
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank" 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. 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. You will be able to view you <strong>API Key</strong> once you log in.
</p> </p>

View File

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