diff --git a/.env.example b/.env.example deleted file mode 100644 index 66d27c2..0000000 --- a/.env.example +++ /dev/null @@ -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 ! \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1412408..ddbf691 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ wakapi .idea build *.exe -*.db \ No newline at end of file +*.db +config.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b550801..91e8863 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN cd /src && go build -o wakapi # Final Stage # When running the application using `docker run`, you can pass environment variables -# to override config values from .env using `-e` syntax. +# to override config values using `-e` syntax. # Available options are: # – WAKAPI_DB_TYPE # – WAKAPI_DB_USER @@ -22,7 +22,7 @@ RUN cd /src && go build -o wakapi FROM debian WORKDIR /app -ENV ENV prod +ENV ENVIRONMENT prod ENV WAKAPI_DB_TYPE sqlite3 ENV WAKAPI_DB_USER '' ENV WAKAPI_DB_PASSWORD '' @@ -31,12 +31,11 @@ ENV WAKAPI_DB_NAME=/data/wakapi.db ENV WAKAPI_PASSWORD_SALT '' COPY --from=build-env /src/wakapi /app/ -COPY --from=build-env /src/config.ini /app/ +COPY --from=build-env /src/config.default.yml /app/config.yml COPY --from=build-env /src/version.txt /app/ -COPY --from=build-env /src/.env.example /app/.env -RUN sed -i 's/listen = 127.0.0.1/listen = 0.0.0.0/g' /app/config.ini -RUN sed -i 's/insecure_cookies = false/insecure_cookies = true/g' /app/config.ini +RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml +RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml ADD static /app/static ADD data /app/data diff --git a/README.md b/README.md index 5d4128f..fb4d439 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ If you like this project, please consider supporting it πŸ™‚. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi! -## Demo -πŸ”₯ **New:** There is hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly. +## πŸ‘€ Demo +πŸ”₯ **New:** There is a hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly. To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕ -## Prerequisites +## βš™οΈ Prerequisites **On the server side:** * Go >= 1.13 (with `$GOPATH` properly set) * gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3)) @@ -30,26 +30,46 @@ To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartb **On your local machine:** * [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE -## Server Setup +## ⌨️ Server Setup ### Run from source 1. Clone the project -1. Copy `.env.example` to `.env` and set database credentials -1. Adapt `config.ini` to your needs +1. Copy `config.default.yml` to `config.yml` and adapt it to your needs 1. Build executable: `GO111MODULE=on go build` 1. Run server: `./wakapi` **As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously. -**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` in `.env` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](i#best-practices)) or set `insecure_cookies = true` in `config.ini`. +**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`. ### Run with Docker ``` docker run -d -p 3000:3000 --name wakapi n1try/wakapi ``` -By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [.env.example](https://github.com/muety/wakapi/blob/master/.env.example) for further options. +By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options. -## Client Setup +## πŸ”§ Configuration +You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options. + +| YAML Key | Environment Variable | Default | Description | +|---------------------------|---------------------------|--------------|---------------------------------------------------------------------| +| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings | +| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) | +| `app.custom_languages` | - | - | Map from file endings to language names | +| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on | +| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on | +| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | +| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing | +| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP | +| `db.host` | `WAKAPI_DB_HOST` | - | Database host | +| `db.port` | `WAKAPI_DB_PORT` | - | Database port | +| `db.user` | `WAKAPI_DB_USER` | - | Database user | +| `db.password` | `WAKAPI_DB_PASSWORD` | - | Database password | +| `db.name` | `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name | +| `db.dialect` | `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of sqlite3, mysql, postgres) | +| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | + +## πŸ’» Client Setup Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up. 1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins) @@ -62,7 +82,7 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server` You can view your API Key after logging in to the web interface. -## Customization +## πŸ”΅ Customization ### Aliases There is an option to add aliases for project names, editors, operating systems and languages. For instance, if you want to map two projects – `myapp-frontend` and `myapp-backend` – two a common project name – `myapp-web` – in your statistics, you can add project aliases. @@ -80,7 +100,7 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam * OS ~ type **3** * Machine ~ type **4** -## API Endpoints +## πŸ”§ API Endpoints The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)). * `POST /api/heartbeat` @@ -90,7 +110,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/health` -## Prometheus Export +## ‴️ Prometheus Export If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. [![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter) @@ -99,15 +119,15 @@ It is a standalone webserver that connects to your Wakapi instance and exposes t Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. -## Badges +## 🏷 Badges +We recently introduced support for [Shields.io](https://shields.io) badges (see above). Visit your Wakapi server's settings page to see details. - -## Best Practices +## πŸ‘ Best Practices It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). -However, if you want to expose your wakapi instance to the public anyway, you need to set `listen = 0.0.0.0` in `config.ini` +However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml` -## Important Note +## ⚠️ Important Note **This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!** -## License +## πŸ““ License GPL-v3 @ [Ferdinand MΓΌtsch](https://muetsch.io) diff --git a/config.default.yml b/config.default.yml new file mode 100644 index 0000000..2b4d2e5 --- /dev/null +++ b/config.default.yml @@ -0,0 +1,25 @@ +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 + 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 diff --git a/config.ini b/config.ini deleted file mode 100644 index 187169c..0000000 --- a/config.ini +++ /dev/null @@ -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 \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..44edf4f --- /dev/null +++ b/config/config.go @@ -0,0 +1,212 @@ +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"` + 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() +} diff --git a/config/legacy.go b/config/legacy.go new file mode 100644 index 0000000..80ad686 --- /dev/null +++ b/config/legacy.go @@ -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 +} diff --git a/go.mod b/go.mod index 5a5c4df..3f824a8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/schema v1.1.0 github.com/gorilla/securecookie v1.1.1 github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 + github.com/jinzhu/configor v1.2.0 github.com/jinzhu/gorm v1.9.11 github.com/joho/godotenv v1.3.0 github.com/kr/pretty v0.2.0 // indirect @@ -17,4 +18,5 @@ require ( github.com/t-tiger/gorm-bulk-insert v1.3.0 golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c gopkg.in/ini.v1 v1.50.0 + gopkg.in/yaml.v2 v2.2.5 ) diff --git a/go.sum b/go.sum index 2d244d4..4c60bb1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= @@ -154,6 +155,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg= github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0= +github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA= +github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE= github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= diff --git a/main.go b/main.go index 8c7c4de..7ba9e4e 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/gorilla/handlers" + conf "github.com/muety/wakapi/config" "log" "net/http" "strconv" @@ -23,7 +24,7 @@ import ( var ( db *gorm.DB - config *models.Config + config *conf.Config ) var ( @@ -38,7 +39,7 @@ var ( // TODO: Refactor entire project to be structured after business domains func main() { - config = models.GetConfig() + config = conf.Load() // Enable line numbers in logging if config.IsDev() { @@ -46,19 +47,19 @@ func main() { } // 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) } // Connect to database var err error - db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config)) - if config.DbDialect == "sqlite3" { + db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config)) + if config.Db.Dialect == "sqlite3" { db.DB().Exec("PRAGMA foreign_keys = ON;") } db.LogMode(config.IsDev()) - db.DB().SetMaxIdleConns(int(config.DbMaxConn)) - db.DB().SetMaxOpenConns(int(config.DbMaxConn)) + db.DB().SetMaxIdleConns(int(config.Db.MaxConn)) + db.DB().SetMaxOpenConns(int(config.Db.MaxConn)) if err != nil { log.Println(err) log.Fatal("could not connect to database") @@ -84,7 +85,7 @@ func main() { // Aggregate heartbeats to summaries and persist them go aggregationService.Schedule() - if config.CleanUp { + if config.App.CleanUp { go heartbeatService.ScheduleCleanUp() } @@ -158,7 +159,7 @@ func main() { router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) // Listen HTTP - portString := config.Addr + ":" + strconv.Itoa(config.Port) + portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port) s := &http.Server{ Handler: router, Addr: portString, @@ -170,19 +171,19 @@ func main() { } func runDatabaseMigrations() { - if err := config.GetMigrationFunc(config.DbDialect)(db); err != nil { + if err := config.GetMigrationFunc(config.Db.Dialect)(db); err != nil { log.Fatal(err) } } func applyFixtures() { - if err := config.GetFixturesFunc(config.DbDialect)(db); err != nil { + if err := config.GetFixturesFunc(config.Db.Dialect)(db); err != nil { log.Fatal(err) } } func migrateLanguages() { - for k, v := range config.CustomLanguages { + for k, v := range config.App.CustomLanguages { result := db.Model(models.Heartbeat{}). Where("language = ?", ""). Where("entity LIKE ?", "%."+k). diff --git a/middlewares/authenticate.go b/middlewares/authenticate.go index 03646d7..d0677c0 100644 --- a/middlewares/authenticate.go +++ b/middlewares/authenticate.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/utils" "log" "net/http" @@ -17,7 +18,7 @@ import ( ) type AuthenticateMiddleware struct { - config *models.Config + config *config2.Config userSrvc *services.UserService cache *cache.Cache whitelistPaths []string @@ -25,7 +26,7 @@ type AuthenticateMiddleware struct { func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware { return &AuthenticateMiddleware{ - config: models.GetConfig(), + config: config2.Get(), userSrvc: userService, cache: cache.New(1*time.Hour, 2*time.Hour), whitelistPaths: whitelistPaths, @@ -57,8 +58,8 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques if strings.HasPrefix(r.URL.Path, "/api") { w.WriteHeader(http.StatusUnauthorized) } else { - utils.ClearCookie(w, models.AuthCookieKey, !m.config.InsecureCookies) - http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound) + utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies) + http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound) } return } @@ -106,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us return nil, err } - if !CheckAndMigratePassword(user, login, m.config.PasswordSalt, m.userSrvc) { + if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) { return nil, errors.New("invalid password") } diff --git a/models/config.go b/models/config.go deleted file mode 100644 index b75d7d2..0000000 --- a/models/config.go +++ /dev/null @@ -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, - } -} diff --git a/models/models.go b/models/models.go index 97c58f4..af32826 100644 --- a/models/models.go +++ b/models/models.go @@ -1,5 +1,4 @@ package models func init() { - SetConfig(readConfig()) } diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index 99499f7..6d003ae 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -2,12 +2,14 @@ package v1 import ( "github.com/gorilla/mux" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" v1 "github.com/muety/wakapi/models/compat/shields/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" "regexp" + "strings" ) const ( @@ -18,14 +20,14 @@ const ( type BadgeHandler struct { userSrvc *services.UserService summarySrvc *services.SummaryService - config *models.Config + config *config2.Config } func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler { return &BadgeHandler{ summarySrvc: summaryService, userSrvc: userService, - config: models.GetConfig(), + config: config2.Get(), } } @@ -33,6 +35,11 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) { intervalReg := regexp.MustCompile(intervalPattern) entityFilterReg := regexp.MustCompile(entityFilterPattern) + if userAgent := r.Header.Get("user-agent"); !strings.HasPrefix(userAgent, "Shields.io/") && !h.config.IsDev() { + w.WriteHeader(http.StatusForbidden) + return + } + requestedUserId := mux.Vars(r)["user"] user, err := h.userSrvc.GetUserById(requestedUserId) if err != nil || !user.BadgesEnabled { diff --git a/routes/compat/wakatime/v1/all_time.go b/routes/compat/wakatime/v1/all_time.go index faac3dd..51577ce 100644 --- a/routes/compat/wakatime/v1/all_time.go +++ b/routes/compat/wakatime/v1/all_time.go @@ -2,6 +2,7 @@ package v1 import ( "github.com/gorilla/mux" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" @@ -13,13 +14,13 @@ import ( type AllTimeHandler struct { summarySrvc *services.SummaryService - config *models.Config + config *config2.Config } func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler { return &AllTimeHandler{ summarySrvc: summaryService, - config: models.GetConfig(), + config: config2.Get(), } } diff --git a/routes/compat/wakatime/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go index 2223d95..fa3e40b 100644 --- a/routes/compat/wakatime/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -3,6 +3,7 @@ package v1 import ( "errors" "github.com/gorilla/mux" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" @@ -14,13 +15,13 @@ import ( type SummariesHandler struct { summarySrvc *services.SummaryService - config *models.Config + config *config2.Config } func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler { return &SummariesHandler{ summarySrvc: summaryService, - config: models.GetConfig(), + config: config2.Get(), } } diff --git a/routes/heartbeat.go b/routes/heartbeat.go index dfa96ec..5a08cb0 100644 --- a/routes/heartbeat.go +++ b/routes/heartbeat.go @@ -2,6 +2,7 @@ package routes import ( "encoding/json" + config2 "github.com/muety/wakapi/config" "net/http" "os" @@ -12,13 +13,13 @@ import ( ) type HeartbeatHandler struct { - config *models.Config + config *config2.Config heartbeatSrvc *services.HeartbeatService } func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler { return &HeartbeatHandler{ - config: models.GetConfig(), + config: config2.Get(), heartbeatSrvc: heartbeatService, } } @@ -46,7 +47,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) { hb.Machine = machineName hb.User = user hb.UserID = user.ID - hb.Augment(h.config.CustomLanguages) + hb.Augment(h.config.App.CustomLanguages) if !hb.Valid() { w.WriteHeader(http.StatusBadRequest) diff --git a/routes/public.go b/routes/public.go index 25b3596..5ad3900 100644 --- a/routes/public.go +++ b/routes/public.go @@ -3,6 +3,7 @@ package routes import ( "fmt" "github.com/gorilla/schema" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" @@ -13,7 +14,7 @@ import ( ) type IndexHandler struct { - config *models.Config + config *config2.Config userSrvc *services.UserService keyValueSrvc *services.KeyValueService } @@ -23,7 +24,7 @@ var signupDecoder = schema.NewDecoder() func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler { return &IndexHandler{ - config: models.GetConfig(), + config: config2.Get(), userSrvc: userService, keyValueSrvc: keyValueService, } @@ -35,7 +36,7 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) { } if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { - http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound) return } @@ -67,7 +68,7 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) { } if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { - http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound) return } @@ -88,12 +89,12 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) { } // TODO: depending on middleware package here is a hack - if !middlewares.CheckAndMigratePassword(user, &login, h.config.PasswordSalt, h.userSrvc) { + if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) { respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized) return } - encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login) + encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login) if err != nil { respondAlert(w, "internal server error", "", "", http.StatusInternalServerError) return @@ -106,11 +107,11 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) { Name: models.AuthCookieKey, Value: encoded, Path: "/", - Secure: !h.config.InsecureCookies, + Secure: !h.config.Security.InsecureCookies, HttpOnly: true, } http.SetCookie(w, cookie) - http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound) } func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) { @@ -118,8 +119,8 @@ func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) { loadTemplates() } - utils.ClearCookie(w, models.AuthCookieKey, !h.config.InsecureCookies) - http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound) + utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies) + http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound) } func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) { @@ -128,7 +129,7 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) { } if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { - http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound) return } @@ -145,7 +146,7 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) { } if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { - http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound) return } @@ -175,5 +176,5 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) { } 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) } diff --git a/routes/routes.go b/routes/routes.go index b379c84..6005caa 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -2,7 +2,7 @@ package routes import ( "fmt" - "github.com/muety/wakapi/models" + "github.com/muety/wakapi/config" "github.com/muety/wakapi/utils" "html/template" "io/ioutil" @@ -25,10 +25,10 @@ func loadTemplates() { "title": strings.Title, "capitalize": utils.Capitalize, "getBasePath": func() string { - return models.GetConfig().BasePath + return config.Get().Server.BasePath }, "getVersion": func() string { - return models.GetConfig().Version + return config.Get().Version }, "htmlSafe": func(html string) template.HTML { return template.HTML(html) diff --git a/routes/settings.go b/routes/settings.go index 8dd0ffc..89bdf1a 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -3,6 +3,7 @@ package routes import ( "fmt" "github.com/gorilla/schema" + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" @@ -11,7 +12,7 @@ import ( ) type SettingsHandler struct { - config *models.Config + config *config2.Config userSrvc *services.UserService } @@ -19,7 +20,7 @@ var credentialsDecoder = schema.NewDecoder() func NewSettingsHandler(userService *services.UserService) *SettingsHandler { return &SettingsHandler{ - config: models.GetConfig(), + config: config2.Get(), userSrvc: userService, } } @@ -58,7 +59,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request return } - if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) { + if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.Security.PasswordSalt) { respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized) return } @@ -69,7 +70,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request } user.Password = credentials.PasswordNew - if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil { + if err := utils.HashPassword(user, h.config.Security.PasswordSalt); err != nil { respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) return } @@ -83,7 +84,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request Username: user.ID, Password: user.Password, } - encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login) + encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login) if err != nil { respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) return @@ -93,13 +94,13 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request Name: models.AuthCookieKey, Value: encoded, Path: "/", - Secure: !h.config.InsecureCookies, + Secure: !h.config.Security.InsecureCookies, HttpOnly: true, } http.SetCookie(w, cookie) msg := url.QueryEscape("password was updated successfully") - http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound) } func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) { @@ -114,7 +115,7 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request } 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) { @@ -129,5 +130,5 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques 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) } diff --git a/routes/summary.go b/routes/summary.go index 0a3137f..4dd57a3 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -1,6 +1,7 @@ package routes import ( + config2 "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" @@ -9,13 +10,13 @@ import ( type SummaryHandler struct { summarySrvc *services.SummaryService - config *models.Config + config *config2.Config } func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler { return &SummaryHandler{ summarySrvc: summaryService, - config: models.GetConfig(), + config: config2.Get(), } } @@ -55,7 +56,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) { vm := models.SummaryViewModel{ Summary: summary, - LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary), + LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary), ApiKey: user.ApiKey, } diff --git a/services/aggregation.go b/services/aggregation.go index 181ed55..406afa9 100644 --- a/services/aggregation.go +++ b/services/aggregation.go @@ -1,6 +1,7 @@ package services import ( + "github.com/muety/wakapi/config" "log" "runtime" "time" @@ -15,7 +16,7 @@ const ( ) type AggregationService struct { - Config *models.Config + Config *config.Config Db *gorm.DB UserService *UserService SummaryService *SummaryService @@ -24,7 +25,7 @@ type AggregationService struct { func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService { return &AggregationService{ - Config: models.GetConfig(), + Config: config.Get(), Db: db, UserService: userService, SummaryService: summaryService, @@ -50,7 +51,7 @@ func (srv *AggregationService) Schedule() { go srv.summaryWorker(jobs, summaries) } - for i := 0; i < int(srv.Config.DbMaxConn); i++ { + for i := 0; i < int(srv.Config.Db.MaxConn); i++ { go srv.persistWorker(summaries) } diff --git a/services/alias.go b/services/alias.go index 082959f..1090c1d 100644 --- a/services/alias.go +++ b/services/alias.go @@ -2,6 +2,7 @@ package services import ( "errors" + "github.com/muety/wakapi/config" "sync" "github.com/jinzhu/gorm" @@ -9,13 +10,13 @@ import ( ) type AliasService struct { - Config *models.Config + Config *config.Config Db *gorm.DB } func NewAliasService(db *gorm.DB) *AliasService { return &AliasService{ - Config: models.GetConfig(), + Config: config.Get(), Db: db, } } diff --git a/services/heartbeat.go b/services/heartbeat.go index d73cd6b..62c156f 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -2,6 +2,7 @@ package services import ( "github.com/jasonlvhit/gocron" + "github.com/muety/wakapi/config" "github.com/muety/wakapi/utils" "log" "time" @@ -17,13 +18,13 @@ const ( ) type HeartbeatService struct { - Config *models.Config + Config *config.Config Db *gorm.DB } func NewHeartbeatService(db *gorm.DB) *HeartbeatService { return &HeartbeatService{ - Config: models.GetConfig(), + Config: config.Get(), Db: db, } } diff --git a/services/key_value.go b/services/key_value.go index d16ecc8..c712852 100644 --- a/services/key_value.go +++ b/services/key_value.go @@ -3,17 +3,18 @@ package services import ( "errors" "github.com/jinzhu/gorm" + "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" ) type KeyValueService struct { - Config *models.Config + Config *config.Config Db *gorm.DB } func NewKeyValueService(db *gorm.DB) *KeyValueService { return &KeyValueService{ - Config: models.GetConfig(), + Config: config.Get(), Db: db, } } diff --git a/services/summary.go b/services/summary.go index 3415858..ce8919b 100644 --- a/services/summary.go +++ b/services/summary.go @@ -3,6 +3,7 @@ package services import ( "crypto/md5" "errors" + "github.com/muety/wakapi/config" "github.com/patrickmn/go-cache" "math" "sort" @@ -14,7 +15,7 @@ import ( ) type SummaryService struct { - Config *models.Config + Config *config.Config Cache *cache.Cache Db *gorm.DB HeartbeatService *HeartbeatService @@ -23,7 +24,7 @@ type SummaryService struct { func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService { return &SummaryService{ - Config: models.GetConfig(), + Config: config.Get(), Cache: cache.New(24*time.Hour, 24*time.Hour), Db: db, HeartbeatService: heartbeatService, diff --git a/services/user.go b/services/user.go index ee66066..dbdc05c 100644 --- a/services/user.go +++ b/services/user.go @@ -3,19 +3,20 @@ package services import ( "errors" "github.com/jinzhu/gorm" + "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "github.com/muety/wakapi/utils" uuid "github.com/satori/go.uuid" ) type UserService struct { - Config *models.Config + Config *config.Config Db *gorm.DB } func NewUserService(db *gorm.DB) *UserService { return &UserService{ - Config: models.GetConfig(), + Config: config.Get(), Db: db, } } @@ -53,7 +54,7 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, Password: signup.Password, } - if err := utils.HashPassword(u, srv.Config.PasswordSalt); err != nil { + if err := utils.HashPassword(u, srv.Config.Security.PasswordSalt); err != nil { return nil, false, err } @@ -102,7 +103,7 @@ func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) { func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { user.Password = login.Password - if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil { + if err := utils.HashPassword(user, srv.Config.Security.PasswordSalt); err != nil { return nil, err } diff --git a/utils/auth.go b/utils/auth.go index 365557c..9ca1389 100644 --- a/utils/auth.go +++ b/utils/auth.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/hex" "errors" + "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "golang.org/x/crypto/bcrypt" "net/http" @@ -45,13 +46,13 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) { return string(keyBytes), err } -func ExtractCookieAuth(r *http.Request, config *models.Config) (login *models.Login, err error) { +func ExtractCookieAuth(r *http.Request, config *config.Config) (login *models.Login, err error) { cookie, err := r.Cookie(models.AuthCookieKey) if err != nil { return nil, errors.New("missing authentication") } - if err := config.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil { + if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil { return nil, errors.New("invalid parameters") } diff --git a/utils/common.go b/utils/common.go index 39c658e..ee297d1 100644 --- a/utils/common.go +++ b/utils/common.go @@ -3,10 +3,9 @@ package utils import ( "errors" "fmt" + "github.com/muety/wakapi/config" "regexp" "time" - - "github.com/muety/wakapi/models" ) func ParseDate(date string) (time.Time, error) { @@ -22,7 +21,7 @@ func FormatDateHuman(date time.Time) string { } func ParseUserAgent(ua string) (string, string, error) { - re := regexp.MustCompile(`^wakatime\/[\d+.]+\s\((\w+).*\)\s.+\s(\w+)\/.+$`) + re := regexp.MustCompile(`(?iU)^wakatime\/[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`) groups := re.FindAllStringSubmatch(ua, -1) if len(groups) == 0 || len(groups[0]) != 3 { return "", "", errors.New("failed to parse user agent string") @@ -30,10 +29,10 @@ func ParseUserAgent(ua string) (string, string, error) { return groups[0][1], groups[0][2], nil } -func MakeConnectionString(config *models.Config) string { - switch config.DbDialect { +func MakeConnectionString(config *config.Config) string { + switch config.Db.Dialect { case "mysql": - return mySqlConnectionString(config) + return mysqlConnectionString(config) case "postgres": return postgresConnectionString(config) case "sqlite3": @@ -42,28 +41,28 @@ func MakeConnectionString(config *models.Config) string { return "" } -func mySqlConnectionString(config *models.Config) string { +func mysqlConnectionString(config *config.Config) string { //location, _ := time.LoadLocation("Local") return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES", - config.DbUser, - config.DbPassword, - config.DbHost, - config.DbPort, - config.DbName, + config.Db.User, + config.Db.Password, + config.Db.Host, + config.Db.Port, + config.Db.Name, "Local", ) } -func postgresConnectionString(config *models.Config) string { +func postgresConnectionString(config *config.Config) string { return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", - config.DbHost, - config.DbPort, - config.DbUser, - config.DbName, - config.DbPassword, + config.Db.Host, + config.Db.Port, + config.Db.User, + config.Db.Name, + config.Db.Password, ) } -func sqliteConnectionString(config *models.Config) string { - return config.DbName +func sqliteConnectionString(config *config.Config) string { + return config.Db.Name } diff --git a/utils/common_test.go b/utils/common_test.go new file mode 100644 index 0000000..f5a5cf5 --- /dev/null +++ b/utils/common_test.go @@ -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) +} diff --git a/version.txt b/version.txt index b0f61c5..6f165bc 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.11.1 \ No newline at end of file +1.12.1 \ No newline at end of file