mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
67 Commits
Author | SHA1 | Date | |
---|---|---|---|
4dd77ded26 | |||
0bccbffd80 | |||
2b45b064eb | |||
5d8fc99b93 | |||
8231d76200 | |||
c6fd43a964 | |||
4ab657ebd5 | |||
0a07ac1dd4 | |||
a64201c93b | |||
b105b0fe1c | |||
649c658923 | |||
bc9191a514 | |||
04690d287d | |||
c142b525a4 | |||
304fa3b03f | |||
e01e6575db | |||
75e61c0dc3 | |||
6973743f41 | |||
26ef93c1af | |||
0556efd39a | |||
030181fb2f | |||
8b9a9a1a42 | |||
6576837396 | |||
1a10a4fb21 | |||
0e3ce1e9e4 | |||
50a54bde22 | |||
53f3a9d685 | |||
c37278e660 | |||
e2deadfd44 | |||
ed35e7b82d | |||
b672859021 | |||
d3713017e3 | |||
dca736752e | |||
337b39481b | |||
b9ea6530f9 | |||
a9739a6db0 | |||
a22836a644 | |||
c8e7fb461a | |||
c2b099378a | |||
20dd4cf0ab | |||
f8e1453754 | |||
fbd90d2cc1 | |||
129e208169 | |||
9fd9ffbb3d | |||
0884f620f1 | |||
7ab9c45f4f | |||
915436822b | |||
0f1d1bce4d | |||
6256c8e10a | |||
2a9fbfdfd7 | |||
56247b4e1e | |||
9d7afde6a9 | |||
0df0168584 | |||
a6fe15d69b | |||
ae363c1c82 | |||
127a614190 | |||
b8cefeb595 | |||
ae97095688 | |||
4706809170 | |||
ddc29f0414 | |||
f4af787ecf | |||
da6a00fec5 | |||
6ad33e3c3b | |||
e6e134678a | |||
1783858854 | |||
e1d040bd55 | |||
7f3a654b26 |
4
.github/workflows/linux-build-on-release.yml
vendored
4
.github/workflows/linux-build-on-release.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -25,9 +25,7 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get github.com/markbates/pkger/cmd/pkger
|
||||
go get
|
||||
go generate
|
||||
|
||||
- name: Build
|
||||
run: GO111MODULE=on go build -v .
|
||||
|
4
.github/workflows/win-build-on-release.yml
vendored
4
.github/workflows/win-build-on-release.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -25,9 +25,7 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get github.com/markbates/pkger/cmd/pkger
|
||||
go get
|
||||
go generate
|
||||
|
||||
- name: Enable Go 1.11 modules
|
||||
run: cmd /c "set GO111MODULE=on"
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -8,3 +8,7 @@ build
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
pkged.go
|
||||
package.json
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
node_modules
|
@ -1,16 +1,16 @@
|
||||
# Build Stage
|
||||
|
||||
FROM golang:1.15 AS build-env
|
||||
FROM golang:1.16 AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download && go get github.com/markbates/pkger/cmd/pkger
|
||||
RUN go mod download
|
||||
|
||||
RUN curl "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -o wait-for-it.sh && \
|
||||
chmod +x wait-for-it.sh
|
||||
|
||||
ADD . .
|
||||
RUN go generate && go build -o wakapi
|
||||
RUN go build -o wakapi
|
||||
|
||||
WORKDIR /app
|
||||
RUN cp /src/wakapi . && \
|
||||
@ -31,6 +31,7 @@ WORKDIR /app
|
||||
RUN apt update && \
|
||||
apt install -y ca-certificates
|
||||
|
||||
# See README.md and config.default.yml for all config options
|
||||
ENV ENVIRONMENT prod
|
||||
ENV WAKAPI_DB_TYPE sqlite3
|
||||
ENV WAKAPI_DB_USER ''
|
||||
|
61
README.md
61
README.md
@ -49,6 +49,8 @@
|
||||
* [Support](#-support)
|
||||
* [FAQs](#-faqs)
|
||||
|
||||
Further instructions can be found in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||
|
||||
## 📬 **User Survey**
|
||||
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
||||
|
||||
@ -75,7 +77,12 @@ If you want to you out free, hosted cloud service, all you need to do is create
|
||||
|
||||
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||
|
||||
### 🐳 Option 2: Use Docker
|
||||
### 📦 Option 2: Quick-run a Release
|
||||
```bash
|
||||
$ curl -L https://wakapi.dev/get | bash
|
||||
```
|
||||
|
||||
### 🐳 Option 3: Use Docker
|
||||
```bash
|
||||
# Create a persistent volume
|
||||
$ docker volume create wakapi-data
|
||||
@ -92,22 +99,9 @@ $ docker run -d \
|
||||
|
||||
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
|
||||
|
||||
### 📦 Option 3: Run a release
|
||||
```bash
|
||||
# Download the release and unpack it
|
||||
$ wget https://github.com/muety/wakapi/releases/download/1.20.2/wakapi_linux_amd64.zip
|
||||
$ unzip wakapi_linux_amd64.zip
|
||||
|
||||
# Optionally adapt config to your needs
|
||||
$ vi config.yml
|
||||
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
```
|
||||
|
||||
### 🧑💻 Option 4: Run from source
|
||||
### 🧑💻 Option 4: Compile and run from source
|
||||
#### Prerequisites
|
||||
* Go >= 1.13 (with `$GOPATH` properly set)
|
||||
* Go >= 1.16 (with `$GOPATH` properly set)
|
||||
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||
* Fedora / RHEL: `dnf install @development-tools`
|
||||
* Ubuntu / Debian: `apt install build-essential`
|
||||
@ -115,23 +109,18 @@ $ ./wakapi
|
||||
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Build the executable
|
||||
$ go build -o wakapi
|
||||
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
|
||||
# Install packaging tool
|
||||
$ export GO111MODULE=on
|
||||
$ go get github.com/markbates/pkger/cmd/pkger
|
||||
|
||||
# Build the executable
|
||||
$ go generate
|
||||
$ go build -o wakapi
|
||||
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
```
|
||||
|
||||
**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 = true` in `config.yml`.
|
||||
**Note:** Check the comments `config.yml` for best practices regarding security configuration and more.
|
||||
|
||||
### 💻 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.
|
||||
@ -149,7 +138,7 @@ api_url = http://localhost:3000/api/heartbeat
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
```
|
||||
|
||||
Optionally, you can set up a [client-side proxy](docs/advanced_setup.md) in addition.
|
||||
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
|
||||
|
||||
## 🔧 Configuration Options
|
||||
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.
|
||||
@ -178,10 +167,15 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
||||
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
||||
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
||||
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
||||
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
|
||||
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details |
|
||||
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
||||
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
||||
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||
| `sentry.sample_rate_heartbats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
||||
| `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
||||
|
||||
### Supported databases
|
||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||
@ -255,13 +249,22 @@ However, if you want to expose your wakapi instance to the public anyway, you ne
|
||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
### Building Tailwind
|
||||
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well.
|
||||
### Building web assets
|
||||
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.
|
||||
|
||||
#### TailwindCSS
|
||||
```bash
|
||||
$ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css
|
||||
```
|
||||
|
||||
#### Iconify
|
||||
```bash
|
||||
$ yarn add -D @iconify/json-tools @iconify/json
|
||||
$ node scripts/bundle_icons.js
|
||||
```
|
||||
|
||||
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
||||
|
||||
## 🙏 Support
|
||||
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!
|
||||
|
||||
|
@ -1,41 +1,58 @@
|
||||
env: development
|
||||
env: production
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||
tls_cert_path: # leave blank to not use https
|
||||
tls_key_path: # leave blank to not use https
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||
tls_cert_path: # leave blank to not use https
|
||||
tls_key_path: # leave blank to not use https
|
||||
port: 3000
|
||||
base_path: /
|
||||
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
svelte: Svelte
|
||||
|
||||
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
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
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
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
insecure_cookies: false # You need to set this to 'true' when on localhost
|
||||
password_salt: # change this
|
||||
insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
|
||||
cookie_max_age: 172800
|
||||
allow_signup: true
|
||||
expose_metrics: false
|
||||
|
||||
sentry:
|
||||
dsn: # leave blank to disable sentry integration
|
||||
enable_tracing: true # whether to use performance monitoring
|
||||
sample_rate: 0.75 # probability of tracing a request
|
||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||
dsn: # leave blank to disable sentry integration
|
||||
enable_tracing: true # whether to use performance monitoring
|
||||
sample_rate: 0.75 # probability of tracing a request
|
||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||
|
||||
mail:
|
||||
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
||||
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
||||
smtp: # smtp settings when sending mails via smtp
|
||||
host:
|
||||
port:
|
||||
username:
|
||||
password:
|
||||
tls:
|
||||
sender: Wakapi <noreply@wakapi.dev>
|
||||
mailwhale: # mailwhale.dev settings when using mailwhale as sending service
|
||||
url:
|
||||
client_id:
|
||||
client_secret:
|
||||
|
260
config/config.go
260
config/config.go
@ -4,21 +4,17 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/muety/wakapi/models"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/data"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -49,8 +45,19 @@ const (
|
||||
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
|
||||
)
|
||||
|
||||
const (
|
||||
MailProviderSmtp = "smtp"
|
||||
MailProviderMailWhale = "mailwhale"
|
||||
)
|
||||
|
||||
var emailProviders = []string{
|
||||
MailProviderSmtp,
|
||||
MailProviderMailWhale,
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||
var env string
|
||||
|
||||
type appConfig struct {
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
@ -72,16 +79,17 @@ type securityConfig struct {
|
||||
}
|
||||
|
||||
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 `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
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 `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
@ -89,6 +97,7 @@ type serverConfig struct {
|
||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
||||
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
|
||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
|
||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
||||
}
|
||||
@ -100,6 +109,28 @@ type sentryConfig struct {
|
||||
SampleRateHeartbeats float32 `yaml:"sample_rate_heartbeats" default:"0.1" env:"WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS"`
|
||||
}
|
||||
|
||||
type mailConfig struct {
|
||||
Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"`
|
||||
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
|
||||
MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
|
||||
Smtp SMTPMailConfig `yaml:"smtp"`
|
||||
}
|
||||
|
||||
type MailwhaleMailConfig struct {
|
||||
Url string `env:"WAKAPI_MAIL_MAILWHALE_URL"`
|
||||
ClientId string `yaml:"client_id" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_ID"`
|
||||
ClientSecret string `yaml:"client_secret" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET"`
|
||||
}
|
||||
|
||||
type SMTPMailConfig struct {
|
||||
Host string `env:"WAKAPI_MAIL_SMTP_HOST"`
|
||||
Port uint `env:"WAKAPI_MAIL_SMTP_PORT"`
|
||||
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
|
||||
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
|
||||
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
|
||||
Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Env string `default:"dev" env:"ENVIRONMENT"`
|
||||
Version string `yaml:"-"`
|
||||
@ -108,6 +139,7 @@ type Config struct {
|
||||
Db dbConfig
|
||||
Server serverConfig
|
||||
Sentry sentryConfig
|
||||
Mail mailConfig
|
||||
}
|
||||
|
||||
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
|
||||
@ -142,86 +174,32 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
db.AutoMigrate(&models.Alias{})
|
||||
db.AutoMigrate(&models.Heartbeat{})
|
||||
db.AutoMigrate(&models.Summary{})
|
||||
db.AutoMigrate(&models.SummaryItem{})
|
||||
db.AutoMigrate(&models.LanguageMapping{})
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.HttpFileSystemMigrationSource{
|
||||
FileSystem: pkger.Dir("/migrations"),
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
sqlDb, _ := db.DB()
|
||||
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logbuch.Info("applied %d fixtures", n)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Name,
|
||||
config.Charset,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
sslmode,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
||||
|
||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
@ -238,23 +216,16 @@ func (c *appConfig) GetOSColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
func (c *serverConfig) GetPublicUrl() string {
|
||||
return strings.TrimSuffix(c.PublicUrl, "/")
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
file, err := pkger.Open("/version.txt")
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
func (c *SMTPMailConfig) ConnStr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(bytes))
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
|
||||
func readColors() map[string]map[string]string {
|
||||
@ -266,19 +237,14 @@ func readColors() map[string]map[string]string {
|
||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
|
||||
raw := data.ColorsFile
|
||||
if IsDev(env) {
|
||||
raw, _ = ioutil.ReadFile("data/colors.json")
|
||||
}
|
||||
|
||||
var colors = make(map[string]map[string]string)
|
||||
|
||||
file, err := pkger.Open("/data/colors.json")
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
bytes, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bytes, &colors); err != nil {
|
||||
if err := json.Unmarshal(raw, &colors); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
@ -299,42 +265,13 @@ func resolveDbDialect(dbType string) string {
|
||||
return dbType
|
||||
}
|
||||
|
||||
func initSentry(config sentryConfig, debug bool) {
|
||||
if err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: config.Dsn,
|
||||
Debug: debug,
|
||||
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
||||
if !config.EnableTracing {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
|
||||
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
||||
txName := hub.Scope().Transaction()
|
||||
|
||||
if strings.HasPrefix(txName, "GET /assets") {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
if txName == "POST /api/heartbeat" {
|
||||
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
|
||||
}
|
||||
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
|
||||
}),
|
||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
type principalGetter interface {
|
||||
GetPrincipal() *models.User
|
||||
}
|
||||
if hint.Context != nil {
|
||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||
if p := req.Context().Value("principal"); p != nil {
|
||||
event.User.ID = p.(principalGetter).GetPrincipal().ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return event
|
||||
},
|
||||
}); err != nil {
|
||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
||||
func findString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func Set(config *Config) {
|
||||
@ -345,7 +282,7 @@ func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
func Load(version string) *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.Parse()
|
||||
@ -354,7 +291,8 @@ func Load() *Config {
|
||||
logbuch.Fatal("failed to read config: %v", err)
|
||||
}
|
||||
|
||||
config.Version = readVersion()
|
||||
env = config.Env
|
||||
config.Version = strings.TrimSpace(version)
|
||||
config.App.Colors = readColors()
|
||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
@ -385,6 +323,10 @@ func Load() *Config {
|
||||
initSentry(config.Sentry, config.IsDev())
|
||||
}
|
||||
|
||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
}
|
||||
|
86
config/db.go
Normal file
86
config/db.go
Normal file
@ -0,0 +1,86 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
/*
|
||||
A quick note to myself including some clarifications about time zones.
|
||||
|
||||
- There are basically four time zones (at least in case of MySQL): (1) User, (2) Wakapi (host system), (3) MySQL server, (4) MySQL session
|
||||
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
|
||||
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps))
|
||||
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
|
||||
- Setting a `loc` parameter specifies what location parsed time.Time objects will be in, however, does not affect the session time zone setting (https://github.com/go-sql-driver/mysql#loc)
|
||||
- I.e., when not setting `time_zone` in addition, the session time zone will probably default to the server time zone (UTC in case of Docker)
|
||||
- Session time zone will result in conversions of inserted times from that time zone to UTC
|
||||
- From my understanding, TIMESTAMP only stores a plain time value without tz information and then converts it only for retrieval to whatever tz is set for the session
|
||||
- E.g., when inserting '2021-04-27 08:26:07' with session tz set to Europe/Berlin and then viewing the database table with UTC tz will return '2021-04-27 06:26:07' instead
|
||||
- Currently, no session tz is set (only loc), so the database server will assume it receives UTC. However, as no tz is set when retrieving the values either, they are also going to be returned just as is and as long as `loc=Local` is set properly, they are parsed in Go code with the correct time zone
|
||||
- As long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
|
||||
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
|
||||
- It is important to have same `time_zone` and `loc` parameters set when sending and receiving, no matter what it is (writing / reading in 'UTC' will yield same results as writing / reading in 'Europe/Berlin')
|
||||
- "The session time zone setting affects display and storage of time values that are zone-sensitive. This includes the values displayed by functions such as NOW() or CURTIME(), and values stored in and retrieved from TIMESTAMP columns. Values for TIMESTAMP columns are converted from the session time zone to UTC for storage, and from UTC to the session time zone for retrieval." (https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html)
|
||||
- Wakapi always uses time.Local for everything, i.e. all times in the database have to be interpreted with that tz
|
||||
- New heartbeats are sent with Python-like Unix timestamps, i.e. are absolute points in time as therefore not subject to any kind of tz issues
|
||||
- E.g. with Wakapi running in Europe/Berlin, 1619379014.7335322 (2021-04-25T19:30:14.733Z (UTC)) will be inserted as 2021-04-25T21:30:14.733+0200 (CEST), but obviously represents the exact same point in time no matter where it originated from
|
||||
- The reason why we need to explicitly care about tzs in the first place is the fact that user's can request their data within intervals and the results should correspond to their tz
|
||||
- Users from California wouldn't have to care about their heartbeats being stored in German time zone
|
||||
- However, they DO care when requesting their summaries
|
||||
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
|
||||
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
|
||||
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
|
||||
*/
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Name,
|
||||
config.Charset,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
sslmode,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
14
config/fs.go
Normal file
14
config/fs.go
Normal file
@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ChooseFS returns a local (DirFS) file system when on 'dev' environment and the given go-embed file system otherwise
|
||||
func ChooseFS(localDir string, embeddedFS fs.FS) fs.FS {
|
||||
if Get().IsDev() {
|
||||
return os.DirFS(localDir)
|
||||
}
|
||||
return embeddedFS
|
||||
}
|
146
config/sentry.go
Normal file
146
config/sentry.go
Normal file
@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// How to: Logging
|
||||
// Use logbuch.[Debug|Info|Warn|Error|Fatal]() by default
|
||||
// Use config.Log().[Debug|Info|Warn|Error|Fatal]() when wanting the log to appear in Sentry as well
|
||||
|
||||
type capturingWriter struct {
|
||||
Writer io.Writer
|
||||
Message string
|
||||
}
|
||||
|
||||
func (c *capturingWriter) Clear() {
|
||||
c.Message = ""
|
||||
}
|
||||
|
||||
func (c *capturingWriter) Write(p []byte) (n int, err error) {
|
||||
c.Message = string(p)
|
||||
return c.Writer.Write(p)
|
||||
}
|
||||
|
||||
// SentryWrapperLogger is a wrapper around a logbuch.Logger that forwards events to Sentry in addition and optionally allows to attach a request context
|
||||
type SentryWrapperLogger struct {
|
||||
*logbuch.Logger
|
||||
req *http.Request
|
||||
outWriter *capturingWriter
|
||||
errWriter *capturingWriter
|
||||
}
|
||||
|
||||
func Log() *SentryWrapperLogger {
|
||||
ow, ew := &capturingWriter{Writer: os.Stdout}, &capturingWriter{Writer: os.Stderr}
|
||||
return &SentryWrapperLogger{
|
||||
Logger: logbuch.NewLogger(ow, ew),
|
||||
outWriter: ow,
|
||||
errWriter: ew,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Request(req *http.Request) *SentryWrapperLogger {
|
||||
l.req = req
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Debug(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Debug(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelDebug)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Info(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Info(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelInfo)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Warn(msg string, params ...interface{}) {
|
||||
l.outWriter.Clear()
|
||||
l.Logger.Warn(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelWarning)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Error(msg string, params ...interface{}) {
|
||||
l.errWriter.Clear()
|
||||
l.Logger.Error(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelError)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) Fatal(msg string, params ...interface{}) {
|
||||
l.errWriter.Clear()
|
||||
l.Logger.Fatal(msg, params...)
|
||||
l.log(l.errWriter.Message, sentry.LevelFatal)
|
||||
}
|
||||
|
||||
func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
|
||||
event := sentry.NewEvent()
|
||||
event.Level = level
|
||||
event.Message = msg
|
||||
|
||||
if l.req != nil {
|
||||
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
||||
hub := h.(*sentry.Hub)
|
||||
hub.Scope().SetRequest(l.req)
|
||||
if u := getPrincipal(l.req); u != nil {
|
||||
hub.Scope().SetUser(sentry.User{ID: u.ID})
|
||||
}
|
||||
hub.CaptureEvent(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sentry.CaptureEvent(event)
|
||||
}
|
||||
|
||||
func initSentry(config sentryConfig, debug bool) {
|
||||
if err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: config.Dsn,
|
||||
Debug: debug,
|
||||
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
||||
if !config.EnableTracing {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
|
||||
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
||||
txName := hub.Scope().Transaction()
|
||||
|
||||
if strings.HasPrefix(txName, "GET /assets") || strings.HasPrefix(txName, "GET /api/health") {
|
||||
return sentry.SampledFalse
|
||||
}
|
||||
if txName == "POST /api/heartbeat" {
|
||||
return sentry.UniformTracesSampler(config.SampleRateHeartbeats).Sample(ctx)
|
||||
}
|
||||
return sentry.UniformTracesSampler(config.SampleRate).Sample(ctx)
|
||||
}),
|
||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
if hint.Context != nil {
|
||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||
if u := getPrincipal(req); u != nil {
|
||||
event.User.ID = u.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return event
|
||||
},
|
||||
}); err != nil {
|
||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getPrincipal(r *http.Request) *models.User {
|
||||
type principalGetter interface {
|
||||
GetPrincipal() *models.User
|
||||
}
|
||||
if p := r.Context().Value("principal"); p != nil {
|
||||
return p.(principalGetter).GetPrincipal()
|
||||
}
|
||||
return nil
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
IndexTemplate = "index.tpl.html"
|
||||
LoginTemplate = "login.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
IndexTemplate = "index.tpl.html"
|
||||
LoginTemplate = "login.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SetPasswordTemplate = "set-password.tpl.html"
|
||||
ResetPasswordTemplate = "reset-password.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
)
|
||||
|
File diff suppressed because it is too large
Load Diff
6
data/data.go
Normal file
6
data/data.go
Normal file
@ -0,0 +1,6 @@
|
||||
package data
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed colors.json
|
||||
var ColorsFile []byte
|
@ -7,10 +7,11 @@ services:
|
||||
- 3000:3000
|
||||
restart: always
|
||||
environment:
|
||||
# See README.md and config.default.yml for all config options
|
||||
WAKAPI_DB_TYPE: "postgres"
|
||||
WAKAPI_DB_NAME: "wakapi"
|
||||
WAKAPI_DB_USER: "wakapi"
|
||||
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
|
||||
WAKAPI_DB_PASSWORD: "choose-a-password"
|
||||
WAKAPI_DB_HOST: "db"
|
||||
WAKAPI_DB_PORT: "5432"
|
||||
ENVIRONMENT: "prod"
|
||||
@ -19,5 +20,5 @@ services:
|
||||
image: postgres:12.3
|
||||
environment:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "CHANGE_ME!!!"
|
||||
POSTGRES_PASSWORD: "choose-a-password"
|
||||
POSTGRES_DB: "wakapi"
|
||||
|
@ -1,47 +0,0 @@
|
||||
# Advanced Setup
|
||||
This page contains instructions for additional setup options, none of which are mandatory.
|
||||
|
||||
## Optional: Client-side proxy
|
||||
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API – Wakapi in this case.
|
||||
|
||||
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
|
||||
|
||||
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
|
||||
|
||||
### Option 1: [tinyproxy](https://tinyproxy.github.io) forward proxy (`Linux`, `Mac` only)
|
||||
In this example we use _tinyproxy_ as a small, easy-to-install proxy server, written in C, that runs on your local machine.
|
||||
1. Install [tinyproxy](https://tinyproxy.github.io)
|
||||
* Fedora / RHEL: `dnf install tinyproxy`
|
||||
* Debian / Ubuntu: `apt install tinyproxy`
|
||||
* MacOS: Install from [MacPorts](https://ports.macports.org/port/tinyproxy/summary)
|
||||
1. Enable and start it
|
||||
* Linux: `sudo systemctl start tinyproxy && sudo systemctl enable tinyproxy`
|
||||
* Mac: Not sure, sorry ¯\_(ツ)_/¯
|
||||
1. Update `~/.wakatime.cfg`
|
||||
* Set `proxy = http://localhost:8888`
|
||||
1. Done
|
||||
* All Wakapi requests are passed through tinyproxy now, which keeps a TCP connection with the server open for some time
|
||||
|
||||
### Option 2: [Caddy](https://caddyserver.com) reverse proxy (`Win`, `Linux`, `Mac`)
|
||||
In this example, we misuse Caddy, which is a web server and reverse proxy, to fulfil the above scenario.
|
||||
|
||||
1. [Install Caddy](https://caddyserver.com/)
|
||||
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
|
||||
1. Create a Caddyfile
|
||||
```
|
||||
# /etc/caddy/Caddyfile
|
||||
|
||||
http://localhost:8070 {
|
||||
reverse_proxy * {
|
||||
to https://wakapi.dev # <-- substitute your own Wakapi host here
|
||||
header_up Host {http.reverse_proxy.upstream.host}
|
||||
header_down -Server
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Restart Caddy
|
||||
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
|
||||
1. Update `~/.wakatime.cfg`
|
||||
* Set `api_url = http://localhost:8070/api/heartbeat`
|
||||
1. Done
|
||||
* All Wakapi requests are passed through Caddy now, which keeps a TCP connection with the server open for some time
|
6
go.mod
6
go.mod
@ -1,9 +1,11 @@
|
||||
module github.com/muety/wakapi
|
||||
|
||||
go 1.13
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/emvi/logbuch v1.1.1
|
||||
github.com/getsentry/sentry-go v0.10.0
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
@ -14,11 +16,9 @@ require (
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/markbates/pkger v0.17.1
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/swaggo/swag v1.7.0
|
||||
|
332
go.sum
332
go.sum
@ -1,12 +1,9 @@
|
||||
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=
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
@ -14,206 +11,98 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
|
||||
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
|
||||
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
|
||||
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU=
|
||||
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
|
||||
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
|
||||
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
|
||||
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
||||
github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
|
||||
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
|
||||
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
|
||||
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
|
||||
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
|
||||
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
|
||||
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
|
||||
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
|
||||
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
|
||||
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
|
||||
github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
|
||||
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
|
||||
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
|
||||
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
|
||||
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
|
||||
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
|
||||
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
|
||||
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
|
||||
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
|
||||
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
|
||||
@ -277,34 +166,23 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
|
||||
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
|
||||
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
|
||||
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
|
||||
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
@ -318,170 +196,80 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
|
||||
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
|
||||
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
|
||||
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
|
||||
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
|
||||
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
|
||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
|
||||
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
|
||||
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
|
||||
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
|
||||
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
|
||||
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
|
||||
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
|
||||
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
|
||||
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
|
||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
@ -494,15 +282,10 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
|
||||
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY=
|
||||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
@ -512,7 +295,6 @@ github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
@ -520,33 +302,19 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@ -555,116 +323,67 @@ golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
@ -672,61 +391,29 @@ golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
|
||||
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@ -740,9 +427,4 @@ gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
|
||||
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
|
||||
|
51
main.go
51
main.go
@ -1,23 +1,25 @@
|
||||
package main
|
||||
|
||||
//go:generate $GOPATH/bin/pkger
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/markbates/pkger"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/routes/api"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm/logger"
|
||||
"embed"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/routes/api"
|
||||
"github.com/muety/wakapi/services/mail"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/routes"
|
||||
@ -30,6 +32,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Embed version.txt
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
// Embed static files
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
config *conf.Config
|
||||
@ -51,6 +61,7 @@ var (
|
||||
languageMappingService services.ILanguageMappingService
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
mailService services.IMailService
|
||||
keyValueService services.IKeyValueService
|
||||
miscService services.IMiscService
|
||||
)
|
||||
@ -78,7 +89,7 @@ var (
|
||||
|
||||
// @BasePath /api
|
||||
func main() {
|
||||
config = conf.Load()
|
||||
config = conf.Load(version)
|
||||
|
||||
// Set log level
|
||||
if config.IsDev() {
|
||||
@ -107,7 +118,7 @@ func main() {
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, _ := db.DB()
|
||||
sqlDb, err := db.DB()
|
||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
if err != nil {
|
||||
@ -134,6 +145,7 @@ func main() {
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
mailService = mail.NewMailService()
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
|
||||
@ -157,9 +169,9 @@ func main() {
|
||||
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
|
||||
// Setup Routers
|
||||
@ -172,8 +184,9 @@ func main() {
|
||||
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
|
||||
router.Use(handlers.RecoveryHandler())
|
||||
if config.Sentry.Dsn != "" {
|
||||
router.Use(middlewares.NewSentryMiddleware())
|
||||
router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
|
||||
}
|
||||
rootRouter.Use(middlewares.NewSecurityMiddleware())
|
||||
|
||||
// Route registrations
|
||||
homeHandler.RegisterRoutes(rootRouter)
|
||||
@ -193,7 +206,11 @@ func main() {
|
||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
// Static Routes
|
||||
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: pkger.Dir("/static")})
|
||||
// https://github.com/golang/go/issues/43431
|
||||
embeddedStatic, _ := fs.Sub(staticFiles, "static")
|
||||
static := conf.ChooseFS("static", embeddedStatic)
|
||||
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
|
||||
router.PathPrefix("/contribute.json").Handler(fileServer)
|
||||
router.PathPrefix("/assets").Handler(fileServer)
|
||||
router.PathPrefix("/swagger-ui").Handler(fileServer)
|
||||
router.PathPrefix("/docs").Handler(
|
||||
|
32
middlewares/security.go
Normal file
32
middlewares/security.go
Normal file
@ -0,0 +1,32 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var securityHeaders = map[string]string{
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Content-Security-Policy": "default-src 'self' 'unsafe-inline'; img-src 'self' https: data:; form-action 'self'; block-all-mixed-content;",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
// SecurityMiddleware is a handler to add some basic security headers to responses
|
||||
type SecurityMiddleware struct {
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func NewSecurityMiddleware() func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return &SecurityMiddleware{h}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *SecurityMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range securityHeaders {
|
||||
if w.Header().Get(k) == "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
f.handler.ServeHTTP(w, r)
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/getsentry/sentry-go"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SentryMiddleware struct {
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func NewSentryMiddleware() func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
}).Handle(&SentryMiddleware{handler: h})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SentryMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), "-", "-")
|
||||
h.handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
if hub := sentry.GetHubFromContext(ctx); hub != nil {
|
||||
if user := GetPrincipal(r); user != nil {
|
||||
hub.Scope().SetUser(sentry.User{ID: user.ID})
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
f := migrationFunc{
|
||||
name: "000-apply_fixtures",
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
return cfg.GetFixturesFunc(cfg.Db.Dialect)(db)
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
47
migrations/20210411_add_imprint_content.go
Normal file
47
migrations/20210411_add_imprint_content.go
Normal file
@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210411-add_imprint_content"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
|
||||
if err := db.
|
||||
Clauses(clause.OnConflict{UpdateAll: false, DoNothing: true}).
|
||||
Where(condition, imprintKv.Key).
|
||||
Assign(imprintKv).
|
||||
Create(imprintKv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: name,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
22
migrations/20210411_drop_migrations_table.go
Normal file
22
migrations/20210411_drop_migrations_table.go
Normal file
@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20210411-drop_migrations_table"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if err := db.Migrator().DropTable("gorp_migrations"); err != nil {
|
||||
logbuch.Info("dropped table 'gorp_migrations'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetAll() ([]*models.Alias, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
|
@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(user, time, time2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
|
@ -19,6 +19,16 @@ func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByEmail(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByResetToken(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
@ -69,6 +79,11 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) FlushCache() {
|
||||
m.Called()
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -30,6 +31,9 @@ type StatsData struct {
|
||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||
totalTime := summary.TotalTime()
|
||||
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
||||
if math.IsInf(float64(numDays), 0) {
|
||||
numDays = 0
|
||||
}
|
||||
|
||||
data := &StatsData{
|
||||
Username: summary.UserID,
|
||||
|
@ -129,7 +129,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
|
@ -34,10 +34,11 @@ func (h *Heartbeat) Valid() bool {
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
||||
for ending, value := range languageMappings {
|
||||
if strings.HasSuffix(h.Entity, "."+ending) {
|
||||
if ok, prec := strings.HasSuffix(h.Entity, "."+ending), strings.Count(ending, "."); ok && prec > maxPrec {
|
||||
h.Language = value
|
||||
return
|
||||
maxPrec = prec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,22 +28,28 @@ func TestHeartbeat_Augment(t *testing.T) {
|
||||
testMappings := map[string]string{
|
||||
"py": "Python3",
|
||||
"foo": "Foo Script",
|
||||
"php": "PHP 8",
|
||||
"blade.php": "Blade",
|
||||
}
|
||||
|
||||
sut1, sut2 := &Heartbeat{
|
||||
sut1, sut2, sut3 := &Heartbeat{
|
||||
Entity: "~/dev/file.py",
|
||||
Language: "Python",
|
||||
}, &Heartbeat{
|
||||
Entity: "~/dev/file.blade.php",
|
||||
Language: "unknown",
|
||||
}, &Heartbeat{
|
||||
Entity: "~/dev/file.php",
|
||||
Language: "PHP",
|
||||
}
|
||||
|
||||
sut1.Augment(testMappings)
|
||||
sut2.Augment(testMappings)
|
||||
sut3.Augment(testMappings)
|
||||
|
||||
assert.Equal(t, "Python3", sut1.Language)
|
||||
assert.Equal(t, "Blade", sut2.Language)
|
||||
assert.Equal(t, "PHP 8", sut3.Language)
|
||||
}
|
||||
|
||||
func TestHeartbeat_GetKey(t *testing.T) {
|
||||
|
45
models/mail.go
Normal file
45
models/mail.go
Normal file
@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mail struct {
|
||||
From MailAddress
|
||||
To MailAddresses
|
||||
Subject string
|
||||
Body string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (m *Mail) WithText(text string) *Mail {
|
||||
m.Body = text
|
||||
m.Type = "text/plain; charset=UTF-8"
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mail) WithHTML(html string) *Mail {
|
||||
m.Body = html
|
||||
m.Type = "text/html; charset=UTF-8"
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mail) String() string {
|
||||
return fmt.Sprintf("To: %s\r\n"+
|
||||
"From: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"Content-Type: %s\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n",
|
||||
strings.Join(m.To.RawStrings(), ", "),
|
||||
m.From.String(),
|
||||
m.Subject,
|
||||
m.Type,
|
||||
m.Body,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Mail) Reader() *strings.Reader {
|
||||
return strings.NewReader(m.String())
|
||||
}
|
66
models/mail_address.go
Normal file
66
models/mail_address.go
Normal file
@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "regexp"
|
||||
|
||||
const (
|
||||
MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
|
||||
EmailAddrPattern = ".*\\s<(" + MailPattern + ")>|(" + MailPattern + ")"
|
||||
)
|
||||
|
||||
var (
|
||||
mailRegex *regexp.Regexp
|
||||
emailAddrRegex *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
mailRegex = regexp.MustCompile(MailPattern)
|
||||
emailAddrRegex = regexp.MustCompile(EmailAddrPattern)
|
||||
}
|
||||
|
||||
type MailAddress string
|
||||
|
||||
type MailAddresses []MailAddress
|
||||
|
||||
func (m MailAddress) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func (m MailAddress) Raw() string {
|
||||
match := emailAddrRegex.FindStringSubmatch(string(m))
|
||||
if len(match) == 3 {
|
||||
if match[2] != "" {
|
||||
return match[2]
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m MailAddress) Valid() bool {
|
||||
return emailAddrRegex.Match([]byte(m))
|
||||
}
|
||||
|
||||
func (m MailAddresses) Strings() []string {
|
||||
out := make([]string, len(m))
|
||||
for i, s := range m {
|
||||
out[i] = s.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m MailAddresses) RawStrings() []string {
|
||||
out := make([]string, len(m))
|
||||
for i, s := range m {
|
||||
out[i] = s.Raw()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m MailAddresses) AllValid() bool {
|
||||
for _, a := range m {
|
||||
if !a.Valid() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
88
models/mail_address_test.go
Normal file
88
models/mail_address_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMailAddress_SingleRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{
|
||||
"john.doe@example.org",
|
||||
"john.doe@example.org",
|
||||
},
|
||||
{
|
||||
"John Doe <john.doe@example.org>",
|
||||
"john.doe@example.org",
|
||||
},
|
||||
{
|
||||
"invalid",
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := MailAddress(test.in).Raw()
|
||||
assert.Equal(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAddress_AllRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
in []string
|
||||
out []string
|
||||
}{
|
||||
{
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
},
|
||||
{
|
||||
[]string{"John Doe <john.doe@example.org>", "foo@bar.com"},
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
},
|
||||
{
|
||||
[]string{"john.doe@example.org", "invalid"},
|
||||
[]string{"john.doe@example.org", ""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := castAddresses(test.in).RawStrings()
|
||||
assert.EqualValues(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAddress_AllValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
in []string
|
||||
out bool
|
||||
}{
|
||||
{
|
||||
[]string{"john.doe@example.org", "foo@bar.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
[]string{"John Doe <john.doe@example.org>", "ínvalid"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
[]string{"", "invalid"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
out := castAddresses(test.in).AllValid()
|
||||
assert.EqualValues(t, test.out, out)
|
||||
}
|
||||
}
|
||||
|
||||
func castAddresses(addresses []string) (m MailAddresses) {
|
||||
for _, a := range addresses {
|
||||
m = append(m, MailAddress(a))
|
||||
}
|
||||
return m
|
||||
}
|
@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -30,24 +29,24 @@ type Interval struct {
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(j.String())
|
||||
return json.Marshal(j.T())
|
||||
}
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
s := strings.Trim(string(b), "\"")
|
||||
ts, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
||||
t := time.Unix(0, int64(ts*1e9)) // ms to ns
|
||||
*j = CustomTime(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
||||
func (j *CustomTime) Scan(value interface{}) error {
|
||||
var (
|
||||
t time.Time
|
||||
@ -56,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
// with sqlite, some queries (like GetLastByUser()) return dates as strings,
|
||||
// however, most of the time they are returned as time.Time
|
||||
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||
}
|
||||
case int64:
|
||||
t = time.Unix(0, value.(int64))
|
||||
break
|
||||
case time.Time:
|
||||
t = value.(time.Time)
|
||||
break
|
||||
@ -76,18 +74,17 @@ func (j *CustomTime) Scan(value interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) Value() (driver.Value, error) {
|
||||
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
return j.T().String()
|
||||
}
|
||||
|
||||
func (j CustomTime) T() time.Time {
|
||||
|
@ -47,6 +47,7 @@ type SummaryItemContainer struct {
|
||||
|
||||
type SummaryViewModel struct {
|
||||
*Summary
|
||||
*SummaryParams
|
||||
User *User
|
||||
LanguageColors map[string]string
|
||||
EditorColors map[string]string
|
||||
|
@ -1,13 +1,8 @@
|
||||
package models
|
||||
|
||||
import "regexp"
|
||||
|
||||
const (
|
||||
MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
|
||||
)
|
||||
|
||||
var (
|
||||
mailRegex *regexp.Regexp
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -17,7 +12,8 @@ func init() {
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique"`
|
||||
Email string `json:"email"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
@ -30,6 +26,7 @@ type User struct {
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"`
|
||||
ResetToken string `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -42,6 +39,17 @@ type Signup struct {
|
||||
Email string `schema:"email"`
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
Token string `schema:"token"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Email string `schema:"email"`
|
||||
}
|
||||
|
||||
type CredentialsReset struct {
|
||||
@ -51,7 +59,8 @@ type CredentialsReset struct {
|
||||
}
|
||||
|
||||
type UserDataUpdate struct {
|
||||
Email string `schema:"email"`
|
||||
Email string `schema:"email"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
@ -64,11 +73,32 @@ type CountByUser struct {
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (u *User) TZ() *time.Location {
|
||||
if u.Location == "" {
|
||||
u.Location = "Local"
|
||||
}
|
||||
tz, err := time.LoadLocation(u.Location)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return tz
|
||||
}
|
||||
|
||||
func (u *User) TZOffset() time.Duration {
|
||||
_, offset := time.Now().In(u.TZ()).Zone()
|
||||
return time.Duration(offset * int(time.Second))
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return ValidatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
}
|
||||
|
||||
func (c *SetPasswordRequest) IsValid() bool {
|
||||
return ValidatePassword(c.Password) &&
|
||||
c.Password == c.PasswordRepeat
|
||||
}
|
||||
|
||||
func (s *Signup) IsValid() bool {
|
||||
return ValidateUsername(s.Username) &&
|
||||
ValidateEmail(s.Email) &&
|
||||
@ -77,7 +107,7 @@ func (s *Signup) IsValid() bool {
|
||||
}
|
||||
|
||||
func (r *UserDataUpdate) IsValid() bool {
|
||||
return ValidateEmail(r.Email)
|
||||
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
|
||||
}
|
||||
|
||||
func ValidateUsername(username string) bool {
|
||||
@ -91,3 +121,8 @@ func ValidatePassword(password string) bool {
|
||||
func ValidateEmail(email string) bool {
|
||||
return email == "" || mailRegex.Match([]byte(email))
|
||||
}
|
||||
|
||||
func ValidateTimezone(tz string) bool {
|
||||
_, err := time.LoadLocation(tz)
|
||||
return err == nil
|
||||
}
|
||||
|
19
models/user_test.go
Normal file
19
models/user_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUser_TZ(t *testing.T) {
|
||||
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
|
||||
pst, _ := time.LoadLocation("America/Los_Angeles")
|
||||
_, offset := time.Now().Zone()
|
||||
|
||||
assert.Equal(t, time.Local, sut1.TZ())
|
||||
assert.Equal(t, pst, sut2.TZ())
|
||||
|
||||
assert.InDelta(t, time.Duration(offset*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||
assert.InDelta(t, time.Duration(-7*int(time.Hour)), sut2.TZOffset(), float64(1*time.Second))
|
||||
}
|
@ -6,6 +6,11 @@ type LoginViewModel struct {
|
||||
TotalUsers int
|
||||
}
|
||||
|
||||
type SetPasswordViewModel struct {
|
||||
LoginViewModel
|
||||
Token string
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
|
@ -14,6 +14,14 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
|
||||
return &AliasRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
|
@ -15,6 +15,15 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
return &HeartbeatRepository{db: db}
|
||||
}
|
||||
|
||||
// Use with caution!!
|
||||
func (r *HeartbeatRepository) GetAll() ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
if err := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
@ -45,8 +54,8 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time < ?", to).
|
||||
Where("time >= ?", from.Local()).
|
||||
Where("time < ?", to.Local()).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
@ -117,7 +126,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t).
|
||||
Where("time <= ?", t.Local()).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@ -16,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
||||
return &KeyValueRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetAll() ([]*models.KeyStringValue, error) {
|
||||
var keyValues []*models.KeyStringValue
|
||||
if err := r.db.Find(&keyValues).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyValues, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := r.db.
|
||||
@ -40,10 +47,6 @@ func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
logbuch.Warn("did not insert key '%s', maybe just updated?", kv.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
|
||||
return &LanguageMappingRepository{config: config.Get(), db: db}
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetAll() ([]*models.LanguageMapping, error) {
|
||||
var mappings []*models.LanguageMapping
|
||||
if err := r.db.Find(&mappings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
mapping := &models.LanguageMapping{}
|
||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||
|
@ -9,6 +9,7 @@ type IAliasRepository interface {
|
||||
Insert(*models.Alias) (*models.Alias, error)
|
||||
Delete(uint) error
|
||||
DeleteBatch([]uint) error
|
||||
GetAll() ([]*models.Alias, error)
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKey(string, string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
@ -17,6 +18,7 @@ type IAliasRepository interface {
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAll() ([]*models.Heartbeat, error)
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLastByUsers() ([]*models.TimeByUser, error)
|
||||
@ -28,12 +30,14 @@ type IHeartbeatRepository interface {
|
||||
}
|
||||
|
||||
type IKeyValueRepository interface {
|
||||
GetAll() ([]*models.KeyStringValue, error)
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
GetAll() ([]*models.LanguageMapping, error)
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
@ -42,6 +46,7 @@ type ILanguageMappingRepository interface {
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetAll() ([]*models.Summary, error)
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
@ -51,6 +56,8 @@ type IUserRepository interface {
|
||||
GetById(string) (*models.User, error)
|
||||
GetByIds([]string) ([]*models.User, error)
|
||||
GetByApiKey(string) (*models.User, error)
|
||||
GetByEmail(string) (*models.User, error)
|
||||
GetByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetByLoggedInAfter(time.Time) ([]*models.User, error)
|
||||
GetByLastActiveAfter(time.Time) ([]*models.User, error)
|
||||
|
@ -14,6 +14,21 @@ func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
|
||||
return &SummaryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) Insert(summary *models.Summary) error {
|
||||
if err := r.db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
@ -25,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Where("from_time >= ?", from.Local()).
|
||||
Where("to_time <= ?", to.Local()).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
|
@ -42,6 +42,28 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
|
||||
if resetToken == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
if email == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
@ -55,7 +77,7 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Where("last_logged_in_at >= ?", t).
|
||||
Where("last_logged_in_at >= ?", t.Local()).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -74,7 +96,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro
|
||||
if err := r.db.
|
||||
Select("user as id").
|
||||
Table("(?) as q", subQuery1).
|
||||
Where("time >= ?", t).
|
||||
Where("time >= ?", t.Local()).
|
||||
Scan(&userIds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -119,6 +141,8 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"share_machines": user.ShareMachines,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
|
@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
@ -83,7 +82,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
logbuch.Error("failed to batch-insert heartbeats – %v", err)
|
||||
conf.Log().Request(r).Error("failed to batch-insert heartbeats – %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -92,12 +91,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
logbuch.Error("failed to update user – %v", err)
|
||||
conf.Log().Request(r).Error("failed to update user – %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
|
@ -78,6 +78,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
var metrics mm.Metrics
|
||||
|
||||
if userMetrics, err := h.getUserMetrics(reqUser); err != nil {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
return
|
||||
@ -89,6 +90,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if reqUser.IsAdmin {
|
||||
if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
return
|
||||
@ -114,7 +116,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
from, to := utils.MustResolveIntervalRaw("today")
|
||||
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
|
||||
|
||||
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
|
||||
if err != nil {
|
||||
|
@ -51,5 +51,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusOK, summary)
|
||||
utils.RespondJSON(w, r, http.StatusOK, summary)
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||
@ -101,7 +101,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||
utils.RespondJSON(w, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
return
|
||||
}
|
||||
|
||||
@ -114,11 +114,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
h.cache.SetDefault(cacheKey, vm)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
|
@ -62,7 +62,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
rangeParam = (*models.IntervalPast7Days)[0]
|
||||
}
|
||||
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam)
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid range"))
|
||||
@ -103,7 +103,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
stats.Data.Machines = nil
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusOK, stats)
|
||||
utils.RespondJSON(w, r, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
||||
|
@ -76,7 +76,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
@ -87,24 +87,24 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
var start, end time.Time
|
||||
if rangeParam != "" {
|
||||
// range param takes precedence
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil {
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
||||
}
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam {
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
|
||||
// also accept start param to be a range param
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
// eventually, consider start and end params a date
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startParam, " ", "+", 1))
|
||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), user.TZ())
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endParam, " ", "+", 1))
|
||||
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), user.TZ())
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ type HomeHandler struct {
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
var resetPasswordDecoder = schema.NewDecoder()
|
||||
|
||||
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
|
||||
return &HomeHandler{
|
||||
|
132
routes/login.go
132
routes/login.go
@ -2,6 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -9,18 +10,21 @@ import (
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
mailSrvc services.IMailService
|
||||
}
|
||||
|
||||
func NewLoginHandler(userService services.IUserService) *LoginHandler {
|
||||
func NewLoginHandler(userService services.IUserService, mailService services.IMailService) *LoginHandler {
|
||||
return &LoginHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
mailSrvc: mailService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +34,10 @@ func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
|
||||
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
|
||||
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
|
||||
router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword)
|
||||
router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword)
|
||||
router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword)
|
||||
router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@ -167,6 +175,128 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
token := values.Get("token")
|
||||
if token == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token"))
|
||||
return
|
||||
}
|
||||
|
||||
vm := &view.SetPasswordViewModel{
|
||||
LoginViewModel: *h.buildViewModel(r),
|
||||
Token: token,
|
||||
}
|
||||
|
||||
templates[conf.SetPasswordTemplate].Execute(w, vm)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
var setRequest models.SetPasswordRequest
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token"))
|
||||
return
|
||||
}
|
||||
|
||||
if !setRequest.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = setRequest.Password
|
||||
user.ResetToken = ""
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if !h.config.Mail.Enabled {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server"))
|
||||
return
|
||||
}
|
||||
|
||||
var resetRequest models.ResetPasswordRequest
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil {
|
||||
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
|
||||
return
|
||||
} else {
|
||||
go func(user *models.User) {
|
||||
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
||||
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
|
||||
conf.Log().Request(r).Error("failed to send password reset mail to %s – %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("sent password reset mail to %s", user.ID)
|
||||
}
|
||||
}(u)
|
||||
}
|
||||
} else {
|
||||
conf.Log().Request(r).Warn("password reset requested for unregistered address '%s'", resetRequest.Email)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
numUsers, _ := h.userSrvc.Count()
|
||||
|
||||
|
@ -2,15 +2,17 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/muety/wakapi/views"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@ -22,12 +24,13 @@ type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
|
||||
var templates map[string]*template.Template
|
||||
|
||||
func loadTemplates() {
|
||||
const tplPath = "/views"
|
||||
tpls := template.New("").Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"simpledate": utils.FormatDate,
|
||||
"simpledatetime": utils.FormatDateTime,
|
||||
"floordate": utils.FloorDate,
|
||||
"ceildate": utils.CeilDate,
|
||||
"title": strings.Title,
|
||||
"join": strings.Join,
|
||||
"add": utils.Add,
|
||||
@ -53,12 +56,10 @@ func loadTemplates() {
|
||||
})
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
dir, err := pkger.Open(tplPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer dir.Close()
|
||||
files, err := dir.Readdir(0)
|
||||
// Use local file system when in 'dev' environment, go embed file system otherwise
|
||||
templateFs := config.ChooseFS("views", views.TemplateFiles)
|
||||
|
||||
files, err := fs.ReadDir(templateFs, ".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -69,7 +70,7 @@ func loadTemplates() {
|
||||
continue
|
||||
}
|
||||
|
||||
templateFile, err := pkger.Open(fmt.Sprintf("%s/%s", tplPath, tplName))
|
||||
templateFile, err := templateFs.Open(tplName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ type SettingsHandler struct {
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
keyValueSrvc services.IKeyValueService
|
||||
mailSrvc services.IMailService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@ -40,6 +41,7 @@ func NewSettingsHandler(
|
||||
aggregationService services.IAggregationService,
|
||||
languageMappingService services.ILanguageMappingService,
|
||||
keyValueService services.IKeyValueService,
|
||||
mailService services.IMailService,
|
||||
) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: conf.Get(),
|
||||
@ -50,6 +52,7 @@ func NewSettingsHandler(
|
||||
userSrvc: userService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
keyValueSrvc: keyValueService,
|
||||
mailSrvc: mailService,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
@ -163,6 +166,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
user.Email = payload.Email
|
||||
user.Location = payload.Location
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||
@ -401,6 +405,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
go func(user *models.User) {
|
||||
start := time.Now()
|
||||
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
|
||||
|
||||
countBefore, err := h.heartbeatSrvc.CountByUser(user)
|
||||
@ -419,23 +424,38 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
|
||||
count := 0
|
||||
batch := make([]*models.Heartbeat, 0)
|
||||
|
||||
insert := func(batch []*models.Heartbeat) {
|
||||
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||
logbuch.Warn("failed to insert imported heartbeat, already existing? – %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for hb := range stream {
|
||||
count++
|
||||
batch = append(batch, hb)
|
||||
|
||||
if len(batch) == h.config.App.ImportBatchSize {
|
||||
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||
logbuch.Warn("failed to insert imported heartbeat, already existing? – %v", err)
|
||||
}
|
||||
|
||||
insert(batch)
|
||||
batch = make([]*models.Heartbeat, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(batch) > 0 {
|
||||
insert(batch)
|
||||
}
|
||||
|
||||
countAfter, _ := h.heartbeatSrvc.CountByUser(user)
|
||||
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
|
||||
|
||||
h.regenerateSummaries(user)
|
||||
|
||||
if user.Email != "" {
|
||||
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
|
||||
conf.Log().Request(r).Error("failed to send import notification mail to %s – %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("sent import notification mail to %s", user.ID)
|
||||
}
|
||||
}
|
||||
}(user)
|
||||
|
||||
h.keyValueSrvc.PutString(&models.KeyStringValue{
|
||||
@ -443,7 +463,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
|
||||
Value: time.Now().Format(time.RFC822),
|
||||
})
|
||||
|
||||
return http.StatusAccepted, "ImportAll started. This may take a few minutes.", ""
|
||||
return http.StatusAccepted, "Import started. This will take several minutes. Please check back later.", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
@ -453,7 +473,7 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
||||
|
||||
go func(user *models.User) {
|
||||
if err := h.regenerateSummaries(user); err != nil {
|
||||
logbuch.Error("failed to regenerate summaries for user '%s' – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' – %v", user.ID, err)
|
||||
}
|
||||
}(middlewares.GetPrincipal(r))
|
||||
|
||||
@ -470,7 +490,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
|
||||
logbuch.Info("deleting user '%s' shortly", user.ID)
|
||||
time.Sleep(5 * time.Minute)
|
||||
if err := h.userSrvc.Delete(user); err != nil {
|
||||
logbuch.Error("failed to delete user '%s' – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to delete user '%s' – %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully deleted user '%s'", user.ID)
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
summaryParams, _ := utils.ParseSummaryParams(r)
|
||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
@ -62,6 +63,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vm := models.SummaryViewModel{
|
||||
Summary: summary,
|
||||
SummaryParams: summaryParams,
|
||||
User: user,
|
||||
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||
|
@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
|
||||
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
80
scripts/bundle_icons.js
Executable file
80
scripts/bundle_icons.js
Executable file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict'
|
||||
|
||||
// Usage:
|
||||
// yarn add -D @iconify/json-tools @iconify/json
|
||||
// node bundle_icons.js
|
||||
// https://iconify.design/docs/icon-bundles/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { Collection } = require('@iconify/json-tools')
|
||||
|
||||
let icons = [
|
||||
'fxemoji:key',
|
||||
'fxemoji:rocket',
|
||||
'fxemoji:satelliteantenna',
|
||||
'fxemoji:lockandkey',
|
||||
'fxemoji:clipboard',
|
||||
'flat-color-icons:donate',
|
||||
'flat-color-icons:clock',
|
||||
'codicon:github-inverted',
|
||||
'ant-design:check-square-filled',
|
||||
'emojione-v1:white-heavy-check-mark',
|
||||
'emojione-v1:alarm-clock',
|
||||
'emojione-v1:warning',
|
||||
'emojione-v1:backhand-index-pointing-right',
|
||||
'twemoji:light-bulb',
|
||||
'noto:play-button',
|
||||
'noto:stop-button',
|
||||
'noto:lock',
|
||||
'twemoji:gear',
|
||||
'eva:corner-right-down-fill',
|
||||
'bi:heart-fill',
|
||||
]
|
||||
|
||||
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))
|
||||
const pretty = false
|
||||
|
||||
// Sort icons by collections: filtered[prefix][array of icons]
|
||||
let filtered = {}
|
||||
icons.forEach(icon => {
|
||||
let parts = icon.split(':'),
|
||||
prefix
|
||||
|
||||
if (parts.length > 1) {
|
||||
prefix = parts.shift()
|
||||
icon = parts.join(':')
|
||||
} else {
|
||||
parts = icon.split('-')
|
||||
prefix = parts.shift()
|
||||
icon = parts.join('-')
|
||||
}
|
||||
if (filtered[prefix] === void 0) {
|
||||
filtered[prefix] = []
|
||||
}
|
||||
if (filtered[prefix].indexOf(icon) === -1) {
|
||||
filtered[prefix].push(icon)
|
||||
}
|
||||
})
|
||||
|
||||
// Parse each collection
|
||||
let code = ''
|
||||
Object.keys(filtered).forEach(prefix => {
|
||||
let collection = new Collection()
|
||||
if (!collection.loadIconifyCollection(prefix)) {
|
||||
console.error('Error loading collection', prefix)
|
||||
return
|
||||
}
|
||||
|
||||
code += collection.scriptify({
|
||||
icons: filtered[prefix],
|
||||
optimize: true,
|
||||
pretty: pretty
|
||||
})
|
||||
})
|
||||
|
||||
// Save code
|
||||
fs.writeFileSync(output, code, 'utf8')
|
||||
console.log('Saved bundle to', output, ' (' + code.length + ' bytes)')
|
12
scripts/config.yml
Normal file
12
scripts/config.yml
Normal file
@ -0,0 +1,12 @@
|
||||
# SQLite
|
||||
source:
|
||||
name: ../wakapi_db.db
|
||||
|
||||
# MySQL
|
||||
target:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: wakapi_user
|
||||
password: wakapi
|
||||
name: wakapi_local
|
||||
dialect:
|
@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DB=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
86
scripts/get.sh
Normal file
86
scripts/get.sh
Normal file
@ -0,0 +1,86 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This script installs Wakapi.
|
||||
#
|
||||
# Quick install: `curl https://wakapi.dev/get | bash`
|
||||
#
|
||||
# This script will install Wakapi to the directory you're in. To install
|
||||
# somewhere else (e.g. /usr/local/bin), cd there and make sure you can write to
|
||||
# that directory, e.g. `cd /usr/local/bin; curl https://wakapi.dev/get | sudo bash`
|
||||
#
|
||||
# Acknowledgments:
|
||||
# - Micro Editor for this script: https://micro-editor.github.io/
|
||||
# - ASCII art courtesy of figlet: http://www.figlet.org/
|
||||
|
||||
set -e -u
|
||||
|
||||
githubLatestTag() {
|
||||
finalUrl=$(curl "https://github.com/$1/releases/latest" -s -L -I -o /dev/null -w '%{url_effective}')
|
||||
printf "%s\n" "${finalUrl##*/}"
|
||||
}
|
||||
|
||||
platform=''
|
||||
machine=$(uname -m) # currently, Wakapi builds are only available for AMD64 anyway
|
||||
|
||||
if [ "${GETWAKAPI_PLATFORM:-x}" != "x" ]; then
|
||||
platform="$GETWAKAPI_PLATFORM"
|
||||
else
|
||||
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
|
||||
"linux") platform='linux_amd64' ;;
|
||||
"msys"*|"cygwin"*|"mingw"*|*"_nt"*|"win"*) platform='win_amd64' ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "x$platform" = "x" ]; then
|
||||
cat << 'EOM'
|
||||
/=====================================\\
|
||||
| COULD NOT DETECT PLATFORM |
|
||||
\\=====================================/
|
||||
|
||||
Uh oh! We couldn't automatically detect your operating system. You can file a
|
||||
bug here: https://github.com/muety/wakapi
|
||||
EOM
|
||||
exit 1
|
||||
else
|
||||
printf "Detected platform: %s\n" "$platform"
|
||||
fi
|
||||
|
||||
TAG=$(githubLatestTag muety/wakapi)
|
||||
|
||||
printf "Tag: %s" "$TAG"
|
||||
|
||||
extension='zip'
|
||||
|
||||
printf "Latest Version: %s\n" "$TAG"
|
||||
printf "Downloading https://github.com/muety/wakapi/releases/download/%s/wakapi_%s.%s\n" "$TAG" "$platform" "$extension"
|
||||
|
||||
curl -L "https://github.com/muety/wakapi/releases/download/$TAG/wakapi_$platform.$extension" > "wakapi.$extension"
|
||||
|
||||
case "$extension" in
|
||||
"zip") unzip -j "wakapi.$extension" -d "wakapi-$TAG" ;;
|
||||
"tar.gz") tar -xvzf "wakapi.$extension" "wakapi-$TAG/wakapi" ;;
|
||||
esac
|
||||
|
||||
mv "wakapi-$TAG/wakapi" ./wakapi
|
||||
mv "wakapi-$TAG/config.yml" ./config.yml
|
||||
|
||||
rm "wakapi.$extension"
|
||||
rm -rf "wakapi-$TAG"
|
||||
|
||||
cat <<-'EOM'
|
||||
|
||||
__ __ _ _
|
||||
\ \ / /_ _| | ____ _ _ __ (_)
|
||||
\ \ /\ / / _` | |/ / _` | '_ \| |
|
||||
\ V V / (_| | < (_| | |_) | |
|
||||
\_/\_/ \__,_|_|\_\__,_| .__/|_|
|
||||
|_|
|
||||
|
||||
Wakapi has been downloaded to the current directory.
|
||||
You can run it with:
|
||||
|
||||
./wakapi
|
||||
|
||||
For further instructions see https://github.com/muety/wakapi
|
||||
|
||||
EOM
|
@ -9,7 +9,6 @@ from datetime import datetime, timedelta
|
||||
from typing import List, Union, Callable
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
MACHINE = "devmachine"
|
||||
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'
|
||||
@ -17,7 +16,10 @@ LANGUAGES = {
|
||||
'Go': 'go',
|
||||
'Java': 'java',
|
||||
'JavaScript': 'js',
|
||||
'Python': 'py'
|
||||
'Python': 'py',
|
||||
# https://github.com/muety/wakapi/issues/172
|
||||
'PHP': 'php',
|
||||
'Blade': 'blade.php'
|
||||
}
|
||||
|
||||
|
||||
@ -50,6 +52,7 @@ class ConfigParams:
|
||||
self.n_projects = 0
|
||||
self.offset = 0
|
||||
self.seed = 0
|
||||
self.batch = False
|
||||
|
||||
|
||||
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
|
||||
@ -83,21 +86,21 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
|
||||
def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
|
||||
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
|
||||
|
||||
for h in data:
|
||||
r = requests.post(url, json=[h.__dict__], headers={
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}',
|
||||
'X-Machine-Name': MACHINE,
|
||||
})
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
r = requests.post(url, json=[h.__dict__ for h in data], headers={
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}',
|
||||
'X-Machine-Name': MACHINE,
|
||||
})
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) -> ('QApplication', 'QWidget'):
|
||||
# https://doc.qt.io/qt-5/qtwidgets-module.html
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \
|
||||
QLineEdit, QSpinBox, QProgressBar, QPushButton
|
||||
QLineEdit, QSpinBox, QProgressBar, QPushButton, QCheckBox
|
||||
|
||||
# Main app
|
||||
app = QApplication([])
|
||||
@ -150,10 +153,14 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
|
||||
seed_input.setMaximum(2147483647)
|
||||
seed_input.setValue(1337)
|
||||
|
||||
batch_checkbox = QCheckBox('Batch Mode')
|
||||
batch_checkbox.setTristate(False)
|
||||
|
||||
form_layout_2.addRow(heartbeats_input_label, heartbeats_input)
|
||||
form_layout_2.addRow(projects_input_label, projects_input)
|
||||
form_layout_2.addRow(offset_input_label, offset_input)
|
||||
form_layout_2.addRow(seed_input_label, seed_input)
|
||||
form_layout_2.addRow(batch_checkbox)
|
||||
|
||||
# Bottom controls
|
||||
bottom_layout = QHBoxLayout()
|
||||
@ -192,6 +199,7 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
|
||||
params.n_projects = projects_input.value()
|
||||
params.offset = offset_input.value()
|
||||
params.seed = seed_input.value()
|
||||
params.batch = batch_checkbox.isChecked()
|
||||
return params
|
||||
|
||||
def update_progress(inc=1):
|
||||
@ -228,6 +236,7 @@ def parse_arguments():
|
||||
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')
|
||||
parser.add_argument('-b', '--batch', default=False, help='batch mode (push all heartbeats at once)', action='store_true')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@ -239,6 +248,7 @@ def args_to_params(parsed_args: argparse.Namespace) -> (ConfigParams, bool):
|
||||
params.seed = parsed_args.seed
|
||||
params.api_url = parsed_args.url
|
||||
params.api_key = parsed_args.apikey
|
||||
params.batch = parsed_args.batch
|
||||
return params, not parsed_args.headless
|
||||
|
||||
|
||||
@ -255,9 +265,14 @@ def run(params: ConfigParams, update_progress: Callable[[int], None]):
|
||||
params.offset * -1 if params.offset < 0 else params.offset
|
||||
)
|
||||
|
||||
for d in data:
|
||||
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key)
|
||||
update_progress(1)
|
||||
# batch-mode won't work when using sqlite backend
|
||||
if params.batch:
|
||||
post_data_sync(data, f'{params.api_url}/heartbeats', params.api_key)
|
||||
update_progress(len(data))
|
||||
else:
|
||||
for d in data:
|
||||
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key)
|
||||
update_progress(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@ -267,5 +282,7 @@ if __name__ == '__main__':
|
||||
window.show()
|
||||
app.exec()
|
||||
else:
|
||||
from tqdm import tqdm
|
||||
|
||||
pbar = tqdm(total=params.n)
|
||||
run(params, pbar.update)
|
||||
|
276
scripts/sqlite2mysql.go
Normal file
276
scripts/sqlite2mysql.go
Normal file
@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
A script to migrate Wakapi data from SQLite to MySQL or Postgres.
|
||||
|
||||
Usage:
|
||||
---
|
||||
1. Set up an empty MySQL or Postgres database (see docker_[mysql|postgres].sh for example)
|
||||
2. Create a migration config file (e.g. config.yml) as shown below
|
||||
3. go run sqlite2mysql.go -config config.yml
|
||||
|
||||
Example: config.yml
|
||||
---
|
||||
source:
|
||||
name: ../wakapi_db.db
|
||||
|
||||
# MySQL / Postgres
|
||||
target:
|
||||
host:
|
||||
port:
|
||||
user:
|
||||
password:
|
||||
name:
|
||||
dialect:
|
||||
|
||||
Troubleshooting:
|
||||
---
|
||||
- Check https://wiki.postgresql.org/wiki/Fixing_Sequences in case of errors with Postgres
|
||||
- Check https://github.com/muety/wakapi/pull/181#issue-621585477 on further details about Postgres migration
|
||||
*/
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Source dbConfig // sqlite
|
||||
Target dbConfig // mysql / postgres
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
Host string
|
||||
Port uint
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
Dialect string `default:"mysql"`
|
||||
}
|
||||
|
||||
const InsertBatchSize = 100
|
||||
|
||||
var cfg *config
|
||||
var dbSource, dbTarget *gorm.DB
|
||||
var cFlag *string
|
||||
|
||||
func init() {
|
||||
cfg = &config{}
|
||||
|
||||
if f := flag.Lookup("config"); f == nil {
|
||||
cFlag = flag.String("config", "sqlite2mysql.yml", "config file location")
|
||||
} else {
|
||||
ff := f.Value.(flag.Getter).Get().(string)
|
||||
cFlag = &ff
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
if err := configor.New(&configor.Config{}).Load(cfg, mustConfigPath()); err != nil {
|
||||
log.Fatalln("failed to read config", err)
|
||||
}
|
||||
|
||||
log.Println("attempting to open sqlite database as source")
|
||||
if db, err := gorm.Open(sqlite.Open(cfg.Source.Name), &gorm.Config{}); err != nil {
|
||||
log.Fatalln(err)
|
||||
} else {
|
||||
dbSource = db
|
||||
}
|
||||
|
||||
if cfg.Target.Dialect == "postgres" {
|
||||
log.Println("attempting to open postgresql database as target")
|
||||
if db, err := gorm.Open(postgres.Open(fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s sslmode=disable timezone=Europe/Berlin",
|
||||
cfg.Target.User,
|
||||
cfg.Target.Password,
|
||||
cfg.Target.Host,
|
||||
cfg.Target.Port,
|
||||
cfg.Target.Name,
|
||||
)), &gorm.Config{}); err != nil {
|
||||
log.Fatalln(err)
|
||||
} else {
|
||||
dbTarget = db
|
||||
}
|
||||
} else {
|
||||
log.Println("attempting to open mysql database as target")
|
||||
if db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
DriverName: "mysql",
|
||||
DSN: fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
cfg.Target.User,
|
||||
cfg.Target.Password,
|
||||
cfg.Target.Host,
|
||||
cfg.Target.Port,
|
||||
cfg.Target.Name,
|
||||
"utf8mb4",
|
||||
"Local",
|
||||
),
|
||||
}), &gorm.Config{}); err != nil {
|
||||
log.Fatalln(err)
|
||||
} else {
|
||||
dbTarget = db
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func destroy() {
|
||||
if db, _ := dbSource.DB(); db != nil {
|
||||
db.Close()
|
||||
}
|
||||
if db, _ := dbTarget.DB(); db != nil {
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer destroy()
|
||||
if err := createSchema(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
keyValueSource := repositories.NewKeyValueRepository(dbSource)
|
||||
keyValueTarget := repositories.NewKeyValueRepository(dbTarget)
|
||||
|
||||
userSource := repositories.NewUserRepository(dbSource)
|
||||
userTarget := repositories.NewUserRepository(dbTarget)
|
||||
|
||||
languageMappingSource := repositories.NewLanguageMappingRepository(dbSource)
|
||||
languageMappingTarget := repositories.NewLanguageMappingRepository(dbTarget)
|
||||
|
||||
aliasSource := repositories.NewAliasRepository(dbSource)
|
||||
aliasTarget := repositories.NewAliasRepository(dbTarget)
|
||||
|
||||
summarySource := repositories.NewSummaryRepository(dbSource)
|
||||
summaryTarget := repositories.NewSummaryRepository(dbTarget)
|
||||
|
||||
heartbeatSource := repositories.NewHeartbeatRepository(dbSource)
|
||||
heartbeatTarget := repositories.NewHeartbeatRepository(dbTarget)
|
||||
|
||||
// TODO: things could be optimized through batch-inserts / inserts within a single transaction
|
||||
|
||||
log.Println("Migrating key-value pairs ...")
|
||||
if data, err := keyValueSource.GetAll(); err == nil {
|
||||
for _, e := range data {
|
||||
if err := keyValueTarget.PutString(e); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
log.Println("Migrating users ...")
|
||||
if data, err := userSource.GetAll(); err == nil {
|
||||
for _, e := range data {
|
||||
if _, _, err := userTarget.InsertOrGet(e); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
log.Println("Migrating language mappings ...")
|
||||
if data, err := languageMappingSource.GetAll(); err == nil {
|
||||
for _, e := range data {
|
||||
e.ID = 0
|
||||
if _, err := languageMappingTarget.Insert(e); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
log.Println("Migrating aliases ...")
|
||||
if data, err := aliasSource.GetAll(); err == nil {
|
||||
for _, e := range data {
|
||||
e.ID = 0
|
||||
if _, err := aliasTarget.Insert(e); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
log.Println("Migrating summaries ...")
|
||||
if data, err := summarySource.GetAll(); err == nil {
|
||||
for _, e := range data {
|
||||
e.ID = 0
|
||||
if err := summaryTarget.Insert(e); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
// TODO: copy in mini-batches instead of loading all heartbeats into memory (potentially millions)
|
||||
|
||||
log.Println("Migrating heartbeats ...")
|
||||
|
||||
if data, err := heartbeatSource.GetAll(); err == nil {
|
||||
log.Printf("Got %d heartbeats loaded into memory. Batch-inserting them now ...\n", len(data))
|
||||
|
||||
var slice = make([]*models.Heartbeat, len(data))
|
||||
for i, heartbeat := range data {
|
||||
heartbeat = heartbeat.Hashed()
|
||||
slice[i] = heartbeat
|
||||
}
|
||||
|
||||
left, right, size := 0, InsertBatchSize, len(slice)
|
||||
for right < size {
|
||||
log.Printf("Inserting batch from %d", left)
|
||||
if err := heartbeatTarget.InsertBatch(slice[left:right]); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
left += InsertBatchSize
|
||||
right += InsertBatchSize
|
||||
}
|
||||
if err := heartbeatTarget.InsertBatch(slice[left:]); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
} else {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createSchema() error {
|
||||
if err := dbTarget.AutoMigrate(&models.User{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.KeyStringValue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.Alias{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.Heartbeat{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.Summary{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.SummaryItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dbTarget.AutoMigrate(&models.LanguageMapping{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustConfigPath() string {
|
||||
if _, err := os.Stat(*cFlag); err != nil {
|
||||
log.Fatalln("failed to find config file at", *cFlag)
|
||||
}
|
||||
return *cFlag
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
@ -14,11 +16,14 @@ const (
|
||||
aggregateIntervalDays int = 1
|
||||
)
|
||||
|
||||
var lock = sync.Mutex{}
|
||||
|
||||
type AggregationService struct {
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
inProgress map[string]bool
|
||||
}
|
||||
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
@ -27,6 +32,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
inProgress: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +55,11 @@ func (srv *AggregationService) Schedule() {
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
if err := srv.lockUsers(userIds); err != nil {
|
||||
return err
|
||||
}
|
||||
defer srv.unlockUsers(userIds)
|
||||
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
|
||||
@ -73,7 +84,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
|
||||
logbuch.Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err)
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
summaries <- summary
|
||||
@ -84,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
logbuch.Error("failed to save summary (%v, %v, %s) – %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) – %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,7 +105,7 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
|
||||
var users []*models.User
|
||||
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
config.Log().Error(err.Error())
|
||||
return err
|
||||
} else if userIds != nil && len(userIds) > 0 {
|
||||
users = make([]*models.User, 0)
|
||||
@ -110,14 +121,14 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
config.Log().Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||
if err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
config.Log().Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@ -145,11 +156,33 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) lockUsers(userIds map[string]bool) error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
for uid := range userIds {
|
||||
if _, ok := srv.inProgress[uid]; ok {
|
||||
return errors.New("aggregation already in progress for at least of the request users")
|
||||
}
|
||||
}
|
||||
for uid := range userIds {
|
||||
srv.inProgress[uid] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) unlockUsers(userIds map[string]bool) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
for uid := range userIds {
|
||||
delete(srv.inProgress, uid)
|
||||
}
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
var to time.Time
|
||||
|
||||
// Go to next day of either user's first heartbeat or latest aggregation
|
||||
from.Add(-1 * time.Second)
|
||||
from = from.Add(-1 * time.Second)
|
||||
from = time.Date(
|
||||
from.Year(),
|
||||
from.Month(),
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
@ -17,7 +18,13 @@ import (
|
||||
)
|
||||
|
||||
const OriginWakatime = "wakatime"
|
||||
const maxWorkers = 6
|
||||
const (
|
||||
// wakatime api permits a max. rate of 10 req / sec
|
||||
// https://github.com/wakatime/wakatime/issues/261
|
||||
// with 5 workers, each sleeping slightly over 1/2 sec after every req., we should stay well below that limit
|
||||
maxWorkers = 5
|
||||
throttleDelay = 550 * time.Millisecond
|
||||
)
|
||||
|
||||
type WakatimeHeartbeatImporter struct {
|
||||
ApiKey string
|
||||
@ -35,7 +42,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
go func(user *models.User, out chan *models.Heartbeat) {
|
||||
startDate, endDate, err := w.fetchRange()
|
||||
if err != nil {
|
||||
logbuch.Error("failed to fetch date range while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -48,13 +55,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
userAgents, err := w.fetchUserAgents()
|
||||
if err != nil {
|
||||
logbuch.Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
machinesNames, err := w.fetchMachineNames()
|
||||
if err != nil {
|
||||
logbuch.Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -72,11 +79,12 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
go func(day time.Time) {
|
||||
defer sem.Release(1)
|
||||
defer time.Sleep(throttleDelay)
|
||||
|
||||
d := day.Format(config.SimpleDateFormat)
|
||||
heartbeats, err := w.fetchHeartbeats(d)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' – &v", day, user.ID, err)
|
||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' – &v", d, user.ID, err)
|
||||
}
|
||||
|
||||
for _, h := range heartbeats {
|
||||
@ -113,6 +121,8 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.Hea
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if res.StatusCode >= 400 {
|
||||
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
||||
}
|
||||
|
||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||
|
81
services/mail/mail.go
Normal file
81
services/mail/mail.go
Normal file
@ -0,0 +1,81 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"text/template"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/views"
|
||||
)
|
||||
|
||||
const (
|
||||
tplNamePasswordReset = "reset_password"
|
||||
tplNameImportNotification = "import_finished"
|
||||
subjectPasswordReset = "Wakapi – Password Reset"
|
||||
subjectImportNotification = "Wakapi – Data Import Finished"
|
||||
)
|
||||
|
||||
type PasswordResetTplData struct {
|
||||
ResetLink string
|
||||
}
|
||||
|
||||
type ImportNotificationTplData struct {
|
||||
PublicUrl string
|
||||
Duration string
|
||||
NumHeartbeats int
|
||||
}
|
||||
|
||||
// Factory
|
||||
func NewMailService() services.IMailService {
|
||||
config := conf.Get()
|
||||
if config.Mail.Enabled {
|
||||
if config.Mail.Provider == conf.MailProviderMailWhale {
|
||||
return NewMailWhaleService(config.Mail.MailWhale, config.Server.PublicUrl)
|
||||
} else if config.Mail.Provider == conf.MailProviderSmtp {
|
||||
return NewSMTPMailService(config.Mail.Smtp, config.Server.PublicUrl)
|
||||
}
|
||||
}
|
||||
return &NoopMailService{}
|
||||
}
|
||||
|
||||
func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
|
||||
tpl, err := loadTemplate(tplNamePasswordReset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := tpl.Execute(&rendered, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rendered, nil
|
||||
}
|
||||
|
||||
func getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
|
||||
tpl, err := loadTemplate(tplNameImportNotification)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := tpl.Execute(&rendered, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rendered, nil
|
||||
}
|
||||
|
||||
func loadTemplate(tplName string) (*template.Template, error) {
|
||||
tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tplFile.Close()
|
||||
|
||||
tplData, err := ioutil.ReadAll(tplFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return template.New(tplName).Parse(string(tplData))
|
||||
}
|
92
services/mail/mailwhale.go
Normal file
92
services/mail/mailwhale.go
Normal file
@ -0,0 +1,92 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MailWhaleMailService struct {
|
||||
publicUrl string
|
||||
config conf.MailwhaleMailConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type MailWhaleSendRequest struct {
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
Html string `json:"html"`
|
||||
TemplateId string `json:"template_id"`
|
||||
TemplateVars map[string]string `json:"template_vars"`
|
||||
}
|
||||
|
||||
func NewMailWhaleService(config conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService {
|
||||
return &MailWhaleMailService{
|
||||
publicUrl: publicUrl,
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error {
|
||||
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.send(recipient.Email, subjectPasswordReset, template.String(), true)
|
||||
}
|
||||
|
||||
func (s *MailWhaleMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
|
||||
template, err := getImportNotificationTemplate(ImportNotificationTplData{
|
||||
PublicUrl: s.publicUrl,
|
||||
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
|
||||
NumHeartbeats: numHeartbeats,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.send(recipient.Email, subjectImportNotification, template.String(), true)
|
||||
}
|
||||
|
||||
func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error {
|
||||
if to == "" {
|
||||
return errors.New("not sending mail as recipient mail address seems to be invalid")
|
||||
}
|
||||
|
||||
sendRequest := &MailWhaleSendRequest{
|
||||
To: []string{to},
|
||||
Subject: subject,
|
||||
}
|
||||
if isHtml {
|
||||
sendRequest.Html = body
|
||||
} else {
|
||||
sendRequest.Text = body
|
||||
}
|
||||
payload, _ := json.Marshal(sendRequest)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/mail", s.config.Url), bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
19
services/mail/noop.go
Normal file
19
services/mail/noop.go
Normal file
@ -0,0 +1,19 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NoopMailService struct{}
|
||||
|
||||
func (n *NoopMailService) SendPasswordReset(recipient *models.User, resetLink string) error {
|
||||
logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NoopMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
|
||||
logbuch.Info("noop mail service doing nothing instead of sending import notification mail to %s", recipient.ID)
|
||||
return nil
|
||||
}
|
117
services/mail/smtp.go
Normal file
117
services/mail/smtp.go
Normal file
@ -0,0 +1,117 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMTPMailService struct {
|
||||
publicUrl string
|
||||
config conf.SMTPMailConfig
|
||||
auth sasl.Client
|
||||
}
|
||||
|
||||
func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailService {
|
||||
return &SMTPMailService{
|
||||
publicUrl: publicUrl,
|
||||
config: config,
|
||||
auth: sasl.NewPlainClient(
|
||||
"",
|
||||
config.Username,
|
||||
config.Password,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error {
|
||||
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mail := &models.Mail{
|
||||
From: models.MailAddress(s.config.Sender),
|
||||
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
|
||||
Subject: subjectPasswordReset,
|
||||
}
|
||||
mail.WithHTML(template.String())
|
||||
|
||||
return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader())
|
||||
}
|
||||
|
||||
func (s *SMTPMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
|
||||
template, err := getImportNotificationTemplate(ImportNotificationTplData{
|
||||
PublicUrl: s.publicUrl,
|
||||
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
|
||||
NumHeartbeats: numHeartbeats,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mail := &models.Mail{
|
||||
From: models.MailAddress(s.config.Sender),
|
||||
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
|
||||
Subject: subjectImportNotification,
|
||||
}
|
||||
mail.WithHTML(template.String())
|
||||
|
||||
return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader())
|
||||
}
|
||||
|
||||
func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string, to []string, r io.Reader) error {
|
||||
dial := smtp.Dial
|
||||
if tls {
|
||||
dial = func(addr string) (*smtp.Client, error) {
|
||||
return smtp.DialTLS(addr, nil)
|
||||
}
|
||||
}
|
||||
|
||||
c, err := dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err = c.StartTLS(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a != nil {
|
||||
if ok, _ := c.Extension("AUTH"); !ok {
|
||||
return errors.New("smtp: server doesn't support AUTH")
|
||||
}
|
||||
if err = c.Auth(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = c.Mail(from, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
1
services/mail/utils.go
Normal file
1
services/mail/utils.go
Normal file
@ -0,0 +1 @@
|
||||
package mail
|
@ -42,7 +42,7 @@ type CountTotalTimeResult struct {
|
||||
func (srv *MiscService) ScheduleCountTotalTime() {
|
||||
// Run once initially
|
||||
if err := srv.runCountTotalTime(); err != nil {
|
||||
logbuch.Error("failed to run CountTotalTimeJob: %v", err)
|
||||
logbuch.Fatal("failed to run CountTotalTimeJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
@ -80,7 +80,7 @@ func (srv *MiscService) runCountTotalTime() error {
|
||||
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
|
||||
for job := range jobs {
|
||||
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, false); err != nil {
|
||||
logbuch.Error("failed to count total for user %s: %v", job.UserID, err)
|
||||
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully counted total for user %s", job.UserID)
|
||||
results <- &CountTotalTimeResult{
|
||||
|
@ -64,6 +64,8 @@ type ISummaryService interface {
|
||||
type IUserService interface {
|
||||
GetUserById(string) (*models.User, error)
|
||||
GetUserByKey(string) (*models.User, error)
|
||||
GetUserByEmail(string) (*models.User, error)
|
||||
GetUserByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetActive() ([]*models.User, error)
|
||||
Count() (int64, error)
|
||||
@ -73,5 +75,11 @@ type IUserService interface {
|
||||
ResetApiKey(*models.User) (*models.User, error)
|
||||
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
GenerateResetToken(*models.User) (*models.User, error)
|
||||
FlushCache()
|
||||
}
|
||||
|
||||
type IMailService interface {
|
||||
SendPasswordReset(*models.User, string) error
|
||||
SendImportNotification(*models.User, time.Duration, int) error
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
@ -236,7 +237,15 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
||||
Machines: make([]*models.SummaryItem, 0),
|
||||
}
|
||||
|
||||
var processed = map[time.Time]bool{}
|
||||
|
||||
for _, s := range summaries {
|
||||
hash := s.FromTime.T()
|
||||
if _, found := processed[hash]; found {
|
||||
logbuch.Warn("summary from %v to %v (user '%s') was attempted to be processed more often than once", s.FromTime, s.ToTime, s.UserID)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.UserID != finalSummary.UserID {
|
||||
return nil, errors.New("users don't match")
|
||||
}
|
||||
@ -254,6 +263,8 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
|
||||
processed[hash] = true
|
||||
}
|
||||
|
||||
finalSummary.FromTime = models.CustomTime(minTime)
|
||||
@ -312,8 +323,17 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
// we assume that, if summary for any time range within a day is present, no further heartbeats exist on that day before 'from' and after 'to' time of that summary
|
||||
// this requires that a summary exists for every single day in a year and none is skipped, which shouldn't ever happen
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
|
||||
// we always want to jump to beginning of next day
|
||||
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
|
||||
if td1.Sub(t1) == 24*time.Hour {
|
||||
td1 = td1.Add(-1 * time.Hour)
|
||||
}
|
||||
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||
|
@ -235,6 +235,106 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
|
||||
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1)
|
||||
|
||||
/* TEST 3 */
|
||||
from = time.Date(suite.TestStartTime.Year(), suite.TestStartTime.Month(), suite.TestStartTime.Day()+1, 0, 0, 0, 0, suite.TestStartTime.Location()) // start of next day
|
||||
to = time.Date(from.Year(), from.Month(), from.Day()+2, 13, 30, 0, 0, from.Location()) // noon of third-next day
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from),
|
||||
ToTime: models.CustomTime(from.Add(24 * time.Hour)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(to.Add(-2 * time.Hour)),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject2,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser).Return(filter(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 2)
|
||||
assert.Equal(suite.T(), 90*time.Minute, result.TotalTime())
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1)
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
|
||||
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice!
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 1)
|
||||
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
|
||||
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
|
@ -52,6 +52,14 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserByEmail(email string) (*models.User, error) {
|
||||
return srv.repository.GetByEmail(email)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserByResetToken(resetToken string) (*models.User, error) {
|
||||
return srv.repository.GetByResetToken(resetToken)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
return srv.repository.GetAll()
|
||||
}
|
||||
@ -68,8 +76,9 @@ func (srv *UserService) Count() (int64, error) {
|
||||
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
|
||||
u := &models.User{
|
||||
ID: signup.Username,
|
||||
Email: signup.Email,
|
||||
ApiKey: uuid.NewV4().String(),
|
||||
Email: signup.Email,
|
||||
Location: signup.Location,
|
||||
Password: signup.Password,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
@ -110,6 +119,10 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi
|
||||
return srv.repository.UpdateField(user, "password", user.Password)
|
||||
}
|
||||
|
||||
func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, error) {
|
||||
return srv.repository.UpdateField(user, "reset_token", uuid.NewV4())
|
||||
}
|
||||
|
||||
func (srv *UserService) Delete(user *models.User) error {
|
||||
srv.cache.Flush()
|
||||
return srv.repository.Delete(user)
|
||||
|
@ -371,21 +371,6 @@ function copyApiKey(event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
// https://koddsson.com/posts/emoji-favicon/
|
||||
const favicon = document.querySelector('link[rel=icon]')
|
||||
if (favicon) {
|
||||
const emoji = favicon.getAttribute('data-emoji')
|
||||
if (emoji) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.height = 64
|
||||
canvas.width = 64
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.font = '64px serif'
|
||||
ctx.fillText(emoji, 0, 64)
|
||||
favicon.href = canvas.toDataURL()
|
||||
}
|
||||
}
|
||||
|
||||
// Click outside
|
||||
window.addEventListener('click', function (event) {
|
||||
if (event.target.classList.contains('popup')) {
|
||||
|
9
static/assets/icons.js
Normal file
9
static/assets/icons.js
Normal file
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#eee"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/><path d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm-.743-.55M28.93 94.535c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zm-.575-.618M31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm0 0M34.573 101.373c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm0 0M39.073 103.324c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm0 0M44.016 103.685c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm0 0M48.614 102.903c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/></g></svg>
|
Before Width: | Height: | Size: 1.9 KiB |
352
static/assets/timezones.js
Normal file
352
static/assets/timezones.js
Normal file
@ -0,0 +1,352 @@
|
||||
// https://stackoverflow.com/a/54500197/3112139
|
||||
|
||||
const tzs = [
|
||||
'Europe/Andorra',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kabul',
|
||||
'Europe/Tirane',
|
||||
'Asia/Yerevan',
|
||||
'Antarctica/Casey',
|
||||
'Antarctica/Davis',
|
||||
'Antarctica/DumontDUrville',
|
||||
'Antarctica/Mawson',
|
||||
'Antarctica/Palmer',
|
||||
'Antarctica/Rothera',
|
||||
'Antarctica/Syowa',
|
||||
'Antarctica/Troll',
|
||||
'Antarctica/Vostok',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Argentina/Cordoba',
|
||||
'America/Argentina/Salta',
|
||||
'America/Argentina/Jujuy',
|
||||
'America/Argentina/Tucuman',
|
||||
'America/Argentina/Catamarca',
|
||||
'America/Argentina/La_Rioja',
|
||||
'America/Argentina/San_Juan',
|
||||
'America/Argentina/Mendoza',
|
||||
'America/Argentina/San_Luis',
|
||||
'America/Argentina/Rio_Gallegos',
|
||||
'America/Argentina/Ushuaia',
|
||||
'Pacific/Pago_Pago',
|
||||
'Europe/Vienna',
|
||||
'Australia/Lord_Howe',
|
||||
'Antarctica/Macquarie',
|
||||
'Australia/Hobart',
|
||||
'Australia/Currie',
|
||||
'Australia/Melbourne',
|
||||
'Australia/Sydney',
|
||||
'Australia/Broken_Hill',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Lindeman',
|
||||
'Australia/Adelaide',
|
||||
'Australia/Darwin',
|
||||
'Australia/Perth',
|
||||
'Australia/Eucla',
|
||||
'Asia/Baku',
|
||||
'America/Barbados',
|
||||
'Asia/Dhaka',
|
||||
'Europe/Brussels',
|
||||
'Europe/Sofia',
|
||||
'Atlantic/Bermuda',
|
||||
'Asia/Brunei',
|
||||
'America/La_Paz',
|
||||
'America/Noronha',
|
||||
'America/Belem',
|
||||
'America/Fortaleza',
|
||||
'America/Recife',
|
||||
'America/Araguaina',
|
||||
'America/Maceio',
|
||||
'America/Bahia',
|
||||
'America/Sao_Paulo',
|
||||
'America/Campo_Grande',
|
||||
'America/Cuiaba',
|
||||
'America/Santarem',
|
||||
'America/Porto_Velho',
|
||||
'America/Boa_Vista',
|
||||
'America/Manaus',
|
||||
'America/Eirunepe',
|
||||
'America/Rio_Branco',
|
||||
'America/Nassau',
|
||||
'Asia/Thimphu',
|
||||
'Europe/Minsk',
|
||||
'America/Belize',
|
||||
'America/St_Johns',
|
||||
'America/Halifax',
|
||||
'America/Glace_Bay',
|
||||
'America/Moncton',
|
||||
'America/Goose_Bay',
|
||||
'America/Blanc-Sablon',
|
||||
'America/Toronto',
|
||||
'America/Nipigon',
|
||||
'America/Thunder_Bay',
|
||||
'America/Iqaluit',
|
||||
'America/Pangnirtung',
|
||||
'America/Atikokan',
|
||||
'America/Winnipeg',
|
||||
'America/Rainy_River',
|
||||
'America/Resolute',
|
||||
'America/Rankin_Inlet',
|
||||
'America/Regina',
|
||||
'America/Swift_Current',
|
||||
'America/Edmonton',
|
||||
'America/Cambridge_Bay',
|
||||
'America/Yellowknife',
|
||||
'America/Inuvik',
|
||||
'America/Creston',
|
||||
'America/Dawson_Creek',
|
||||
'America/Fort_Nelson',
|
||||
'America/Vancouver',
|
||||
'America/Whitehorse',
|
||||
'America/Dawson',
|
||||
'Indian/Cocos',
|
||||
'Europe/Zurich',
|
||||
'Africa/Abidjan',
|
||||
'Pacific/Rarotonga',
|
||||
'America/Santiago',
|
||||
'America/Punta_Arenas',
|
||||
'Pacific/Easter',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Urumqi',
|
||||
'America/Bogota',
|
||||
'America/Costa_Rica',
|
||||
'America/Havana',
|
||||
'Atlantic/Cape_Verde',
|
||||
'America/Curacao',
|
||||
'Indian/Christmas',
|
||||
'Asia/Nicosia',
|
||||
'Asia/Famagusta',
|
||||
'Europe/Prague',
|
||||
'Europe/Berlin',
|
||||
'Europe/Copenhagen',
|
||||
'America/Santo_Domingo',
|
||||
'Africa/Algiers',
|
||||
'America/Guayaquil',
|
||||
'Pacific/Galapagos',
|
||||
'Europe/Tallinn',
|
||||
'Africa/Cairo',
|
||||
'Africa/El_Aaiun',
|
||||
'Europe/Madrid',
|
||||
'Africa/Ceuta',
|
||||
'Atlantic/Canary',
|
||||
'Europe/Helsinki',
|
||||
'Pacific/Fiji',
|
||||
'Atlantic/Stanley',
|
||||
'Pacific/Chuuk',
|
||||
'Pacific/Pohnpei',
|
||||
'Pacific/Kosrae',
|
||||
'Atlantic/Faroe',
|
||||
'Europe/Paris',
|
||||
'Europe/London',
|
||||
'Asia/Tbilisi',
|
||||
'America/Cayenne',
|
||||
'Africa/Accra',
|
||||
'Europe/Gibraltar',
|
||||
'America/Godthab',
|
||||
'America/Danmarkshavn',
|
||||
'America/Scoresbysund',
|
||||
'America/Thule',
|
||||
'Europe/Athens',
|
||||
'Atlantic/South_Georgia',
|
||||
'America/Guatemala',
|
||||
'Pacific/Guam',
|
||||
'Africa/Bissau',
|
||||
'America/Guyana',
|
||||
'Asia/Hong_Kong',
|
||||
'America/Tegucigalpa',
|
||||
'America/Port-au-Prince',
|
||||
'Europe/Budapest',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Pontianak',
|
||||
'Asia/Makassar',
|
||||
'Asia/Jayapura',
|
||||
'Europe/Dublin',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Kolkata',
|
||||
'Indian/Chagos',
|
||||
'Asia/Baghdad',
|
||||
'Asia/Tehran',
|
||||
'Atlantic/Reykjavik',
|
||||
'Europe/Rome',
|
||||
'America/Jamaica',
|
||||
'Asia/Amman',
|
||||
'Asia/Tokyo',
|
||||
'Africa/Nairobi',
|
||||
'Asia/Bishkek',
|
||||
'Pacific/Tarawa',
|
||||
'Pacific/Enderbury',
|
||||
'Pacific/Kiritimati',
|
||||
'Asia/Pyongyang',
|
||||
'Asia/Seoul',
|
||||
'Asia/Almaty',
|
||||
'Asia/Qyzylorda',
|
||||
'Asia/Qostanay',
|
||||
'Asia/Aqtobe',
|
||||
'Asia/Aqtau',
|
||||
'Asia/Atyrau',
|
||||
'Asia/Oral',
|
||||
'Asia/Beirut',
|
||||
'Asia/Colombo',
|
||||
'Africa/Monrovia',
|
||||
'Europe/Vilnius',
|
||||
'Europe/Luxembourg',
|
||||
'Europe/Riga',
|
||||
'Africa/Tripoli',
|
||||
'Africa/Casablanca',
|
||||
'Europe/Monaco',
|
||||
'Europe/Chisinau',
|
||||
'Pacific/Majuro',
|
||||
'Pacific/Kwajalein',
|
||||
'Asia/Yangon',
|
||||
'Asia/Ulaanbaatar',
|
||||
'Asia/Hovd',
|
||||
'Asia/Choibalsan',
|
||||
'Asia/Macau',
|
||||
'America/Martinique',
|
||||
'Europe/Malta',
|
||||
'Indian/Mauritius',
|
||||
'Indian/Maldives',
|
||||
'America/Mexico_City',
|
||||
'America/Cancun',
|
||||
'America/Merida',
|
||||
'America/Monterrey',
|
||||
'America/Matamoros',
|
||||
'America/Mazatlan',
|
||||
'America/Chihuahua',
|
||||
'America/Ojinaga',
|
||||
'America/Hermosillo',
|
||||
'America/Tijuana',
|
||||
'America/Bahia_Banderas',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Kuching',
|
||||
'Africa/Maputo',
|
||||
'Africa/Windhoek',
|
||||
'Pacific/Noumea',
|
||||
'Pacific/Norfolk',
|
||||
'Africa/Lagos',
|
||||
'America/Managua',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Oslo',
|
||||
'Asia/Kathmandu',
|
||||
'Pacific/Nauru',
|
||||
'Pacific/Niue',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Chatham',
|
||||
'America/Panama',
|
||||
'America/Lima',
|
||||
'Pacific/Tahiti',
|
||||
'Pacific/Marquesas',
|
||||
'Pacific/Gambier',
|
||||
'Pacific/Port_Moresby',
|
||||
'Pacific/Bougainville',
|
||||
'Asia/Manila',
|
||||
'Asia/Karachi',
|
||||
'Europe/Warsaw',
|
||||
'America/Miquelon',
|
||||
'Pacific/Pitcairn',
|
||||
'America/Puerto_Rico',
|
||||
'Asia/Gaza',
|
||||
'Asia/Hebron',
|
||||
'Europe/Lisbon',
|
||||
'Atlantic/Madeira',
|
||||
'Atlantic/Azores',
|
||||
'Pacific/Palau',
|
||||
'America/Asuncion',
|
||||
'Asia/Qatar',
|
||||
'Indian/Reunion',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Belgrade',
|
||||
'Europe/Kaliningrad',
|
||||
'Europe/Moscow',
|
||||
'Europe/Simferopol',
|
||||
'Europe/Kirov',
|
||||
'Europe/Astrakhan',
|
||||
'Europe/Volgograd',
|
||||
'Europe/Saratov',
|
||||
'Europe/Ulyanovsk',
|
||||
'Europe/Samara',
|
||||
'Asia/Yekaterinburg',
|
||||
'Asia/Omsk',
|
||||
'Asia/Novosibirsk',
|
||||
'Asia/Barnaul',
|
||||
'Asia/Tomsk',
|
||||
'Asia/Novokuznetsk',
|
||||
'Asia/Krasnoyarsk',
|
||||
'Asia/Irkutsk',
|
||||
'Asia/Chita',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Khandyga',
|
||||
'Asia/Vladivostok',
|
||||
'Asia/Ust-Nera',
|
||||
'Asia/Magadan',
|
||||
'Asia/Sakhalin',
|
||||
'Asia/Srednekolymsk',
|
||||
'Asia/Kamchatka',
|
||||
'Asia/Anadyr',
|
||||
'Asia/Riyadh',
|
||||
'Pacific/Guadalcanal',
|
||||
'Indian/Mahe',
|
||||
'Africa/Khartoum',
|
||||
'Europe/Stockholm',
|
||||
'Asia/Singapore',
|
||||
'America/Paramaribo',
|
||||
'Africa/Juba',
|
||||
'Africa/Sao_Tome',
|
||||
'America/El_Salvador',
|
||||
'Asia/Damascus',
|
||||
'America/Grand_Turk',
|
||||
'Africa/Ndjamena',
|
||||
'Indian/Kerguelen',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Dushanbe',
|
||||
'Pacific/Fakaofo',
|
||||
'Asia/Dili',
|
||||
'Asia/Ashgabat',
|
||||
'Africa/Tunis',
|
||||
'Pacific/Tongatapu',
|
||||
'Europe/Istanbul',
|
||||
'America/Port_of_Spain',
|
||||
'Pacific/Funafuti',
|
||||
'Asia/Taipei',
|
||||
'Europe/Kiev',
|
||||
'Europe/Uzhgorod',
|
||||
'Europe/Zaporozhye',
|
||||
'Pacific/Wake',
|
||||
'America/New_York',
|
||||
'America/Detroit',
|
||||
'America/Kentucky/Louisville',
|
||||
'America/Kentucky/Monticello',
|
||||
'America/Indiana/Indianapolis',
|
||||
'America/Indiana/Vincennes',
|
||||
'America/Indiana/Winamac',
|
||||
'America/Indiana/Marengo',
|
||||
'America/Indiana/Petersburg',
|
||||
'America/Indiana/Vevay',
|
||||
'America/Chicago',
|
||||
'America/Indiana/Tell_City',
|
||||
'America/Indiana/Knox',
|
||||
'America/Menominee',
|
||||
'America/North_Dakota/Center',
|
||||
'America/North_Dakota/New_Salem',
|
||||
'America/North_Dakota/Beulah',
|
||||
'America/Denver',
|
||||
'America/Boise',
|
||||
'America/Phoenix',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Juneau',
|
||||
'America/Sitka',
|
||||
'America/Metlakatla',
|
||||
'America/Yakutat',
|
||||
'America/Nome',
|
||||
'America/Adak',
|
||||
'Pacific/Honolulu',
|
||||
'America/Montevideo',
|
||||
'Asia/Samarkand',
|
||||
'Asia/Tashkent',
|
||||
'America/Caracas',
|
||||
'Asia/Ho_Chi_Minh',
|
||||
'Pacific/Efate',
|
||||
'Pacific/Wallis',
|
||||
'Pacific/Apia',
|
||||
'Africa/Johannesburg'
|
||||
]
|
13
static/assets/vendor/iconify.basic.min.js
vendored
Normal file
13
static/assets/vendor/iconify.basic.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
static/assets/vendor/tailwind.dist.css
vendored
4
static/assets/vendor/tailwind.dist.css
vendored
@ -745,6 +745,10 @@ video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
2
static/assets/vendor/twemoji.min.js
vendored
Normal file
2
static/assets/vendor/twemoji.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
25
static/contribute.json
Normal file
25
static/contribute.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "wakapi",
|
||||
"description": "A minimalist, self-hosted WakaTime-compatible backend for coding statistics",
|
||||
"repository": {
|
||||
"url": "https://github.com/muety/wakapi",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"urls": {
|
||||
"prod": "https://wakapi.dev"
|
||||
},
|
||||
"bugs": {
|
||||
"list": "https://github.com/muety/wakapi/issues",
|
||||
"report": "https://github.com/muety/wakapi/issues/new"
|
||||
},
|
||||
"keywords": [
|
||||
"go",
|
||||
"golang",
|
||||
"html5",
|
||||
"sql",
|
||||
"productivity",
|
||||
"timetracking",
|
||||
"selfhosted",
|
||||
"devtools"
|
||||
]
|
||||
}
|
@ -7,12 +7,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParseDate(date string) (time.Time, error) {
|
||||
return time.Parse(config.SimpleDateFormat, date)
|
||||
}
|
||||
|
||||
func ParseDateTime(date string) (time.Time, error) {
|
||||
return time.Parse(config.SimpleDateTimeFormat, date)
|
||||
// ParseDateTimeTZ attempts to parse the given date string from multiple formats.
|
||||
// First, a time-zoned date-time string (e.g. 2006-01-02T15:04:05+02:00) is tried
|
||||
// Second, a non-time-zoned date-time string (e.g. 2006-01-02 15:04:05) is tried at the given zone
|
||||
// Third, a non-time-zoned date string (e.g. 2006-01-02) is tried at the given zone
|
||||
// Example:
|
||||
// - Server runs in CEST (UTC+2), requesting user lives in PDT (UTC-7).
|
||||
// - 2021-04-25T10:30:00Z, 2021-04-25T3:30:00-0100 and 2021-04-25T12:30:00+0200 are equivalent, they represent the same point in time
|
||||
// - When user requests non-time-zoned range (e.g. 2021-04-25T00:00:00), but has their time zone properly configured, this will resolve to 2021-04-25T09:00:00
|
||||
func ParseDateTimeTZ(date string, tz *time.Location) (time.Time, error) {
|
||||
if t, err := time.Parse(time.RFC3339, date); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if t, err := time.ParseInLocation(config.SimpleDateTimeFormat, date, tz); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.ParseInLocation(config.SimpleDateFormat, date, tz)
|
||||
}
|
||||
|
||||
func FormatDate(date time.Time) string {
|
||||
|
@ -5,30 +5,68 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartOfToday() time.Time {
|
||||
return StartOfDay(time.Now())
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
return FloorDate(date)
|
||||
}
|
||||
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
func StartOfToday(tz *time.Location) time.Time {
|
||||
return StartOfDay(FloorDate(time.Now().In(tz)))
|
||||
}
|
||||
|
||||
func StartOfThisWeek(tz *time.Location) time.Time {
|
||||
return StartOfWeek(time.Now().In(tz))
|
||||
}
|
||||
|
||||
func StartOfWeek(date time.Time) time.Time {
|
||||
year, week := date.ISOWeek()
|
||||
return firstDayOfISOWeek(year, week, date.Location())
|
||||
}
|
||||
|
||||
func StartOfThisMonth(tz *time.Location) time.Time {
|
||||
return StartOfMonth(time.Now().In(tz))
|
||||
}
|
||||
|
||||
func StartOfMonth(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func StartOfThisYear(tz *time.Location) time.Time {
|
||||
return StartOfYear(time.Now().In(tz))
|
||||
}
|
||||
|
||||
func StartOfYear(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
// FloorDate rounds date down to the start of the day and keeps the time zone
|
||||
func FloorDate(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func StartOfWeek() time.Time {
|
||||
ref := time.Now()
|
||||
year, week := ref.ISOWeek()
|
||||
return firstDayOfISOWeek(year, week, ref.Location())
|
||||
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
|
||||
func CeilDate(date time.Time) time.Time {
|
||||
floored := FloorDate(date)
|
||||
if floored == date {
|
||||
return floored
|
||||
}
|
||||
return floored.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
func StartOfMonth() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
||||
// SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
|
||||
func SetLocation(date time.Time, tz *time.Location) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
|
||||
}
|
||||
|
||||
func StartOfYear() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||
// WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
|
||||
func WithOffset(date time.Time, tz *time.Location) time.Time {
|
||||
now := time.Now()
|
||||
_, localOffset := now.Zone()
|
||||
_, targetOffset := now.In(tz).Zone()
|
||||
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
|
||||
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
|
||||
}
|
||||
|
||||
// SplitRangeByDays creates a slice of intervals between from and to, each of which is at max of 24 hours length and has its split at midnight
|
||||
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
||||
intervals := make([][]time.Time, 0)
|
||||
|
||||
|
126
utils/date_test.go
Normal file
126
utils/date_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
tzLocal *time.Location
|
||||
tzUtc *time.Location
|
||||
tzCet *time.Location
|
||||
tzPst *time.Location
|
||||
)
|
||||
|
||||
func init() {
|
||||
tzLocal = time.Local
|
||||
tzUtc, _ = time.LoadLocation("UTC")
|
||||
tzCet, _ = time.LoadLocation("Europe/Berlin")
|
||||
tzPst, _ = time.LoadLocation("America/Los_Angeles")
|
||||
}
|
||||
|
||||
func TestDate_Ceil(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{
|
||||
"02 Jan 06 15:04 MST",
|
||||
"03 Jan 06 00:00 MST",
|
||||
},
|
||||
{
|
||||
"03 Jan 06 00:00 MST",
|
||||
"03 Jan 06 00:00 MST",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
inDate, _ := time.Parse(time.RFC822, test.in)
|
||||
outDate, _ := time.Parse(time.RFC822, test.out)
|
||||
out := CeilDate(inDate)
|
||||
assert.Equal(t, outDate, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDate_StartOfDay(t *testing.T) {
|
||||
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
|
||||
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
|
||||
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
|
||||
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
|
||||
|
||||
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzLocal)
|
||||
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzUtc)
|
||||
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzPst)
|
||||
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzCet)
|
||||
|
||||
assert.Equal(t, t1, StartOfDay(d1))
|
||||
assert.Equal(t, t2, StartOfDay(d2))
|
||||
assert.Equal(t, t3, StartOfDay(d3))
|
||||
assert.Equal(t, t4, StartOfDay(d4))
|
||||
|
||||
assert.Equal(t, tzLocal, StartOfDay(d1).Location())
|
||||
assert.Equal(t, tzUtc, StartOfDay(d2).Location())
|
||||
assert.Equal(t, tzPst, StartOfDay(d3).Location())
|
||||
assert.Equal(t, tzCet, StartOfDay(d4).Location())
|
||||
}
|
||||
|
||||
func TestDate_StartOfWeek(t *testing.T) {
|
||||
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
|
||||
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
|
||||
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
|
||||
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
|
||||
|
||||
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzLocal)
|
||||
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzUtc)
|
||||
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzPst)
|
||||
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzCet)
|
||||
|
||||
assert.Equal(t, t1, StartOfWeek(d1))
|
||||
assert.Equal(t, t2, StartOfWeek(d2))
|
||||
assert.Equal(t, t3, StartOfWeek(d3))
|
||||
assert.Equal(t, t4, StartOfWeek(d4))
|
||||
|
||||
assert.Equal(t, tzLocal, StartOfWeek(d1).Location())
|
||||
assert.Equal(t, tzUtc, StartOfWeek(d2).Location())
|
||||
assert.Equal(t, tzPst, StartOfWeek(d3).Location())
|
||||
assert.Equal(t, tzCet, StartOfWeek(d4).Location())
|
||||
}
|
||||
|
||||
func TestDate_SplitRangeByDays(t *testing.T) {
|
||||
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
|
||||
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
|
||||
df2 := df1
|
||||
dt2 := CeilDate(df1)
|
||||
df3 := df1
|
||||
dt3 := df1.Add(10 * time.Second)
|
||||
df4 := df1
|
||||
dt4 := df4
|
||||
|
||||
result1 := SplitRangeByDays(df1, dt1)
|
||||
result2 := SplitRangeByDays(df2, dt2)
|
||||
result3 := SplitRangeByDays(df3, dt3)
|
||||
result4 := SplitRangeByDays(df4, dt4)
|
||||
|
||||
assert.Len(t, result1, 4)
|
||||
assert.Len(t, result1[0], 2)
|
||||
assert.Equal(t, result1[0][0], df1)
|
||||
assert.Equal(t, result1[3][1], dt1)
|
||||
assert.Equal(t, result1[1][0].Hour()+result1[1][0].Minute()+result1[1][0].Second(), 0)
|
||||
assert.Equal(t, result1[2][0].Hour()+result1[2][0].Minute()+result1[2][0].Second(), 0)
|
||||
assert.Equal(t, result1[3][0].Hour()+result1[3][0].Minute()+result1[3][0].Second(), 0)
|
||||
assert.Equal(t, result1[1][0], result1[0][1])
|
||||
assert.Equal(t, result1[2][0], result1[1][1])
|
||||
assert.Equal(t, result1[3][0], result1[2][1])
|
||||
|
||||
assert.Len(t, result2, 1)
|
||||
assert.Equal(t, result2[0][0], df2)
|
||||
assert.Equal(t, result2[0][1], dt2)
|
||||
|
||||
assert.Len(t, result3, 1)
|
||||
assert.Equal(t, result3[0][0], df3)
|
||||
assert.Equal(t, result3[0][1], dt3)
|
||||
|
||||
assert.Len(t, result4, 0)
|
||||
}
|
@ -2,14 +2,14 @@ package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
|
||||
func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(object); err != nil {
|
||||
logbuch.Error("error while writing json response: %v", err)
|
||||
config.Log().Request(r).Error("error while writing json response: %v", err)
|
||||
}
|
||||
}
|
||||
|
@ -16,51 +16,51 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||
return nil, errors.New("not a valid interval")
|
||||
}
|
||||
|
||||
func MustResolveIntervalRaw(interval string) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRaw(interval)
|
||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||
return from, to
|
||||
}
|
||||
|
||||
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) {
|
||||
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||
parsed, err := ParseInterval(interval)
|
||||
if err != nil {
|
||||
return err, time.Time{}, time.Time{}
|
||||
}
|
||||
return ResolveInterval(parsed)
|
||||
return ResolveIntervalTZ(parsed, tz)
|
||||
}
|
||||
|
||||
func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) {
|
||||
to = time.Now()
|
||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||
to = time.Now().In(tz)
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = StartOfToday()
|
||||
from = StartOfToday(tz)
|
||||
case models.IntervalYesterday:
|
||||
from = StartOfToday().Add(-24 * time.Hour)
|
||||
to = StartOfToday()
|
||||
from = StartOfToday(tz).Add(-24 * time.Hour)
|
||||
to = StartOfToday(tz)
|
||||
case models.IntervalThisWeek:
|
||||
from = StartOfWeek()
|
||||
from = StartOfThisWeek(tz)
|
||||
case models.IntervalLastWeek:
|
||||
from = StartOfWeek().AddDate(0, 0, -7)
|
||||
to = StartOfWeek()
|
||||
from = StartOfThisWeek(tz).AddDate(0, 0, -7)
|
||||
to = StartOfThisWeek(tz)
|
||||
case models.IntervalThisMonth:
|
||||
from = StartOfMonth()
|
||||
from = StartOfThisMonth(tz)
|
||||
case models.IntervalLastMonth:
|
||||
from = StartOfMonth().AddDate(0, -1, 0)
|
||||
to = StartOfMonth()
|
||||
from = StartOfThisMonth(tz).AddDate(0, -1, 0)
|
||||
to = StartOfThisMonth(tz)
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfYear()
|
||||
from = StartOfThisYear(tz)
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday().AddDate(0, 0, -7)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -7)
|
||||
case models.IntervalPast7DaysYesterday:
|
||||
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = StartOfToday().AddDate(0, 0, -1)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||
case models.IntervalPast14Days:
|
||||
from = StartOfToday().AddDate(0, 0, -14)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -14)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday().AddDate(0, 0, -30)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday().AddDate(0, -12, 0)
|
||||
from = StartOfToday(tz).AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
@ -78,24 +78,18 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
var from, to time.Time
|
||||
|
||||
if interval := params.Get("interval"); interval != "" {
|
||||
err, from, to = ResolveIntervalRaw(interval)
|
||||
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
|
||||
} else if start := params.Get("start"); start != "" {
|
||||
err, from, to = ResolveIntervalRaw(start)
|
||||
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
|
||||
} else {
|
||||
from, err = ParseDateTime(params.Get("from"))
|
||||
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
|
||||
if err != nil {
|
||||
from, err = ParseDate(params.Get("from"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'from' parameter")
|
||||
}
|
||||
return nil, errors.New("missing or invalid 'from' parameter")
|
||||
}
|
||||
|
||||
to, err = ParseDateTime(params.Get("to"))
|
||||
to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
|
||||
if err != nil {
|
||||
to, err = ParseDate(params.Get("to"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'to' parameter")
|
||||
}
|
||||
return nil, errors.New("missing or invalid 'to' parameter")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1 +1 @@
|
||||
1.25.4
|
||||
1.26.8
|
||||
|
@ -1,2 +1,4 @@
|
||||
<script src="assets/vendor/iconify.basic.min.js"></script>
|
||||
<script src="assets/vendor/seedrandom.min.js"></script>
|
||||
<script src="assets/vendor/Chart.bundle.min.js"></script>
|
||||
<script src="assets/vendor/Chart.bundle.min.js"></script>
|
||||
<script src="assets/icons.js"></script>
|
@ -3,7 +3,7 @@
|
||||
v{{ getVersion }} @ {{ getDbType }}
|
||||
</div>
|
||||
<div>
|
||||
Made with 🤍 by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a
|
||||
Made with <span class="iconify inline" data-icon="bi:heart-fill"></span> by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a
|
||||
href="https://github.com/muety/wakapi" class="border-b border-green-700">open-source</a> software
|
||||
</div>
|
||||
<div>
|
||||
|
@ -2,6 +2,10 @@
|
||||
<title>Wakapi – Coding Statistics</title>
|
||||
<base href="{{ getBasePath }}/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<meta property="og:title" content="Wakapi - Coding Statistics" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks and anyone else.">
|
||||
<meta property="og:image" content="assets/images/screenshot.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
|
||||
|
@ -11,21 +11,21 @@
|
||||
|
||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||
<div class="mx-1">
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑
|
||||
Login️</a>
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">
|
||||
<span class="iconify inline" data-icon="fxemoji:key"></span> Login️</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex flex-col text-white">
|
||||
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span
|
||||
class="text-green-700">Your</span> Coding Time 🕓</h1>
|
||||
class="text-green-700">Your</span> Coding Time <span class="iconify inline" data-icon="flat-color-icons:clock"></span></h1>
|
||||
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the
|
||||
time you have spent coding on different projects in different programming languages and more. Ideal for
|
||||
statistics freaks and anyone else.</p>
|
||||
|
||||
<p class="text-center text-gray-500 text-xl my-4">
|
||||
<span class="mr-1">💡 The system has tracked a total of </span>
|
||||
<span class="mr-1"><span class="iconify inline" data-icon="twemoji:light-bulb"></span> The system has tracked a total of </span>
|
||||
{{ range $d := .TotalHours | printf "%d" | toRunes }}
|
||||
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated every hour)">{{ $d }}</span>
|
||||
{{ end }}
|
||||
@ -39,16 +39,20 @@
|
||||
<div class="flex justify-center mt-4 mb-8 space-x-2">
|
||||
<a href="login">
|
||||
<button type="button"
|
||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!
|
||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold"><span class="iconify inline" data-icon="fxemoji:rocket"></span> Try it!
|
||||
</button>
|
||||
</a>
|
||||
<a href="https://github.com/muety/wakapi#%EF%B8%8F-how-to-use" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="fxemoji:satelliteantenna"></span> Host it
|
||||
</button>
|
||||
</a>
|
||||
<a href="https://liberapay.com/muety" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="flat-color-icons:donate"></span> Support it
|
||||
</button>
|
||||
</a>
|
||||
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
|
||||
<img alt="GitHub Icon" src="assets/images/ghicon.svg" width="22px">
|
||||
<span class="iconify inline text-white" data-icon="codicon:github-inverted"></span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
@ -61,19 +65,15 @@
|
||||
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Features</h1>
|
||||
<div class="mt-4 text-lg">
|
||||
<ul>
|
||||
<li>✅ 100 % free and open-source</li>
|
||||
<li>✅ Built by developers for developers</li>
|
||||
<li>✅ Fancy statistics and plots</li>
|
||||
<li>✅ Cool badges for readmes</li>
|
||||
<li>✅ Intuitive REST API</li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer"
|
||||
class="underline">Prometheus</a> metrics via <a
|
||||
href="https://github.com/MacroPower/wakatime_exporter" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">exporter</a></li>
|
||||
<li>✅ Lightning fast</li>
|
||||
<li>✅ Self-hosted</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> 100 % free and open-source</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Built by developers for developers</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Fancy statistics and plots</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Cool badges for readmes</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Intuitive REST API</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Lightning fast</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Self-hosted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -7,8 +7,12 @@
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Login</h1>
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-lg flex-grow">
|
||||
<div><a onclick="window.history.back()" class="text-gray-500 text-sm cursor-pointer">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
@ -28,11 +32,16 @@
|
||||
type="password" id="password"
|
||||
name="password" placeholder="******" minlength="6" required>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a href="signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="reset-password" class="text-gray-500 text-sm">
|
||||
Forgot password?
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
<div class="flex space-x-2">
|
||||
<a href="signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
152
views/mail/import_finished.tpl.html
Normal file
152
views/mail/import_finished.tpl.html
Normal file
@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Wakapi – Data Import Finished</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.btn-primary table td:hover {
|
||||
background-color: #047857 !important;
|
||||
}
|
||||
.btn-primary a:hover {
|
||||
background-color: #047857 !important;
|
||||
border-color: #047857 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
|
||||
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Data import finished</p>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have requested to import data from WakaTime to Wakapi. The import has now finished after {{ .Duration }} ({{ .NumHeartbeats }} new heartbeats imported).<br><br>You should be able to see the newly imported coding statistics in Wakapi.</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .PublicUrl }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #2F855A; border: solid 1px #2F855A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #2F855A;">Go to dashboard</a> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
|
||||
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
153
views/mail/reset_password.tpl.html
Normal file
153
views/mail/reset_password.tpl.html
Normal file
@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Wakapi – Reset Password</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.btn-primary table td:hover {
|
||||
background-color: #047857 !important;
|
||||
}
|
||||
.btn-primary a:hover {
|
||||
background-color: #047857 !important;
|
||||
border-color: #047857 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
|
||||
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Password Reset</p>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have requested to reset your Wakapi password. Please click the following link to proceed.</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .ResetLink }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #2F855A; border: solid 1px #2F855A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #2F855A;">Reset Password</a> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">If you did not request a password change, please just ignore this mail.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
|
||||
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
42
views/reset-password.tpl.html
Normal file
42
views/reset-password.tpl.html
Normal file
@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-lg flex-grow">
|
||||
<div><a onclick="window.history.back()" class="text-gray-500 text-sm cursor-pointer">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Reset Password</h1></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<form action="reset-password" method="post">
|
||||
<p class="text-sm text-white mb-8">If you forgot your password, enter the e-mail address you associated with Wakapi to reset it and choose a new one. You will receive an e-mail afterwards. Make sure to check your spam folder if it does not arrive after a few minutes.</p>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="email">E-Mail</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="email" id="email"
|
||||
name="email" placeholder="Enter your e-mail address" minlength="1" required autofocus>
|
||||
</div>
|
||||
<div class="flex justify-end items-center">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
44
views/set-password.tpl.html
Normal file
44
views/set-password.tpl.html
Normal file
@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Choose a new password</h1>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<form action="set-password" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="password" id="password"
|
||||
name="password" placeholder="Choose a password" minlength="6" required>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="password" id="password_repeat"
|
||||
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||
</div>
|
||||
<div class="flex justify-end items-center">
|
||||
<input type="hidden" name="token" value="{{ .Token }}">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user