mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
14 Commits
1a6ee55d14
...
5f1ca4ed69
Author | SHA1 | Date | |
---|---|---|---|
|
5f1ca4ed69 | ||
|
c06b2b8aca | ||
|
45a003185e | ||
|
3063e80692 | ||
|
38286c7f3a | ||
|
37e6acd058 | ||
|
07b24fe3b1 | ||
|
78f327dbeb | ||
|
2af82f529a | ||
|
5278dba4f4 | ||
|
35ef323b19 | ||
|
a8e2bc671d | ||
|
7b60c44ac6 | ||
|
055d006379 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: ^1.19
|
go-version: ^1.20
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: ^1.19
|
go-version: ^1.20
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@ -90,7 +90,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: ^1.19
|
go-version: ^1.20
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
@ -115,7 +115,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: ^1.19
|
go-version: ^1.20
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: ubuntu-18.04
|
- platform: ubuntu-latest
|
||||||
GOOS: linux
|
GOOS: linux
|
||||||
GOARCH: amd64
|
GOARCH: amd64
|
||||||
- platform: ubuntu-18.04
|
- platform: ubuntu-latest
|
||||||
GOOS: linux
|
GOOS: linux
|
||||||
GOARCH: arm64
|
GOARCH: arm64
|
||||||
- platform: windows-latest
|
- platform: windows-latest
|
||||||
@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: ^1.19
|
go-version: ^1.20
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
|
26
README.md
26
README.md
@ -47,10 +47,6 @@ Installation instructions can be found below and in the [Wiki](https://github.co
|
|||||||
* ✅ Lightning fast
|
* ✅ Lightning fast
|
||||||
* ✅ Self-hosted
|
* ✅ Self-hosted
|
||||||
|
|
||||||
## 🚧 Roadmap
|
|
||||||
|
|
||||||
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
|
|
||||||
|
|
||||||
## ⌨️ How to use?
|
## ⌨️ How to use?
|
||||||
|
|
||||||
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
||||||
@ -298,6 +294,28 @@ Preview:
|
|||||||
</details>
|
</details>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
## 📦 Data Export
|
||||||
|
You can export your coding activity from Wakapi to CSV in the form of raw heartbeats. While there is no way to accomplish this directly through the web UI, we provide an easy-to-use Python [script](scripts/download_heartbeats.py) instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pip install requests tqdm
|
||||||
|
$ python scripts/download_heartbeats.py --api_key API_KEY [--url URL] [--from FROM] [--to TO] [--output OUTPUT]
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary>Example</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/download_heartbeats.py --api_key 04648d14-15c9-432b-b901-dbeec70d4eaf \
|
||||||
|
--url https://wakapi.dev/api \
|
||||||
|
--from 2023-01-01 \
|
||||||
|
--to 2023-01-31 \
|
||||||
|
--output wakapi_export.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 👍 Best practices
|
## 👍 Best practices
|
||||||
|
|
||||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), to enable **TLS encryption** (HTTPS).
|
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), to enable **TLS encryption** (HTTPS).
|
||||||
|
@ -51,6 +51,7 @@ const (
|
|||||||
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
|
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
|
||||||
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
|
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
|
||||||
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
|
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
|
||||||
|
WakatimeApiDataDumpUrl = "/users/current/data_dumps"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -113,8 +113,7 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
AttachStacktrace: true,
|
AttachStacktrace: true,
|
||||||
EnableTracing: config.EnableTracing,
|
EnableTracing: config.EnableTracing,
|
||||||
TracesSampler: func(ctx sentry.SamplingContext) float64 {
|
TracesSampler: func(ctx sentry.SamplingContext) float64 {
|
||||||
hub := sentry.GetHubFromContext(ctx.Span.Context())
|
txName := ctx.Span.Name
|
||||||
txName := hub.Scope().Transaction()
|
|
||||||
for _, ex := range excludedRoutes {
|
for _, ex := range excludedRoutes {
|
||||||
if strings.HasPrefix(txName, ex) {
|
if strings.HasPrefix(txName, ex) {
|
||||||
return 0.0
|
return 0.0
|
||||||
|
File diff suppressed because it is too large
Load Diff
66
go.mod
66
go.mod
@ -1,15 +1,16 @@
|
|||||||
module github.com/muety/wakapi
|
module github.com/muety/wakapi
|
||||||
|
|
||||||
go 1.19
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
codeberg.org/Codeberg/avatars v1.0.0
|
codeberg.org/Codeberg/avatars v1.0.0
|
||||||
github.com/duke-git/lancet/v2 v2.1.18
|
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736
|
||||||
|
github.com/duke-git/lancet/v2 v2.2.3
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||||
github.com/emersion/go-smtp v0.16.0
|
github.com/emersion/go-smtp v0.17.0
|
||||||
github.com/emvi/logbuch v1.2.0
|
github.com/emvi/logbuch v1.2.0
|
||||||
github.com/getsentry/sentry-go v0.17.0
|
github.com/getsentry/sentry-go v0.22.0
|
||||||
github.com/glebarez/sqlite v1.7.0
|
github.com/glebarez/sqlite v1.9.0
|
||||||
github.com/go-chi/chi/v5 v5.0.8
|
github.com/go-chi/chi/v5 v5.0.8
|
||||||
github.com/gorilla/schema v1.2.0
|
github.com/gorilla/schema v1.2.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
@ -25,55 +26,56 @@ require (
|
|||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/satori/go.uuid v1.2.0
|
github.com/satori/go.uuid v1.2.0
|
||||||
github.com/stretchr/testify v1.8.2
|
github.com/stretchr/testify v1.8.2
|
||||||
github.com/stripe/stripe-go/v74 v74.15.0
|
github.com/stripe/stripe-go/v74 v74.25.0
|
||||||
github.com/swaggo/http-swagger v1.3.4
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
github.com/swaggo/swag v1.8.12
|
github.com/swaggo/swag v1.16.1
|
||||||
go.uber.org/atomic v1.10.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.8.0
|
golang.org/x/crypto v0.11.0
|
||||||
golang.org/x/sync v0.1.0
|
golang.org/x/sync v0.3.0
|
||||||
gorm.io/driver/mysql v1.4.7
|
gorm.io/driver/mysql v1.5.1
|
||||||
gorm.io/driver/postgres v1.5.0
|
gorm.io/driver/postgres v1.5.2
|
||||||
gorm.io/driver/sqlite v1.4.4
|
gorm.io/driver/sqlite v1.5.2
|
||||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11
|
gorm.io/gorm v1.25.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.1 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
github.com/go-openapi/spec v0.20.8 // indirect
|
github.com/go-openapi/spec v0.20.9 // indirect
|
||||||
github.com/go-openapi/swag v0.22.3 // indirect
|
github.com/go-openapi/swag v0.22.4 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/pgx/v5 v5.3.1 // indirect
|
github.com/jackc/pgx/v5 v5.4.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
github.com/swaggo/files v1.0.1 // indirect
|
github.com/swaggo/files v1.0.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
|
||||||
golang.org/x/image v0.7.0 // indirect
|
golang.org/x/image v0.9.0 // indirect
|
||||||
golang.org/x/net v0.9.0 // indirect
|
golang.org/x/net v0.12.0 // indirect
|
||||||
golang.org/x/sys v0.7.0 // indirect
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.8.0 // indirect
|
golang.org/x/tools v0.11.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.3 // indirect
|
modernc.org/libc v1.24.1 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.6.0 // indirect
|
||||||
modernc.org/sqlite v1.21.1 // indirect
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
143
go.sum
143
go.sum
@ -1,31 +1,35 @@
|
|||||||
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
||||||
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
|
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4=
|
||||||
|
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/duke-git/lancet/v2 v2.1.18 h1:S5dsxDRFhMNTlhuoJZnK3PiiGfE8psPejsEUjfuQwtQ=
|
github.com/duke-git/lancet/v2 v2.2.3 h1:Lj4iWgvEbgktEjAfqxE1G2BoGm1mL7l3QHBlXRYptjE=
|
||||||
github.com/duke-git/lancet/v2 v2.1.18/go.mod h1:hNcc06mV7qr+crH/0nP+rlC3TB0Q9g5OrVnO8/TGD4c=
|
github.com/duke-git/lancet/v2 v2.2.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
|
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
|
||||||
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
|
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
|
||||||
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
|
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
|
||||||
github.com/getsentry/sentry-go v0.17.0 h1:UustVWnOoDFHBS7IJUB2QK/nB5pap748ZEp0swnQJak=
|
github.com/getsentry/sentry-go v0.22.0 h1:XNX9zKbv7baSEI65l+H1GEJgSeIC1c7EN5kluWaP6dM=
|
||||||
github.com/getsentry/sentry-go v0.17.0/go.mod h1:B82dxtBvxG0KaPD8/hfSV+VcHD+Lg/xUS4JuQn1P4cM=
|
github.com/getsentry/sentry-go v0.22.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||||
github.com/glebarez/go-sqlite v1.21.1 h1:7MZyUPh2XTrHS7xNEHQbrhfMZuPSzhkm2A1qgg0y5NY=
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI=
|
github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc=
|
||||||
github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk=
|
github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8=
|
||||||
|
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
|
||||||
|
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
|
||||||
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
|
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
|
||||||
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
@ -36,14 +40,16 @@ github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaL
|
|||||||
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU=
|
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
|
||||||
github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
|
||||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||||
|
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
@ -62,15 +68,12 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
github.com/jackc/pgx/v5 v5.4.1 h1:oKfB/FhuVtit1bBM3zNRRsZ925ZkMN3HXL+LgLUM9lE=
|
||||||
github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU=
|
github.com/jackc/pgx/v5 v5.4.1/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
|
||||||
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||||
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
@ -80,7 +83,6 @@ github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLC
|
|||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@ -94,11 +96,10 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
|
|||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
|
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
|
||||||
@ -117,8 +118,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@ -134,41 +135,43 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v74 v74.15.0 h1:P3ZYrY4CdZeV8Pc/205utqjur+5gcTef+9hgtj8P8IY=
|
github.com/stripe/stripe-go/v74 v74.25.0 h1:mGJp9L1ymxjFvq5MlmG6ynv/fAGX6LLU8MyMVsiRAMY=
|
||||||
github.com/stripe/stripe-go/v74 v74.15.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.25.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||||
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
|
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
|
||||||
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
|
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
|
||||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||||
|
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@ -177,31 +180,32 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||||
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/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-20191119224855-298f0cb1881e/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
|
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
|
||||||
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
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.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
@ -209,21 +213,26 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
|
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||||
gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
|
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
|
||||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
|
||||||
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
|
||||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
|
||||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g=
|
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||||
|
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
|
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
||||||
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
|
@ -99,11 +99,6 @@ func (m *UserServiceMock) SetWakatimeApiCredentials(user *models.User, s1, s2 st
|
|||||||
return args.Get(0).(*models.User), args.Error(1)
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
|
||||||
args := m.Called(user, login)
|
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) {
|
func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) {
|
||||||
args := m.Called(user)
|
args := m.Called(user)
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
|
24
models/compat/wakatime/v1/data_dump.go
Normal file
24
models/compat/wakatime/v1/data_dump.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
type DataDumpViewModel struct {
|
||||||
|
Data []*DataDumpData `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataDumpResultViewModel struct {
|
||||||
|
Data *DataDumpData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataDumpData struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DownloadUrl string `json:"download_url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PercentComplete float32 `json:"percent_complete"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
HasFailed bool `json:"has_failed"`
|
||||||
|
IsStuck bool `json:"is_stuck"`
|
||||||
|
IsProcessing bool `json:"is_processing"`
|
||||||
|
}
|
17
models/compat/wakatime/v1/json_export.go
Normal file
17
models/compat/wakatime/v1/json_export.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
type JsonExportViewModel struct {
|
||||||
|
//User *User `json:"user"`
|
||||||
|
Range *JsonExportRange `json:"range"`
|
||||||
|
Days []*JsonExportDay `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonExportRange struct {
|
||||||
|
Start int64 `json:"start"`
|
||||||
|
End int64 `json:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonExportDay struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Heartbeats []*HeartbeatEntry `json:"heartbeats"`
|
||||||
|
}
|
@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/mitchellh/hashstructure/v2"
|
"github.com/mitchellh/hashstructure/v2"
|
||||||
"strings"
|
"strings"
|
||||||
@ -39,6 +40,22 @@ func (h *Heartbeat) Timely(maxAge time.Duration) bool {
|
|||||||
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
|
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Heartbeat) Sanitize() *Heartbeat {
|
||||||
|
// wakatime has a special keyword that indicates to use the most recent project for a given heartbeat
|
||||||
|
// in chrome, the browser extension sends this keyword for (most?) heartbeats
|
||||||
|
// presumably the idea behind this is that if you're coding, your browsing activity will likely also relate to that coding project
|
||||||
|
// but i don't really like this, i'd rather have all browsing activity under the "unknown" project (as the case with firefox, for whatever reason)
|
||||||
|
// see https://github.com/wakatime/browser-wakatime/pull/206
|
||||||
|
if (h.Type == "url" || h.Type == "domain") && h.Project == "<<LAST_PROJECT>>" {
|
||||||
|
h.Project = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.OperatingSystem = strutil.Capitalize(h.OperatingSystem)
|
||||||
|
h.Editor = strutil.Capitalize(h.Editor)
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||||
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
||||||
for ending, value := range languageMappings {
|
for ending, value := range languageMappings {
|
||||||
|
@ -7,4 +7,5 @@ type Report struct {
|
|||||||
To time.Time
|
To time.Time
|
||||||
User *User
|
User *User
|
||||||
Summary *Summary
|
Summary *Summary
|
||||||
|
DailySummaries []*Summary
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
|
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval.Key, filters.Hash(), r.URL.RawQuery)
|
||||||
noCache := utils.IsNoCache(r, 1*time.Hour)
|
noCache := utils.IsNoCache(r, 1*time.Hour)
|
||||||
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
|
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
|
||||||
respondSvg(w, cacheResult.([]byte))
|
respondSvg(w, cacheResult.([]byte))
|
||||||
|
@ -93,7 +93,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
if !utils.ComparePassword(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid credentials"))
|
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid credentials"))
|
||||||
return
|
return
|
||||||
@ -252,7 +252,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user.Password = setRequest.Password
|
user.Password = setRequest.Password
|
||||||
user.ResetToken = ""
|
user.ResetToken = ""
|
||||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
if hash, err := utils.HashPassword(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
||||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to set new password"))
|
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to set new password"))
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
"github.com/muety/wakapi/helpers"
|
"github.com/muety/wakapi/helpers"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -34,7 +35,7 @@ func DefaultTemplateFuncs() template.FuncMap {
|
|||||||
"title": strings.Title,
|
"title": strings.Title,
|
||||||
"join": strings.Join,
|
"join": strings.Join,
|
||||||
"add": add,
|
"add": add,
|
||||||
"capitalize": utils.Capitalize,
|
"capitalize": strutil.Capitalize,
|
||||||
"lower": strings.ToLower,
|
"lower": strings.ToLower,
|
||||||
"toRunes": utils.ToRunes,
|
"toRunes": utils.ToRunes,
|
||||||
"localTZOffset": utils.LocalTZOffset,
|
"localTZOffset": utils.LocalTZOffset,
|
||||||
|
@ -217,7 +217,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
|
|||||||
return http.StatusBadRequest, "", "missing parameters"
|
return http.StatusBadRequest, "", "missing parameters"
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
if !utils.ComparePassword(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
||||||
return http.StatusUnauthorized, "", "invalid credentials"
|
return http.StatusUnauthorized, "", "invalid credentials"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +226,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Password = credentials.PasswordNew
|
user.Password = credentials.PasswordNew
|
||||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
if hash, err := utils.HashPassword(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||||
} else {
|
} else {
|
||||||
user.Password = hash
|
user.Password = hash
|
||||||
@ -513,23 +513,27 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
go func(user *models.User) {
|
go func(user *models.User) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
|
importer := imports.NewWakatimeImporter(user.WakatimeApiKey)
|
||||||
|
|
||||||
countBefore, err := h.heartbeatSrvc.CountByUser(user)
|
countBefore, _ := h.heartbeatSrvc.CountByUser(user)
|
||||||
if err != nil {
|
|
||||||
println(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream <-chan *models.Heartbeat
|
var (
|
||||||
|
stream <-chan *models.Heartbeat
|
||||||
|
importError error
|
||||||
|
)
|
||||||
if latest, err := h.heartbeatSrvc.GetLatestByOriginAndUser(imports.OriginWakatime, user); latest == nil || err != nil {
|
if latest, err := h.heartbeatSrvc.GetLatestByOriginAndUser(imports.OriginWakatime, user); latest == nil || err != nil {
|
||||||
stream = importer.ImportAll(user)
|
stream, importError = importer.ImportAll(user)
|
||||||
} else {
|
} else {
|
||||||
// if an import has happened before, only import heartbeats newer than the latest of the last import
|
// if an import has happened before, only import heartbeats newer than the latest of the last import
|
||||||
stream = importer.Import(user, latest.Time.T(), time.Now())
|
stream, importError = importer.Import(user, latest.Time.T(), time.Now())
|
||||||
|
}
|
||||||
|
if importError != nil {
|
||||||
|
conf.Log().Error("wakatime import for user '%s' failed - %v", user.ID, importError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
batch := make([]*models.Heartbeat, 0)
|
batch := make([]*models.Heartbeat, 0, h.config.App.ImportBatchSize)
|
||||||
|
|
||||||
insert := func(batch []*models.Heartbeat) {
|
insert := func(batch []*models.Heartbeat) {
|
||||||
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||||
@ -543,10 +547,9 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
if len(batch) == h.config.App.ImportBatchSize {
|
if len(batch) == h.config.App.ImportBatchSize {
|
||||||
insert(batch)
|
insert(batch)
|
||||||
batch = make([]*models.Heartbeat, 0)
|
batch = make([]*models.Heartbeat, 0, h.config.App.ImportBatchSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(batch) > 0 {
|
if len(batch) > 0 {
|
||||||
insert(batch)
|
insert(batch)
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
|||||||
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
|
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
|
||||||
for _, hb := range heartbeats {
|
for _, hb := range heartbeats {
|
||||||
if !hashes.Contain(hb.Hash) {
|
if !hashes.Contain(hb.Hash) {
|
||||||
|
hb = hb.Sanitize()
|
||||||
filteredHeartbeats = append(filteredHeartbeats, hb)
|
filteredHeartbeats = append(filteredHeartbeats, hb)
|
||||||
hashes.Add(hb.Hash)
|
hashes.Add(hb.Hash)
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HeartbeatImporter interface {
|
type DataImporter interface {
|
||||||
Import(*models.User, time.Time, time.Time) <-chan *models.Heartbeat
|
Import(*models.User, time.Time, time.Time) (<-chan *models.Heartbeat, error)
|
||||||
ImportAll(*models.User) <-chan *models.Heartbeat
|
ImportAll(*models.User) (<-chan *models.Heartbeat, error)
|
||||||
}
|
}
|
||||||
|
@ -1,342 +1,30 @@
|
|||||||
package imports
|
package imports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/duke-git/lancet/v2/datetime"
|
|
||||||
"github.com/muety/artifex/v2"
|
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
"strings"
|
||||||
"go.uber.org/atomic"
|
"time"
|
||||||
"golang.org/x/sync/semaphore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const OriginWakatime = "wakatime"
|
type WakatimeImporter struct {
|
||||||
const (
|
apiKey string
|
||||||
// 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
|
|
||||||
httpClient *http.Client
|
|
||||||
queue *artifex.Dispatcher
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
|
func NewWakatimeImporter(apiKey string) *WakatimeImporter {
|
||||||
return &WakatimeHeartbeatImporter{
|
return &WakatimeImporter{apiKey: apiKey}
|
||||||
ApiKey: apiKey,
|
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
queue: config.GetQueue(config.QueueImports),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
|
func (w *WakatimeImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) (<-chan *models.Heartbeat, error) {
|
||||||
out := make(chan *models.Heartbeat)
|
if strings.Contains(user.WakaTimeURL(config.WakatimeApiUrl), "wakatime.com") {
|
||||||
|
return NewWakatimeDumpImporter(w.apiKey).Import(user, minFrom, maxTo)
|
||||||
process := func(user *models.User, minFrom time.Time, maxTo time.Time, out chan *models.Heartbeat) {
|
|
||||||
logbuch.Info("running wakatime import for user '%s'", user.ID)
|
|
||||||
|
|
||||||
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
|
|
||||||
|
|
||||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
|
||||||
if err != nil {
|
|
||||||
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return NewWakatimeHeartbeatImporter(w.apiKey).Import(user, minFrom, maxTo)
|
||||||
if startDate.Before(minFrom) {
|
|
||||||
startDate = minFrom
|
|
||||||
}
|
|
||||||
if endDate.After(maxTo) {
|
|
||||||
endDate = maxTo
|
|
||||||
}
|
|
||||||
|
|
||||||
userAgents := map[string]*wakatime.UserAgentEntry{}
|
|
||||||
if data, err := w.fetchUserAgents(baseUrl); err == nil {
|
|
||||||
userAgents = data
|
|
||||||
} else if strings.Contains(baseUrl, "wakatime.com") {
|
|
||||||
// when importing from wakatime, resolving user agents is mandatorily required
|
|
||||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
machinesNames := map[string]*wakatime.MachineEntry{}
|
|
||||||
if data, err := w.fetchMachineNames(baseUrl); err == nil {
|
|
||||||
machinesNames = data
|
|
||||||
} else if strings.Contains(baseUrl, "wakatime.com") {
|
|
||||||
// when importing from wakatime, resolving machine names is mandatorily required
|
|
||||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
days := generateDays(startDate, endDate)
|
|
||||||
|
|
||||||
c := atomic.NewUint32(uint32(len(days)))
|
|
||||||
ctx := context.TODO()
|
|
||||||
sem := semaphore.NewWeighted(maxWorkers)
|
|
||||||
|
|
||||||
for _, d := range days {
|
|
||||||
if err := sem.Acquire(ctx, 1); err != nil {
|
|
||||||
logbuch.Error("failed to acquire semaphore - %v", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
go func(day time.Time) {
|
|
||||||
defer sem.Release(1)
|
|
||||||
defer time.Sleep(throttleDelay)
|
|
||||||
|
|
||||||
d := day.Format(config.SimpleDateFormat)
|
|
||||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
|
||||||
if err != nil {
|
|
||||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - %v", d, user.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range heartbeats {
|
|
||||||
out <- mapHeartbeat(h, userAgents, machinesNames, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Dec() == 0 {
|
|
||||||
close(out)
|
|
||||||
}
|
|
||||||
}(d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if minDataAge := user.MinDataAge(); minFrom.Before(minDataAge) {
|
|
||||||
logbuch.Info("wakatime data import for user '%s' capped to [%v, &v]", user.ID, minDataAge, maxTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
logbuch.Info("scheduling wakatime import for user '%s' (interval [%v, %v])", user.ID, minFrom, maxTo)
|
|
||||||
if err := w.queue.Dispatch(func() {
|
|
||||||
process(user, minFrom, maxTo, out)
|
|
||||||
}); err != nil {
|
|
||||||
config.Log().Error("failed to dispatch wakatime import job for user '%s', %v", user.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.Heartbeat {
|
func (w *WakatimeImporter) ImportAll(user *models.User) (<-chan *models.Heartbeat, error) {
|
||||||
return w.Import(user, time.Time{}, time.Now())
|
if strings.Contains(user.WakaTimeURL(config.WakatimeApiUrl), "wakatime.com") {
|
||||||
}
|
return NewWakatimeDumpImporter(w.apiKey).ImportAll(user)
|
||||||
|
}
|
||||||
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
return NewWakatimeHeartbeatImporter(w.apiKey).ImportAll(user)
|
||||||
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
|
||||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Add("date", day)
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
var empty []*wakatime.HeartbeatEntry
|
|
||||||
|
|
||||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
|
||||||
if err != nil {
|
|
||||||
return empty, err
|
|
||||||
} else if res.StatusCode == 402 {
|
|
||||||
return empty, nil // date outside free plan range -> return empty data, but do not throw error
|
|
||||||
} else if res.StatusCode >= 400 {
|
|
||||||
return empty, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
|
||||||
return empty, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return heartbeatsData.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
|
||||||
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
|
||||||
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
|
||||||
notime := time.Time{}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
|
|
||||||
if err != nil {
|
|
||||||
return notime, notime, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
|
||||||
if err != nil {
|
|
||||||
return notime, notime, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// see https://github.com/muety/wakapi/issues/370
|
|
||||||
allTimeData, err := utils.ParseJsonDropKeys[wakatime.AllTimeViewModel](res.Body, "text")
|
|
||||||
if err != nil {
|
|
||||||
return notime, notime, err
|
|
||||||
}
|
|
||||||
|
|
||||||
startDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.StartDate)
|
|
||||||
if err != nil {
|
|
||||||
return notime, notime, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.EndDate)
|
|
||||||
if err != nil {
|
|
||||||
return notime, notime, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return startDate, endDate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/user_agents
|
|
||||||
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
|
||||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
|
||||||
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
|
||||||
|
|
||||||
for page := 1; ; page++ {
|
|
||||||
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiUserAgentsUrl, page)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var userAgentsData wakatime.UserAgentsViewModel
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ua := range userAgentsData.Data {
|
|
||||||
userAgents[ua.Id] = ua
|
|
||||||
}
|
|
||||||
|
|
||||||
if page == userAgentsData.TotalPages {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return userAgents, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/machine_names
|
|
||||||
// https://pastr.de/p/v58cv0xrupp3zvyyv8o6973j
|
|
||||||
func (w *WakatimeHeartbeatImporter) fetchMachineNames(baseUrl string) (map[string]*wakatime.MachineEntry, error) {
|
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
machines := make(map[string]*wakatime.MachineEntry)
|
|
||||||
|
|
||||||
for page := 1; ; page++ {
|
|
||||||
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiMachineNamesUrl, page)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := httpClient.Do(w.withHeaders(req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var machineData wakatime.MachineViewModel
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ma := range machineData.Data {
|
|
||||||
machines[ma.Id] = ma
|
|
||||||
}
|
|
||||||
|
|
||||||
if page == machineData.TotalPages {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return machines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapHeartbeat(
|
|
||||||
entry *wakatime.HeartbeatEntry,
|
|
||||||
userAgents map[string]*wakatime.UserAgentEntry,
|
|
||||||
machineNames map[string]*wakatime.MachineEntry,
|
|
||||||
user *models.User,
|
|
||||||
) *models.Heartbeat {
|
|
||||||
ua := userAgents[entry.UserAgentId]
|
|
||||||
if ua == nil {
|
|
||||||
// try to parse id as an actual user agent string (as returned by wakapi)
|
|
||||||
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
|
|
||||||
ua = &wakatime.UserAgentEntry{
|
|
||||||
Editor: editor,
|
|
||||||
Os: opSys,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ua = &wakatime.UserAgentEntry{
|
|
||||||
Editor: "unknown",
|
|
||||||
Os: "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ma := machineNames[entry.MachineNameId]
|
|
||||||
if ma == nil {
|
|
||||||
ma = &wakatime.MachineEntry{
|
|
||||||
Id: entry.MachineNameId,
|
|
||||||
Value: entry.MachineNameId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (&models.Heartbeat{
|
|
||||||
User: user,
|
|
||||||
UserID: user.ID,
|
|
||||||
Entity: entry.Entity,
|
|
||||||
Type: entry.Type,
|
|
||||||
Category: entry.Category,
|
|
||||||
Project: entry.Project,
|
|
||||||
Branch: entry.Branch,
|
|
||||||
Language: entry.Language,
|
|
||||||
IsWrite: entry.IsWrite,
|
|
||||||
Editor: ua.Editor,
|
|
||||||
OperatingSystem: ua.Os,
|
|
||||||
Machine: ma.Value,
|
|
||||||
UserAgent: ua.Value,
|
|
||||||
Time: models.CustomTime(time.Unix(0, int64(entry.Time*1e9))),
|
|
||||||
Origin: OriginWakatime,
|
|
||||||
OriginId: entry.Id,
|
|
||||||
CreatedAt: models.CustomTime(entry.CreatedAt),
|
|
||||||
}).Hashed()
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateDays(from, to time.Time) []time.Time {
|
|
||||||
days := make([]time.Time, 0)
|
|
||||||
|
|
||||||
from = datetime.BeginOfDay(from)
|
|
||||||
to = datetime.BeginOfDay(to.AddDate(0, 0, 1))
|
|
||||||
|
|
||||||
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
|
|
||||||
days = append(days, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
return days
|
|
||||||
}
|
}
|
||||||
|
155
services/imports/wakatime_dump.go
Normal file
155
services/imports/wakatime_dump.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// data example: https://github.com/muety/wakapi/issues/323#issuecomment-1627467052
|
||||||
|
|
||||||
|
type WakatimeDumpImporter struct {
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
queue *artifex.Dispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWakatimeDumpImporter(apiKey string) *WakatimeDumpImporter {
|
||||||
|
return &WakatimeDumpImporter{
|
||||||
|
apiKey: apiKey,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
queue: config.GetQueue(config.QueueImports),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeDumpImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) (<-chan *models.Heartbeat, error) {
|
||||||
|
out := make(chan *models.Heartbeat)
|
||||||
|
logbuch.Info("running wakatime dump import for user '%s'", user.ID)
|
||||||
|
|
||||||
|
url := config.WakatimeApiUrl + config.WakatimeApiDataDumpUrl // this importer only works with wakatime currently, so no point in using user's custom wakatime api url
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte(`{ "type": "heartbeats", "email_when_finished": false }`)))
|
||||||
|
res, err := utils.RaiseForStatus(w.httpClient.Do(w.withHeaders(req)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var datadumpData wakatime.DataDumpResultViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&datadumpData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var readyPollTimer *artifex.DispatchTicker
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
checkDumpReady := func(dumpId string, user *models.User) (bool, *wakatime.DataDumpData, error) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
res, err := utils.RaiseForStatus(w.httpClient.Do(w.withHeaders(req)))
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var datadumpData wakatime.DataDumpViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&datadumpData); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dump, ok := slice.FindBy[*wakatime.DataDumpData](datadumpData.Data, func(i int, item *wakatime.DataDumpData) bool {
|
||||||
|
return item.Id == dumpId
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return false, nil, errors.New(fmt.Sprintf("data dump with id '%s' for user '%s' not found", dumpId, user.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dump.Status == "Completed", dump, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
onDumpFailed := func(err error, user *models.User) {
|
||||||
|
config.Log().Error("fetching data dump for user '%s' failed - %v", user.ID, err)
|
||||||
|
readyPollTimer.Stop()
|
||||||
|
close(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDumpReady := func(dump *wakatime.DataDumpData, user *models.User, out chan *models.Heartbeat) {
|
||||||
|
config.Log().Info("data dump for user '%s' is available for download", user.ID)
|
||||||
|
readyPollTimer.Stop()
|
||||||
|
defer close(out)
|
||||||
|
|
||||||
|
// download
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, dump.DownloadUrl, nil)
|
||||||
|
res, err := utils.RaiseForStatus((&http.Client{Timeout: 5 * time.Minute}).Do(req))
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to download %s - %v", dump.DownloadUrl, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
logbuch.Info("fetched %d bytes data dump for user '%s'", res.ContentLength, user.ID)
|
||||||
|
|
||||||
|
// decode
|
||||||
|
var data wakatime.JsonExportViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
|
||||||
|
config.Log().Error("failed to decode data dump for user '%s' ('%s') - %v", user.ID, dump.DownloadUrl, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch user agents and machine names
|
||||||
|
var userAgents map[string]*wakatime.UserAgentEntry
|
||||||
|
if userAgents, err = fetchUserAgents(config.WakatimeApiUrl, w.apiKey); err != nil {
|
||||||
|
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var machinesNames map[string]*wakatime.MachineEntry
|
||||||
|
if machinesNames, err = fetchMachineNames(config.WakatimeApiUrl, w.apiKey); err != nil {
|
||||||
|
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream
|
||||||
|
for _, d := range data.Days {
|
||||||
|
for _, h := range d.Heartbeats {
|
||||||
|
hb := mapHeartbeat(h, userAgents, machinesNames, user)
|
||||||
|
if hb.Time.T().Before(minFrom) || hb.Time.T().After(maxTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out <- hb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start polling for dump to be ready
|
||||||
|
readyPollTimer, err = w.queue.DispatchEvery(func() {
|
||||||
|
u := *user
|
||||||
|
ok, dump, err := checkDumpReady(datadumpData.Data.Id, &u)
|
||||||
|
logbuch.Info("waiting for data dump '%s' for user '%s' to become downloadable (%.2f percent complete)", datadumpData.Data.Id, u.ID, dump.PercentComplete)
|
||||||
|
if err != nil {
|
||||||
|
onDumpFailed(err, &u)
|
||||||
|
} else if ok {
|
||||||
|
onDumpReady(dump, &u, out)
|
||||||
|
}
|
||||||
|
}, 10*time.Second)
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeDumpImporter) ImportAll(user *models.User) (<-chan *models.Heartbeat, error) {
|
||||||
|
return w.Import(user, time.Time{}, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeDumpImporter) withHeaders(req *http.Request) *http.Request {
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.apiKey))))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
return req
|
||||||
|
}
|
223
services/imports/wakatime_heartbeats.go
Normal file
223
services/imports/wakatime_heartbeats.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/duke-git/lancet/v2/datetime"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
|
"go.uber.org/atomic"
|
||||||
|
"golang.org/x/sync/semaphore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const OriginWakatime = "wakatime"
|
||||||
|
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 WakatimeHeartbeatsImporter struct {
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
queue *artifex.Dispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatsImporter {
|
||||||
|
return &WakatimeHeartbeatsImporter{
|
||||||
|
apiKey: apiKey,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
queue: config.GetQueue(config.QueueImports),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeHeartbeatsImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) (<-chan *models.Heartbeat, error) {
|
||||||
|
out := make(chan *models.Heartbeat)
|
||||||
|
|
||||||
|
process := func(user *models.User, minFrom time.Time, maxTo time.Time, out chan *models.Heartbeat) {
|
||||||
|
logbuch.Info("running wakatime import for user '%s'", user.ID)
|
||||||
|
|
||||||
|
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
|
||||||
|
|
||||||
|
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate.Before(minFrom) {
|
||||||
|
startDate = minFrom
|
||||||
|
}
|
||||||
|
if endDate.After(maxTo) {
|
||||||
|
endDate = maxTo
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgents := map[string]*wakatime.UserAgentEntry{}
|
||||||
|
if data, err := fetchUserAgents(baseUrl, w.apiKey); err == nil {
|
||||||
|
userAgents = data
|
||||||
|
} else if strings.Contains(baseUrl, "wakatime.com") {
|
||||||
|
// when importing from wakatime, resolving user agents is mandatorily required
|
||||||
|
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
machinesNames := map[string]*wakatime.MachineEntry{}
|
||||||
|
if data, err := fetchMachineNames(baseUrl, w.apiKey); err == nil {
|
||||||
|
machinesNames = data
|
||||||
|
} else if strings.Contains(baseUrl, "wakatime.com") {
|
||||||
|
// when importing from wakatime, resolving machine names is mandatorily required
|
||||||
|
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days := generateDays(startDate, endDate)
|
||||||
|
|
||||||
|
c := atomic.NewUint32(uint32(len(days)))
|
||||||
|
ctx := context.TODO()
|
||||||
|
sem := semaphore.NewWeighted(maxWorkers)
|
||||||
|
|
||||||
|
for _, d := range days {
|
||||||
|
if err := sem.Acquire(ctx, 1); err != nil {
|
||||||
|
logbuch.Error("failed to acquire semaphore - %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(day time.Time) {
|
||||||
|
defer sem.Release(1)
|
||||||
|
defer time.Sleep(throttleDelay)
|
||||||
|
|
||||||
|
d := day.Format(config.SimpleDateFormat)
|
||||||
|
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - %v", d, user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range heartbeats {
|
||||||
|
hb := mapHeartbeat(h, userAgents, machinesNames, user)
|
||||||
|
if hb.Time.T().Before(minFrom) || hb.Time.T().After(maxTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out <- hb
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Dec() == 0 {
|
||||||
|
close(out)
|
||||||
|
}
|
||||||
|
}(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if minDataAge := user.MinDataAge(); minFrom.Before(minDataAge) {
|
||||||
|
logbuch.Info("wakatime data import for user '%s' capped to [%v, &v]", user.ID, minDataAge, maxTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("scheduling wakatime import for user '%s' (interval [%v, %v])", user.ID, minFrom, maxTo)
|
||||||
|
if err := w.queue.Dispatch(func() {
|
||||||
|
process(user, minFrom, maxTo, out)
|
||||||
|
}); err != nil {
|
||||||
|
config.Log().Error("failed to dispatch wakatime import job for user '%s', %v", user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeHeartbeatsImporter) ImportAll(user *models.User) (<-chan *models.Heartbeat, error) {
|
||||||
|
return w.Import(user, time.Time{}, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||||
|
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||||
|
func (w *WakatimeHeartbeatsImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("date", day)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
var empty []*wakatime.HeartbeatEntry
|
||||||
|
|
||||||
|
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||||
|
if err != nil {
|
||||||
|
return empty, err
|
||||||
|
} else if res.StatusCode == 402 {
|
||||||
|
return empty, nil // date outside free plan range -> return empty data, but do not throw error
|
||||||
|
} else if res.StatusCode >= 400 {
|
||||||
|
return empty, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
||||||
|
return empty, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return heartbeatsData.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
||||||
|
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||||
|
func (w *WakatimeHeartbeatsImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
||||||
|
notime := time.Time{}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// see https://github.com/muety/wakapi/issues/370
|
||||||
|
allTimeData, err := utils.ParseJsonDropKeys[wakatime.AllTimeViewModel](res.Body, "text")
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return startDate, endDate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeHeartbeatsImporter) withHeaders(req *http.Request) *http.Request {
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.apiKey))))
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDays(from, to time.Time) []time.Time {
|
||||||
|
days := make([]time.Time, 0)
|
||||||
|
|
||||||
|
from = datetime.BeginOfDay(from)
|
||||||
|
to = datetime.BeginOfDay(to.AddDate(0, 0, 1))
|
||||||
|
|
||||||
|
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
140
services/imports/wakatime_utils.go
Normal file
140
services/imports/wakatime_utils.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/machine_names
|
||||||
|
// https://pastr.de/p/v58cv0xrupp3zvyyv8o6973j
|
||||||
|
func fetchMachineNames(baseUrl, apiKey string) (map[string]*wakatime.MachineEntry, error) {
|
||||||
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
machines := make(map[string]*wakatime.MachineEntry)
|
||||||
|
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiMachineNamesUrl, page)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(apiKey))))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var machineData wakatime.MachineViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ma := range machineData.Data {
|
||||||
|
machines[ma.Id] = ma
|
||||||
|
}
|
||||||
|
|
||||||
|
if page == machineData.TotalPages {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return machines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/user_agents
|
||||||
|
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||||
|
func fetchUserAgents(baseUrl, apiKey string) (map[string]*wakatime.UserAgentEntry, error) {
|
||||||
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||||
|
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
url := fmt.Sprintf("%s%s?page=%d", baseUrl, config.WakatimeApiUserAgentsUrl, page)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(apiKey))))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var userAgentsData wakatime.UserAgentsViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ua := range userAgentsData.Data {
|
||||||
|
userAgents[ua.Id] = ua
|
||||||
|
}
|
||||||
|
|
||||||
|
if page == userAgentsData.TotalPages {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userAgents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapHeartbeat(
|
||||||
|
entry *wakatime.HeartbeatEntry,
|
||||||
|
userAgents map[string]*wakatime.UserAgentEntry,
|
||||||
|
machineNames map[string]*wakatime.MachineEntry,
|
||||||
|
user *models.User,
|
||||||
|
) *models.Heartbeat {
|
||||||
|
ua := userAgents[entry.UserAgentId]
|
||||||
|
if ua == nil {
|
||||||
|
// try to parse id as an actual user agent string (as returned by wakapi)
|
||||||
|
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
|
||||||
|
ua = &wakatime.UserAgentEntry{
|
||||||
|
Editor: editor,
|
||||||
|
Os: opSys,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ua = &wakatime.UserAgentEntry{
|
||||||
|
Editor: "unknown",
|
||||||
|
Os: "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ma := machineNames[entry.MachineNameId]
|
||||||
|
if ma == nil {
|
||||||
|
ma = &wakatime.MachineEntry{
|
||||||
|
Id: entry.MachineNameId,
|
||||||
|
Value: entry.MachineNameId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (&models.Heartbeat{
|
||||||
|
User: user,
|
||||||
|
UserID: user.ID,
|
||||||
|
Entity: entry.Entity,
|
||||||
|
Type: entry.Type,
|
||||||
|
Category: entry.Category,
|
||||||
|
Project: entry.Project,
|
||||||
|
Branch: entry.Branch,
|
||||||
|
Language: entry.Language,
|
||||||
|
IsWrite: entry.IsWrite,
|
||||||
|
Editor: ua.Editor,
|
||||||
|
OperatingSystem: ua.Os,
|
||||||
|
Machine: ma.Value,
|
||||||
|
UserAgent: ua.Value,
|
||||||
|
Time: models.CustomTime(time.Unix(0, int64(entry.Time*1e9))),
|
||||||
|
Origin: OriginWakatime,
|
||||||
|
OriginId: entry.Id,
|
||||||
|
CreatedAt: models.CustomTime(entry.CreatedAt),
|
||||||
|
}).Hashed()
|
||||||
|
}
|
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -58,13 +59,10 @@ func (s *MailWhaleSendingService) Send(mail *models.Mail) error {
|
|||||||
req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret)
|
req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
res, err := s.httpClient.Do(req)
|
_, err = utils.RaiseForStatus(s.httpClient.Do(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if res.StatusCode >= 400 {
|
|
||||||
return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/duke-git/lancet/v2/datetime"
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/leandro-lugaresi/hub"
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/artifex/v2"
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -100,17 +102,34 @@ func (srv *ReportService) SendReport(user *models.User, duration time.Duration)
|
|||||||
end := time.Now().In(user.TZ())
|
end := time.Now().In(user.TZ())
|
||||||
start := time.Now().Add(-1 * duration)
|
start := time.Now().Add(-1 * duration)
|
||||||
|
|
||||||
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
|
fullSummary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to generate report for '%s' - %v", user.ID, err)
|
config.Log().Error("failed to generate report for '%s' - %v", user.ID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generate per-day summaries
|
||||||
|
dayIntervals := utils.SplitRangeByDays(start, end)
|
||||||
|
dailySummaries := make([]*models.Summary, len(dayIntervals))
|
||||||
|
|
||||||
|
for i, interval := range dayIntervals {
|
||||||
|
from, to := datetime.BeginOfDay(interval[0]), interval[1]
|
||||||
|
summary, err := srv.summaryService.Aliased(from, to, user, srv.summaryService.Retrieve, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to generate day summary (%v to %v) for report for '%s' - %v", from, to, user.ID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
summary.FromTime = models.CustomTime(from)
|
||||||
|
summary.ToTime = models.CustomTime(to.Add(-1 * time.Second))
|
||||||
|
dailySummaries[i] = summary
|
||||||
|
}
|
||||||
|
|
||||||
report := &models.Report{
|
report := &models.Report{
|
||||||
From: start,
|
From: start,
|
||||||
To: end,
|
To: end,
|
||||||
User: user,
|
User: user,
|
||||||
Summary: summary,
|
Summary: fullSummary,
|
||||||
|
DailySummaries: dailySummaries,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := srv.mailService.SendReport(user, report); err != nil {
|
if err := srv.mailService.SendReport(user, report); err != nil {
|
||||||
|
@ -139,7 +139,6 @@ type IUserService interface {
|
|||||||
Delete(*models.User) error
|
Delete(*models.User) error
|
||||||
ResetApiKey(*models.User) (*models.User, error)
|
ResetApiKey(*models.User) (*models.User, error)
|
||||||
SetWakatimeApiCredentials(*models.User, string, string) (*models.User, error)
|
SetWakatimeApiCredentials(*models.User, string, string) (*models.User, error)
|
||||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
|
||||||
GenerateResetToken(*models.User) (*models.User, error)
|
GenerateResetToken(*models.User) (*models.User, error)
|
||||||
FlushCache()
|
FlushCache()
|
||||||
FlushUserCache(string)
|
FlushUserCache(string)
|
||||||
|
@ -157,7 +157,7 @@ func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*model
|
|||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash, err := utils.HashBcrypt(u.Password, srv.config.Security.PasswordSalt); err != nil {
|
if hash, err := utils.HashPassword(u.Password, srv.config.Security.PasswordSalt); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
} else {
|
} else {
|
||||||
u.Password = hash
|
u.Password = hash
|
||||||
@ -194,17 +194,6 @@ func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey stri
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
|
||||||
srv.FlushUserCache(user.ID)
|
|
||||||
user.Password = login.Password
|
|
||||||
if hash, err := utils.HashBcrypt(user.Password, srv.config.Security.PasswordSalt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else {
|
|
||||||
user.Password = hash
|
|
||||||
}
|
|
||||||
return srv.repository.UpdateField(user, "password", user.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, error) {
|
func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, error) {
|
||||||
return srv.repository.UpdateField(user, "reset_token", uuid.NewV4())
|
return srv.repository.UpdateField(user, "reset_token", uuid.NewV4())
|
||||||
}
|
}
|
||||||
|
39
testing/config.cockroach.yml
Normal file
39
testing/config.cockroach.yml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
env: production
|
||||||
|
|
||||||
|
server:
|
||||||
|
listen_ipv4: 127.0.0.1
|
||||||
|
listen_ipv6:
|
||||||
|
tls_cert_path:
|
||||||
|
tls_key_path:
|
||||||
|
port: 3000
|
||||||
|
base_path: /
|
||||||
|
public_url: http://localhost:3000
|
||||||
|
|
||||||
|
app:
|
||||||
|
aggregation_time: '02:15'
|
||||||
|
report_time_weekly: 'fri,18:00'
|
||||||
|
heartbeat_max_age: 87600h # 10 years
|
||||||
|
inactive_days: 7
|
||||||
|
custom_languages:
|
||||||
|
vue: Vue
|
||||||
|
jsx: JSX
|
||||||
|
svelte: Svelte
|
||||||
|
|
||||||
|
db:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 56257
|
||||||
|
user: root
|
||||||
|
password:
|
||||||
|
name: defaultdb
|
||||||
|
dialect: cockroach
|
||||||
|
charset:
|
||||||
|
max_conn: 2
|
||||||
|
ssl: false
|
||||||
|
automgirate_fail_silently: false
|
||||||
|
|
||||||
|
security:
|
||||||
|
password_salt:
|
||||||
|
insecure_cookies: true
|
||||||
|
cookie_max_age: 172800
|
||||||
|
allow_signup: true
|
||||||
|
expose_metrics: true
|
@ -9,8 +9,6 @@ services:
|
|||||||
POSTGRES_DB: "wakapi"
|
POSTGRES_DB: "wakapi"
|
||||||
PGPORT: 55432
|
PGPORT: 55432
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
|
||||||
- wakapi-postgres:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8
|
image: mysql:8
|
||||||
@ -21,8 +19,6 @@ services:
|
|||||||
MYSQL_DATABASE: "wakapi"
|
MYSQL_DATABASE: "wakapi"
|
||||||
MYSQL_ROOT_PASSWORD: example
|
MYSQL_ROOT_PASSWORD: example
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
|
||||||
- wakapi-mysql:/var/lib/mysql
|
|
||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
@ -33,10 +29,8 @@ services:
|
|||||||
MARIADB_DATABASE: "wakapi"
|
MARIADB_DATABASE: "wakapi"
|
||||||
MARIADB_ROOT_PASSWORD: example
|
MARIADB_ROOT_PASSWORD: example
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
|
||||||
- wakapi-mariadb:/var/lib/mysql
|
|
||||||
|
|
||||||
volumes:
|
cockroach:
|
||||||
wakapi-postgres: {}
|
image: cockroachdb/cockroach
|
||||||
wakapi-mysql: {}
|
entrypoint: '/cockroach/cockroach start-single-node --insecure --sql-addr=:56257'
|
||||||
wakapi-mariadb: {}
|
network_mode: host
|
||||||
|
@ -46,9 +46,8 @@ trap cleanup EXIT
|
|||||||
|
|
||||||
# Initialise test data
|
# Initialise test data
|
||||||
case $1 in
|
case $1 in
|
||||||
postgres|mysql|mariadb)
|
postgres|mysql|mariadb|cockroach)
|
||||||
docker compose -f "$script_dir/docker-compose.yml" down
|
docker compose -f "$script_dir/docker-compose.yml" down
|
||||||
docker volume rm "testing_wakapi-$1"
|
|
||||||
|
|
||||||
docker_down=1
|
docker_down=1
|
||||||
docker compose -f "$script_dir/docker-compose.yml" up --wait -d "$1"
|
docker compose -f "$script_dir/docker-compose.yml" up --wait -d "$1"
|
||||||
@ -61,8 +60,10 @@ case $1 in
|
|||||||
db_port=0
|
db_port=0
|
||||||
if [ "$1" == "postgres" ]; then
|
if [ "$1" == "postgres" ]; then
|
||||||
db_port=55432
|
db_port=55432
|
||||||
|
elif [ "$1" == "cockroach" ]; then
|
||||||
|
db_port=56257
|
||||||
else
|
else
|
||||||
db_port=53306
|
db_port=26257
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for _ in $(seq 0 30); do
|
for _ in $(seq 0 30); do
|
||||||
@ -90,8 +91,8 @@ wait_for_wakapi () {
|
|||||||
counter=0
|
counter=0
|
||||||
echo "Waiting for Wakapi to come up ..."
|
echo "Waiting for Wakapi to come up ..."
|
||||||
until curl --output /dev/null --silent --get --fail http://localhost:3000/api/health; do
|
until curl --output /dev/null --silent --get --fail http://localhost:3000/api/health; do
|
||||||
if [ "$counter" -ge 5 ]; then
|
if [ "$counter" -ge 30 ]; then
|
||||||
echo "Waited for 5s, but Wakapi failed to come up ..."
|
echo "Waited for 30s, but Wakapi failed to come up ..."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/alexedwards/argon2id"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@ -42,9 +43,22 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) {
|
|||||||
return string(keyBytes), err
|
return string(keyBytes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func CompareBcrypt(wanted, actual, pepper string) bool {
|
// password hashing
|
||||||
plainPassword := []byte(strings.TrimSpace(actual) + pepper)
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(wanted), plainPassword)
|
func ComparePassword(hashed, plain, pepper string) bool {
|
||||||
|
if hashed[0:10] == "$argon2id$" {
|
||||||
|
return CompareArgon2Id(hashed, plain, pepper)
|
||||||
|
}
|
||||||
|
return CompareBcrypt(hashed, plain, pepper)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(plain, pepper string) (string, error) {
|
||||||
|
return HashArgon2Id(plain, pepper)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompareBcrypt(hashed, plain, pepper string) bool {
|
||||||
|
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashed), plainPepperedPassword)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,3 +70,18 @@ func HashBcrypt(plain, pepper string) (string, error) {
|
|||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CompareArgon2Id(hashed, plain, pepper string) bool {
|
||||||
|
plainPepperedPassword := strings.TrimSpace(plain) + pepper
|
||||||
|
match, err := argon2id.ComparePasswordAndHash(plainPepperedPassword, hashed)
|
||||||
|
return err == nil && match
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashArgon2Id(plain, pepper string) (string, error) {
|
||||||
|
plainPepperedPassword := strings.TrimSpace(plain) + pepper
|
||||||
|
hash, err := argon2id.CreateHash(plainPepperedPassword, argon2id.DefaultParams)
|
||||||
|
if err == nil {
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
@ -55,6 +55,12 @@ func TestCommon_ParseUserAgent(t *testing.T) {
|
|||||||
"chrome",
|
"chrome",
|
||||||
nil,
|
nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Chrome/114.0.0.0 linux_x86-64 chrome-wakatime/3.0.17",
|
||||||
|
"linux",
|
||||||
|
"chrome",
|
||||||
|
nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
@ -2,6 +2,7 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -78,10 +79,20 @@ func ParsePageParamsWithDefault(r *http.Request, page, size int) *PageParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ParseUserAgent(ua string) (string, string, error) {
|
func ParseUserAgent(ua string) (string, string, error) {
|
||||||
re := regexp.MustCompile(`(?iU)^(?:(?:wakatime|chrome|firefox)\/(?:v?[\d+.]+|unset)\s)?(?:\((\w+)-.*\)\s.+\s)?([^\/\s]+)-wakatime\/.+$`)
|
re := regexp.MustCompile(`(?iU)^(?:(?:wakatime|chrome|firefox)\/(?:v?[\d+.]+|unset)\s)?(?:\(?(\w+)[-_].*\)?.+\s)?([^\/\s]+)-wakatime\/.+$`)
|
||||||
groups := re.FindAllStringSubmatch(ua, -1)
|
groups := re.FindAllStringSubmatch(ua, -1)
|
||||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
if len(groups) == 0 || len(groups[0]) != 3 {
|
||||||
return "", "", errors.New("failed to parse user agent string")
|
return "", "", errors.New("failed to parse user agent string")
|
||||||
}
|
}
|
||||||
return groups[0][1], groups[0][2], nil
|
return groups[0][1], groups[0][2], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RaiseForStatus(res *http.Response, err error) (*http.Response, error) {
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if res.StatusCode >= 400 {
|
||||||
|
return res, fmt.Errorf("got response status %d for '%s %s'", res.StatusCode, res.Request.Method, res.Request.URL.String())
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Capitalize(s string) string {
|
|
||||||
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func SplitMulti(s string, delimiters ...string) []string {
|
func SplitMulti(s string, delimiters ...string) []string {
|
||||||
return strings.FieldsFunc(s, func(r rune) bool {
|
return strings.FieldsFunc(s, func(r rune) bool {
|
||||||
for _, d := range delimiters {
|
for _, d := range delimiters {
|
||||||
|
@ -32,6 +32,20 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{{ if len .Report.DailySummaries }}
|
||||||
|
<p style="font-family: sans-serif; font-size: 16px; font-weight: 500; margin: 0; Margin-bottom: 15px; Margin-top: 30px;">Weekdays</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>
|
||||||
|
{{ range $i, $summary := .Report.DailySummaries }}
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="width: 300px; font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px; font-weight: 800;">{{ $summary.FromTime.T | date }}:</td>
|
||||||
|
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">{{ $summary.TotalTime | duration }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<p style="font-family: sans-serif; font-size: 16px; font-weight: 500; margin: 0; Margin-bottom: 15px; Margin-top: 30px;">Languages</p>
|
<p style="font-family: sans-serif; font-size: 16px; font-weight: 500; margin: 0; Margin-bottom: 15px; Margin-top: 30px;">Languages</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;">
|
<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>
|
<tbody>
|
||||||
|
Loading…
Reference in New Issue
Block a user