mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
05bc55a488 | ||
|
a9364e3d9e | ||
|
ec65847d0c | ||
|
eca443be35 | ||
|
04ec44dcef | ||
|
938290b2da | ||
|
c8b88ccef5 | ||
|
bc2d05bd85 | ||
|
3785867c3a | ||
|
56de275781 | ||
|
583ddcab7a | ||
|
7b0bbcefe6 | ||
|
5f1ca4ed69 | ||
|
c06b2b8aca | ||
|
45a003185e | ||
|
3063e80692 | ||
|
38286c7f3a | ||
|
37e6acd058 | ||
|
07b24fe3b1 | ||
|
78f327dbeb | ||
|
2af82f529a | ||
|
5278dba4f4 | ||
|
35ef323b19 | ||
|
a8e2bc671d | ||
|
7b60c44ac6 | ||
|
055d006379 | ||
|
1a6ee55d14 | ||
|
74390bfccf | ||
|
8de56a4c7b | ||
|
a6915a187a | ||
|
df25183035 | ||
|
b33c71b41f | ||
|
a9e1c4b589 | ||
|
dc4eefbede | ||
|
a20456bb8e | ||
|
44c481b9e0 | ||
|
beced39923 | ||
|
083fbf8633 | ||
|
ca3320b174 | ||
|
406f5147c8 | ||
|
d061a4ef1b | ||
|
65c2d9a17f | ||
|
de2702241b |
BIN
.github/assets/screenshot_browser_plugin.png
vendored
Normal file
BIN
.github/assets/screenshot_browser_plugin.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
33
.github/workflows/ci.yml
vendored
33
.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
|
||||||
|
@ -75,33 +75,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
sarif_file: mapi.sarif
|
sarif_file: mapi.sarif
|
||||||
|
|
||||||
build:
|
|
||||||
name: 'Build (Win, Linux, Mac)'
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
platform: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Set up Go 1.x
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: ^1.19
|
|
||||||
id: go
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: go get
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build -v .
|
|
||||||
|
|
||||||
migration:
|
migration:
|
||||||
name: Migration tests
|
name: Migration tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -115,7 +88,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
|
||||||
|
|
19
.github/workflows/docker.yml
vendored
19
.github/workflows/docker.yml
vendored
|
@ -1,10 +1,9 @@
|
||||||
name: Publish Docker Image
|
name: Publish Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags:
|
types:
|
||||||
- '*.*.*'
|
- published
|
||||||
- '!*.*.*-*'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-publish:
|
docker-publish:
|
||||||
|
@ -21,19 +20,19 @@ jobs:
|
||||||
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
@ -41,7 +40,7 @@ jobs:
|
||||||
|
|
||||||
- name: Docker Metadata
|
- name: Docker Metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
@ -54,7 +53,7 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
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
|
||||||
|
|
46
README.md
46
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.
|
||||||
|
@ -258,7 +254,7 @@ Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing,
|
||||||
|
|
||||||
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example. To use this, don't forget to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
|
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example. To use this, don't forget to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
|
||||||
|
|
||||||
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
|
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact&range=last_7_days)
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Click to view code</summary>
|
<summary>Click to view code</summary>
|
||||||
|
@ -298,6 +294,44 @@ Preview:
|
||||||
</details>
|
</details>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
### Browser Plugin (Chrome & Firefox)
|
||||||
|
The [browser-wakatime](https://github.com/wakatime/browser-wakatime) plugin enables you to track your web surfing in WakaTime (and Wakapi, of course). Visited websites will appear as "files" in the summary. Follow these instructions to get started:
|
||||||
|
|
||||||
|
1. Install the browser extension from the official store ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/wakatimes), [Chrome](https://chrome.google.com/webstore/detail/wakatime/jnbbnacmeggbgdjgaoojpmhdlkkpblgi?hl=de))
|
||||||
|
2. Open the extension settings dialog
|
||||||
|
3. Configure it like so (see screenshot below):
|
||||||
|
* API Key: Your personal API key (get it at [wakapi.dev](https://wakapi.dev))
|
||||||
|
* Logging Type: _Only the domain_
|
||||||
|
* API URL: `https://wakapi.dev/api/compat/wakatime/v1` (alternatively, replace _wakapi.dev_ with your self-hosted instance hostname)
|
||||||
|
4. Save
|
||||||
|
5. Start browsing!
|
||||||
|
|
||||||
|
![](.github/assets/screenshot_browser_plugin.png)
|
||||||
|
|
||||||
|
Note: the plugin will only sync heartbeats once in a while, so it might take some time for them to appear on Wakapi. To "force" it to sync, simply bring up the plugin main dialog.
|
||||||
|
|
||||||
|
## 📦 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).
|
||||||
|
@ -462,4 +496,4 @@ Moreover, thanks to **[Frachtwerk](https://frachtwerk.de)** for sponsoring serve
|
||||||
|
|
||||||
## 📓 License
|
## 📓 License
|
||||||
|
|
||||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
MIT @ [Ferdinand Mütsch](https://muetsch.io)
|
||||||
|
|
|
@ -2,11 +2,9 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -17,11 +15,12 @@ import (
|
||||||
"github.com/jinzhu/configor"
|
"github.com/jinzhu/configor"
|
||||||
"github.com/muety/wakapi/data"
|
"github.com/muety/wakapi/data"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
uuid "github.com/satori/go.uuid"
|
uuid "github.com/satori/go.uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultConfigPath = "config.yml"
|
DefaultConfigPath = "config.yml"
|
||||||
|
|
||||||
SQLDialectMysql = "mysql"
|
SQLDialectMysql = "mysql"
|
||||||
SQLDialectPostgres = "postgres"
|
SQLDialectPostgres = "postgres"
|
||||||
|
@ -52,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 (
|
||||||
|
@ -65,7 +65,6 @@ var emailProviders = []string{
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg *Config
|
var cfg *Config
|
||||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
|
||||||
var env string
|
var env string
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
|
@ -344,7 +343,7 @@ func readColors() map[string]map[string]string {
|
||||||
|
|
||||||
raw := data.ColorsFile
|
raw := data.ColorsFile
|
||||||
if IsDev(env) {
|
if IsDev(env) {
|
||||||
raw, _ = ioutil.ReadFile("data/colors.json")
|
raw, _ = os.ReadFile("data/colors.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
var colors = make(map[string]map[string]string)
|
var colors = make(map[string]map[string]string)
|
||||||
|
@ -376,12 +375,10 @@ func Get() *Config {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(version string) *Config {
|
func Load(configFlag string, version string) *Config {
|
||||||
config := &Config{}
|
config := &Config{}
|
||||||
|
|
||||||
flag.Parse()
|
if err := configor.New(&configor.Config{}).Load(config, configFlag); err != nil {
|
||||||
|
|
||||||
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
|
|
||||||
logbuch.Fatal("failed to read config: %v", err)
|
logbuch.Fatal("failed to read config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,9 +407,7 @@ func Load(version string) *Config {
|
||||||
config.Security.SecureCookie = securecookie.New(hashKey, blockKey)
|
config.Security.SecureCookie = securecookie.New(hashKey, blockKey)
|
||||||
config.Security.SessionKey = sessionKey
|
config.Security.SessionKey = sessionKey
|
||||||
|
|
||||||
if strings.HasSuffix(config.Server.BasePath, "/") {
|
config.Server.BasePath = strings.TrimSuffix(config.Server.BasePath, "/")
|
||||||
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range config.App.CustomLanguages {
|
for k, v := range config.App.CustomLanguages {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|
|
@ -12,6 +12,7 @@ const (
|
||||||
TopicHeartbeat = "heartbeat.*"
|
TopicHeartbeat = "heartbeat.*"
|
||||||
TopicProjectLabel = "project_label.*"
|
TopicProjectLabel = "project_label.*"
|
||||||
EventUserUpdate = "user.update"
|
EventUserUpdate = "user.update"
|
||||||
|
EventUserDelete = "user.delete"
|
||||||
EventHeartbeatCreate = "heartbeat.create"
|
EventHeartbeatCreate = "heartbeat.create"
|
||||||
EventProjectLabelCreate = "project_label.create"
|
EventProjectLabelCreate = "project_label.create"
|
||||||
EventProjectLabelDelete = "project_label.delete"
|
EventProjectLabelDelete = "project_label.delete"
|
||||||
|
|
|
@ -2,10 +2,11 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
|
||||||
"github.com/muety/artifex/v2"
|
|
||||||
"math"
|
"math"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jobQueues map[string]*artifex.Dispatcher
|
var jobQueues map[string]*artifex.Dispatcher
|
||||||
|
@ -28,7 +29,9 @@ type JobQueueMetrics struct {
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
jobQueues = make(map[string]*artifex.Dispatcher)
|
jobQueues = make(map[string]*artifex.Dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartJobs() {
|
||||||
InitQueue(QueueDefault, 1)
|
InitQueue(QueueDefault, 1)
|
||||||
InitQueue(QueueProcessing, halfCPUs())
|
InitQueue(QueueProcessing, halfCPUs())
|
||||||
InitQueue(QueueReports, 1)
|
InitQueue(QueueReports, 1)
|
||||||
|
|
|
@ -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
74
go.mod
74
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.13
|
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.6.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
|
||||||
|
@ -24,56 +25,57 @@ require (
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
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.1
|
github.com/stretchr/testify v1.8.2
|
||||||
github.com/stripe/stripe-go/v74 v74.6.0
|
github.com/stripe/stripe-go/v74 v74.25.0
|
||||||
github.com/swaggo/http-swagger v1.3.3
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
github.com/swaggo/swag v1.8.10
|
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.5.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.5
|
gorm.io/driver/mysql v1.5.1
|
||||||
gorm.io/driver/postgres v1.4.6
|
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.3
|
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.20.0 // 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.2.0 // 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.17 // 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-20230126093431-47fa9a501578 // 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.0 // indirect
|
github.com/swaggo/files v1.0.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d // indirect
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
|
||||||
golang.org/x/image v0.3.0 // indirect
|
golang.org/x/image v0.9.0 // indirect
|
||||||
golang.org/x/net v0.5.0 // indirect
|
golang.org/x/net v0.12.0 // indirect
|
||||||
golang.org/x/sys v0.4.0 // indirect
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
golang.org/x/text v0.6.0 // indirect
|
golang.org/x/text v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.5.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.2 // 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.20.3 // indirect
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
235
go.sum
235
go.sum
|
@ -1,35 +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/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4=
|
||||||
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
|
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc=
|
||||||
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
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.13 h1:KOCCVrfh4pjuwl6td5MQ4OqvV73qFdoGxv20HWmyPaM=
|
github.com/duke-git/lancet/v2 v2.2.3 h1:Lj4iWgvEbgktEjAfqxE1G2BoGm1mL7l3QHBlXRYptjE=
|
||||||
github.com/duke-git/lancet/v2 v2.1.13/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.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
|
||||||
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.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8=
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.6.0 h1:ZpvDLv4zBi2cuuQPitRiVz/5Uh6sXa5d8eBu0xNTpAo=
|
github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc=
|
||||||
github.com/glebarez/sqlite v1.6.0/go.mod h1:6D6zPU/HTrFlYmVDKqBJlmQvma90P6r7sRRdkUUZOYk=
|
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=
|
||||||
|
@ -40,20 +40,20 @@ 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.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||||
|
@ -64,31 +64,25 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
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-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
|
||||||
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.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
|
github.com/jackc/pgx/v5 v5.4.1 h1:oKfB/FhuVtit1bBM3zNRRsZ925ZkMN3HXL+LgLUM9lE=
|
||||||
github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
|
github.com/jackc/pgx/v5 v5.4.1/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
|
||||||
github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
|
|
||||||
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=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
|
||||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||||
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=
|
||||||
|
@ -102,12 +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
|
||||||
github.com/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=
|
||||||
|
@ -122,12 +114,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/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=
|
||||||
|
@ -136,101 +128,84 @@ github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
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/stripe/stripe-go/v74 v74.6.0 h1:IQTT+psxj1hkXo6onQsyfw5Eopj7p6e0oltkWLlf0Tw=
|
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||||
github.com/stripe/stripe-go/v74 v74.6.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
|
github.com/stripe/stripe-go/v74 v74.25.0 h1:mGJp9L1ymxjFvq5MlmG6ynv/fAGX6LLU8MyMVsiRAMY=
|
||||||
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
|
github.com/stripe/stripe-go/v74 v74.25.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
|
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||||
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
|
||||||
|
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
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.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||||
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||||
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d h1:9Bio0JlZpJ1P4NXsK5i8Rf2MclrRzMGzJWOIkhZ5Um8=
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||||
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
|
||||||
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
|
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
|
||||||
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
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.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
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-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
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-20211112202133-69e39bad7dc2/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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
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-20201020160332-67f06af15bc9/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.0.0-20220923202941-7f9b1623fab7/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-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=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.4.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||||
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
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.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
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.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
|
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
|
||||||
|
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=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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=
|
||||||
|
@ -238,48 +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.5 h1:u1lytId4+o9dDaNcPCFzNv7h6wvmc92UjNk3z8enSBU=
|
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||||
gorm.io/driver/mysql v1.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
|
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||||
gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
|
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
|
||||||
gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
|
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.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||||
gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
|
||||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
|
||||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
|
||||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
|
||||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
|
||||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
|
||||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
|
||||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
|
||||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
|
||||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
|
||||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
|
||||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
|
||||||
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
|
||||||
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
|
||||||
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
|
||||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
|
||||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
|
||||||
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/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
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/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
|
||||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
|
||||||
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
|
|
||||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
|
||||||
|
|
107
helpers/interval.go
Normal file
107
helpers/interval.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||||
|
for _, i := range models.AllIntervals {
|
||||||
|
if i.HasAlias(interval) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("not a valid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustParseInterval(interval string) *models.IntervalKey {
|
||||||
|
key, _ := ParseInterval(interval)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||||
|
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
parsed, err := ParseInterval(interval)
|
||||||
|
if err != nil {
|
||||||
|
return err, time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
return ResolveIntervalTZ(parsed, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
now := time.Now().In(tz)
|
||||||
|
to = now
|
||||||
|
|
||||||
|
switch interval {
|
||||||
|
case models.IntervalToday:
|
||||||
|
from = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
||||||
|
to = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalPastDay:
|
||||||
|
from = now.Add(-24 * time.Hour)
|
||||||
|
case models.IntervalThisWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalLastWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalThisMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalLastMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
||||||
|
to = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalThisYear:
|
||||||
|
from = utils.BeginOfThisYear(tz)
|
||||||
|
case models.IntervalPast7Days:
|
||||||
|
from = now.AddDate(0, 0, -7)
|
||||||
|
case models.IntervalPast7DaysYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
||||||
|
case models.IntervalPast14Days:
|
||||||
|
from = now.AddDate(0, 0, -14)
|
||||||
|
case models.IntervalPast30Days:
|
||||||
|
from = now.AddDate(0, 0, -30)
|
||||||
|
case models.IntervalPast6Months:
|
||||||
|
from = now.AddDate(0, -6, 0)
|
||||||
|
case models.IntervalPast12Months:
|
||||||
|
from = now.AddDate(0, -12, 0)
|
||||||
|
case models.IntervalAny:
|
||||||
|
from = time.Time{}
|
||||||
|
default:
|
||||||
|
err = errors.New("invalid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
return err, from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveClosestRange returns the interval label (e.g. "last_7_days") of the maximum allowed range when having opted to share this many days or an error for days == 0.
|
||||||
|
func ResolveMaximumRange(days int) (error, *models.IntervalKey) {
|
||||||
|
if days == 0 {
|
||||||
|
return errors.New("no matching interval"), nil
|
||||||
|
}
|
||||||
|
if days < 0 {
|
||||||
|
return nil, models.IntervalAny
|
||||||
|
}
|
||||||
|
if days < 7 {
|
||||||
|
return nil, models.IntervalPastDay
|
||||||
|
}
|
||||||
|
if days < 14 {
|
||||||
|
return nil, models.IntervalPast7Days
|
||||||
|
}
|
||||||
|
if days < 30 {
|
||||||
|
return nil, models.IntervalPast14Days
|
||||||
|
}
|
||||||
|
if days < 181 { // 3*31 + 2*30 + 1*28
|
||||||
|
return nil, models.IntervalPast30Days
|
||||||
|
}
|
||||||
|
if days < 365 { // 7*31 + 4*30 + 1*28
|
||||||
|
return nil, models.IntervalPast6Months
|
||||||
|
}
|
||||||
|
return nil, models.IntervalPast12Months
|
||||||
|
}
|
27
helpers/interval_test.go
Normal file
27
helpers/interval_test.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveMaximumRange_Default(t *testing.T) {
|
||||||
|
for i := 1; i <= 366; i++ {
|
||||||
|
err1, maximumInterval := ResolveMaximumRange(i)
|
||||||
|
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC)
|
||||||
|
|
||||||
|
assert.Nil(t, err1)
|
||||||
|
assert.Nil(t, err2)
|
||||||
|
assert.LessOrEqual(t, to.Sub(from), time.Duration(i*24)*time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMaximumRange_EdgeCases(t *testing.T) {
|
||||||
|
err, _ := ResolveMaximumRange(0)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
|
||||||
|
_, maximumInterval := ResolveMaximumRange(-1)
|
||||||
|
assert.Equal(t, models.IntervalAny, maximumInterval)
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ package helpers
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -82,69 +81,3 @@ func extractUser(r *http.Request) *models.User {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
|
||||||
for _, i := range models.AllIntervals {
|
|
||||||
if i.HasAlias(interval) {
|
|
||||||
return i, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.New("not a valid interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
|
||||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
|
||||||
return from, to
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
|
||||||
parsed, err := ParseInterval(interval)
|
|
||||||
if err != nil {
|
|
||||||
return err, time.Time{}, time.Time{}
|
|
||||||
}
|
|
||||||
return ResolveIntervalTZ(parsed, tz)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
|
||||||
now := time.Now().In(tz)
|
|
||||||
to = now
|
|
||||||
|
|
||||||
switch interval {
|
|
||||||
case models.IntervalToday:
|
|
||||||
from = utils.BeginOfToday(tz)
|
|
||||||
case models.IntervalYesterday:
|
|
||||||
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
|
||||||
to = utils.BeginOfToday(tz)
|
|
||||||
case models.IntervalThisWeek:
|
|
||||||
from = utils.BeginOfThisWeek(tz)
|
|
||||||
case models.IntervalLastWeek:
|
|
||||||
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
|
||||||
to = utils.BeginOfThisWeek(tz)
|
|
||||||
case models.IntervalThisMonth:
|
|
||||||
from = utils.BeginOfThisMonth(tz)
|
|
||||||
case models.IntervalLastMonth:
|
|
||||||
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
|
||||||
to = utils.BeginOfThisMonth(tz)
|
|
||||||
case models.IntervalThisYear:
|
|
||||||
from = utils.BeginOfThisYear(tz)
|
|
||||||
case models.IntervalPast7Days:
|
|
||||||
from = now.AddDate(0, 0, -7)
|
|
||||||
case models.IntervalPast7DaysYesterday:
|
|
||||||
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
|
||||||
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
|
||||||
case models.IntervalPast14Days:
|
|
||||||
from = now.AddDate(0, 0, -14)
|
|
||||||
case models.IntervalPast30Days:
|
|
||||||
from = now.AddDate(0, 0, -30)
|
|
||||||
case models.IntervalPast6Months:
|
|
||||||
from = now.AddDate(0, -6, 0)
|
|
||||||
case models.IntervalPast12Months:
|
|
||||||
from = now.AddDate(0, -12, 0)
|
|
||||||
case models.IntervalAny:
|
|
||||||
from = time.Time{}
|
|
||||||
default:
|
|
||||||
err = errors.New("invalid interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
return err, from, to
|
|
||||||
}
|
|
||||||
|
|
49
main.go
49
main.go
|
@ -2,15 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/go-chi/chi/v5"
|
"flag"
|
||||||
middleware "github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/lpar/gzipped/v2"
|
|
||||||
"github.com/muety/wakapi/middlewares"
|
|
||||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
|
||||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
|
||||||
"github.com/muety/wakapi/routes/relay"
|
|
||||||
fsutils "github.com/muety/wakapi/utils/fs"
|
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
@ -19,23 +11,30 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/static/docs"
|
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
conf "github.com/muety/wakapi/config"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/muety/wakapi/migrations"
|
middleware "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/lpar/gzipped/v2"
|
||||||
"github.com/muety/wakapi/routes"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
"github.com/muety/wakapi/routes/api"
|
|
||||||
"github.com/muety/wakapi/services"
|
|
||||||
"github.com/muety/wakapi/services/mail"
|
|
||||||
_ "gorm.io/driver/mysql"
|
_ "gorm.io/driver/mysql"
|
||||||
_ "gorm.io/driver/postgres"
|
_ "gorm.io/driver/postgres"
|
||||||
_ "gorm.io/driver/sqlite"
|
_ "gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
_ "github.com/muety/wakapi/static/docs"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/middlewares"
|
||||||
|
"github.com/muety/wakapi/migrations"
|
||||||
|
"github.com/muety/wakapi/repositories"
|
||||||
|
"github.com/muety/wakapi/routes"
|
||||||
|
"github.com/muety/wakapi/routes/api"
|
||||||
|
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||||
|
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||||
|
"github.com/muety/wakapi/routes/relay"
|
||||||
|
"github.com/muety/wakapi/services"
|
||||||
|
"github.com/muety/wakapi/services/mail"
|
||||||
|
docs "github.com/muety/wakapi/static/docs"
|
||||||
|
fsutils "github.com/muety/wakapi/utils/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Embed version.txt
|
// Embed version.txt
|
||||||
|
@ -106,7 +105,15 @@ var (
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
config = conf.Load(version)
|
var versionFlag = flag.Bool("version", false, "print version")
|
||||||
|
var configFlag = flag.String("config", conf.DefaultConfigPath, "config file location")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *versionFlag {
|
||||||
|
print(version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
config = conf.Load(*configFlag, version)
|
||||||
|
|
||||||
// Configure Swagger docs
|
// Configure Swagger docs
|
||||||
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
|
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
|
||||||
|
@ -117,6 +124,7 @@ func main() {
|
||||||
} else {
|
} else {
|
||||||
logbuch.SetLevel(logbuch.LevelInfo)
|
logbuch.SetLevel(logbuch.LevelInfo)
|
||||||
}
|
}
|
||||||
|
logbuch.Info("Wakapi " + version)
|
||||||
|
|
||||||
// Set up GORM
|
// Set up GORM
|
||||||
gormLogger := logger.New(
|
gormLogger := logger.New(
|
||||||
|
@ -184,6 +192,7 @@ func main() {
|
||||||
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
|
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
|
||||||
|
|
||||||
// Schedule background tasks
|
// Schedule background tasks
|
||||||
|
go conf.StartJobs()
|
||||||
go aggregationService.Schedule()
|
go aggregationService.Schedule()
|
||||||
go leaderboardService.Schedule()
|
go leaderboardService.Schedule()
|
||||||
go reportService.Schedule()
|
go reportService.Schedule()
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// due to an error in the model definition, idx_time_user used to only cover 'user_id', but not time column
|
// due to an error in the model definition, idx_time_user used to only cover 'user_id', but not time column
|
||||||
|
@ -37,7 +38,7 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := regexp.MustCompile("(?i)\\((.+)\\)$").FindStringSubmatch(ddl)
|
matches := regexp.MustCompile("(?i)\\((.+)\\)$").FindStringSubmatch(ddl)
|
||||||
if len(matches) > 0 && !strings.Contains(matches[0], "`user_id`") || !strings.Contains(matches[0], "`time`") {
|
if len(matches) > 0 && (!strings.Contains(matches[0], "`user_id`") || !strings.Contains(matches[0], "`time`")) {
|
||||||
drop = true
|
drop = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
28
models/compat/wakatime/v1/data_dump.go
Normal file
28
models/compat/wakatime/v1/data_dump.go
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
type DataDumpViewModel struct {
|
||||||
|
Data []*DataDumpData `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataDumpResultErrorModel struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
@ -14,19 +15,26 @@ type StatsViewModel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatsData struct {
|
type StatsData struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
UserId string `json:"user_id"`
|
UserId string `json:"user_id"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
TotalSeconds float64 `json:"total_seconds"`
|
Status string `json:"status"`
|
||||||
DailyAverage float64 `json:"daily_average"`
|
TotalSeconds float64 `json:"total_seconds"`
|
||||||
DaysIncludingHolidays int `json:"days_including_holidays"`
|
DailyAverage float64 `json:"daily_average"`
|
||||||
Editors []*SummariesEntry `json:"editors"`
|
DaysIncludingHolidays int `json:"days_including_holidays"`
|
||||||
Languages []*SummariesEntry `json:"languages"`
|
Range string `json:"range"`
|
||||||
Machines []*SummariesEntry `json:"machines"`
|
HumanReadableRange string `json:"human_readable_range"`
|
||||||
Projects []*SummariesEntry `json:"projects"`
|
HumanReadableTotal string `json:"human_readable_total"`
|
||||||
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
HumanReadableDailyAverage string `json:"human_readable_daily_average"`
|
||||||
Branches []*SummariesEntry `json:"branches,omitempty"`
|
IsCodingActivityVisible bool `json:"is_coding_activity_visible"`
|
||||||
|
IsOtherUsageVisible bool `json:"is_other_usage_visible"`
|
||||||
|
Editors []*SummariesEntry `json:"editors"`
|
||||||
|
Languages []*SummariesEntry `json:"languages"`
|
||||||
|
Machines []*SummariesEntry `json:"machines"`
|
||||||
|
Projects []*SummariesEntry `json:"projects"`
|
||||||
|
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||||
|
Branches []*SummariesEntry `json:"branches,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||||
|
@ -38,11 +46,16 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
|
||||||
UserId: summary.UserID,
|
UserId: summary.UserID,
|
||||||
Start: summary.FromTime.T(),
|
Start: summary.FromTime.T(),
|
||||||
End: summary.ToTime.T(),
|
End: summary.ToTime.T(),
|
||||||
|
Status: "ok",
|
||||||
TotalSeconds: totalTime.Seconds(),
|
TotalSeconds: totalTime.Seconds(),
|
||||||
DailyAverage: totalTime.Seconds() / float64(numDays),
|
|
||||||
DaysIncludingHolidays: numDays,
|
DaysIncludingHolidays: numDays,
|
||||||
|
HumanReadableTotal: helpers.FmtWakatimeDuration(totalTime),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if numDays > 0 {
|
||||||
|
data.DailyAverage = totalTime.Seconds() / float64(numDays)
|
||||||
|
data.HumanReadableDailyAverage = helpers.FmtWakatimeDuration(totalTime / time.Duration(numDays))
|
||||||
|
}
|
||||||
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
||||||
data.DailyAverage = 0
|
data.DailyAverage = 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ func (f OrFilter) Exists() bool {
|
||||||
|
|
||||||
func (f OrFilter) MatchAny(search string) bool {
|
func (f OrFilter) MatchAny(search string) bool {
|
||||||
for _, s := range f {
|
for _, s := range f {
|
||||||
if s == search {
|
if s == search || (s == "-" && search == "") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -1,27 +1,33 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
// Support Wakapi and WakaTime range / interval identifiers
|
// Support Wakapi and WakaTime range / interval identifiers
|
||||||
// See https://wakatime.com/developers/#summaries
|
// See https://wakatime.com/developers/#summaries
|
||||||
var (
|
var (
|
||||||
IntervalToday = &IntervalKey{"today", "Today"}
|
IntervalToday = &IntervalKey{"today", "Today"}
|
||||||
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
|
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
|
||||||
|
IntervalPastDay = &IntervalKey{"24_hours", "last_24_hours", "last_day", "Last 24 Hours"} // non-official one
|
||||||
IntervalThisWeek = &IntervalKey{"week", "This Week"}
|
IntervalThisWeek = &IntervalKey{"week", "This Week"}
|
||||||
IntervalLastWeek = &IntervalKey{"Last Week"}
|
IntervalLastWeek = &IntervalKey{"last_week", "Last Week"}
|
||||||
IntervalThisMonth = &IntervalKey{"month", "This Month"}
|
IntervalThisMonth = &IntervalKey{"month", "This Month"}
|
||||||
IntervalLastMonth = &IntervalKey{"Last Month"}
|
IntervalLastMonth = &IntervalKey{"last_month", "Last Month"}
|
||||||
IntervalThisYear = &IntervalKey{"year"}
|
IntervalThisYear = &IntervalKey{"year", "This Year"}
|
||||||
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
|
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
|
||||||
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
||||||
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
IntervalPast14Days = &IntervalKey{"14_days", "last_14_days", "Last 14 Days"}
|
||||||
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
||||||
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
|
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months", "Last 6 Months"}
|
||||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
|
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year", "Last 12 Months"}
|
||||||
IntervalAny = &IntervalKey{"any", "all_time"}
|
IntervalAny = &IntervalKey{"any", "all_time", "All Time"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllIntervals = []*IntervalKey{
|
var AllIntervals = []*IntervalKey{
|
||||||
IntervalToday,
|
IntervalToday,
|
||||||
IntervalYesterday,
|
IntervalYesterday,
|
||||||
|
IntervalPastDay,
|
||||||
IntervalThisWeek,
|
IntervalThisWeek,
|
||||||
IntervalLastWeek,
|
IntervalLastWeek,
|
||||||
IntervalThisMonth,
|
IntervalThisMonth,
|
||||||
|
@ -46,3 +52,12 @@ func (k *IntervalKey) HasAlias(s string) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *IntervalKey) GetHumanReadable() string {
|
||||||
|
for _, s := range *k {
|
||||||
|
if unicode.IsUpper(rune(s[0])) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
@ -3,8 +3,9 @@ package models
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Report struct {
|
type Report struct {
|
||||||
From time.Time
|
From time.Time
|
||||||
To time.Time
|
To time.Time
|
||||||
User *User
|
User *User
|
||||||
Summary *Summary
|
Summary *Summary
|
||||||
|
DailySummaries []*Summary
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,6 +163,10 @@ func (u *User) MinDataAge() time.Time {
|
||||||
return time.Now().AddDate(0, -retentionMonths, 0)
|
return time.Now().AddDate(0, -retentionMonths, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) AnyDataShared() bool {
|
||||||
|
return u.ShareDataMaxDays != 0 && (u.ShareEditors || u.ShareLanguages || u.ShareProjects || u.ShareOSs || u.ShareMachines || u.ShareLabels)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
conf "github.com/muety/wakapi/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUser_TZ(t *testing.T) {
|
func TestUser_TZ(t *testing.T) {
|
||||||
|
@ -21,7 +22,7 @@ func TestUser_TZ(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUser_MinDataAge(t *testing.T) {
|
func TestUser_MinDataAge(t *testing.T) {
|
||||||
c := conf.Load("")
|
c := conf.Load("", "")
|
||||||
|
|
||||||
var sut *User
|
var sut *User
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -89,7 +90,13 @@ func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *mo
|
||||||
Order("time asc")
|
Order("time asc")
|
||||||
|
|
||||||
for col, vals := range filterMap {
|
for col, vals := range filterMap {
|
||||||
q = q.Where(col+" in ?", vals)
|
q = q.Where(col+" in ?", slice.Map[string, string](vals, func(i int, val string) string {
|
||||||
|
// query for "unknown" projects, languages, etc.
|
||||||
|
if val == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := q.Find(&heartbeats).Error; err != nil {
|
if err := q.Find(&heartbeats).Error; err != nil {
|
||||||
|
|
|
@ -2,6 +2,7 @@ package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
@ -36,8 +37,12 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
|
||||||
|
|
||||||
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
|
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
|
||||||
var keyValues []*models.KeyStringValue
|
var keyValues []*models.KeyStringValue
|
||||||
|
condition := "key like ?"
|
||||||
|
if r.db.Config.Name() == config.SQLDialectMysql {
|
||||||
|
condition = "`key` like ?"
|
||||||
|
}
|
||||||
if err := r.db.Table("key_string_values").
|
if err := r.db.Table("key_string_values").
|
||||||
Where("`key` like ?", like).
|
Where(condition, like).
|
||||||
Find(&keyValues).
|
Find(&keyValues).
|
||||||
Error; err != nil {
|
Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -76,8 +76,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if no range was requested, get the maximum allowed range given the users max shared days, otherwise default to past 7 days (which will fail in the next step, because user didn't allow any sharing)
|
||||||
|
// this "floors" the user's maximum shared date to the supported range buckets (e.g. if user opted to share 12 days, we'll still fallback to "last_7_days") for consistency with wakatime
|
||||||
if rangeParam == "" {
|
if rangeParam == "" {
|
||||||
rangeParam = (*models.IntervalPast7Days)[0]
|
if _, userRange := helpers.ResolveMaximumRange(requestedUser.ShareDataMaxDays); userRange != nil {
|
||||||
|
rangeParam = (*userRange)[1]
|
||||||
|
} else {
|
||||||
|
rangeParam = (*models.IntervalPast7Days)[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||||
|
@ -103,6 +109,10 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
||||||
|
stats.Data.Range = rangeParam
|
||||||
|
stats.Data.HumanReadableRange = helpers.MustParseInterval(rangeParam).GetHumanReadable()
|
||||||
|
stats.Data.IsCodingActivityVisible = requestedUser.ShareDataMaxDays != 0
|
||||||
|
stats.Data.IsOtherUsageVisible = requestedUser.AnyDataShared()
|
||||||
|
|
||||||
// post filter stats according to user's given sharing permissions
|
// post filter stats according to user's given sharing permissions
|
||||||
if !requestedUser.ShareEditors {
|
if !requestedUser.ShareEditors {
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -65,6 +65,7 @@ func (h *RelayHandler) Any(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write([]byte{})
|
w.Write([]byte{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
targetUrl.RawQuery = r.URL.RawQuery
|
||||||
|
|
||||||
p := httputil.ReverseProxy{
|
p := httputil.ReverseProxy{
|
||||||
Director: func(r *http.Request) {
|
Director: func(r *http.Request) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
@ -16,6 +17,7 @@ import (
|
||||||
stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session"
|
stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session"
|
||||||
stripeCustomer "github.com/stripe/stripe-go/v74/customer"
|
stripeCustomer "github.com/stripe/stripe-go/v74/customer"
|
||||||
stripePrice "github.com/stripe/stripe-go/v74/price"
|
stripePrice "github.com/stripe/stripe-go/v74/price"
|
||||||
|
stripeSubscription "github.com/stripe/stripe-go/v74/subscription"
|
||||||
"github.com/stripe/stripe-go/v74/webhook"
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -32,8 +34,11 @@ import (
|
||||||
4. Copy the publishable API key (https://dashboard.stripe.com/test/apikeys) and save it to 'stripe_api_key'
|
4. Copy the publishable API key (https://dashboard.stripe.com/test/apikeys) and save it to 'stripe_api_key'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// TODO: move all logic inside this controller into a separate service
|
||||||
|
|
||||||
type SubscriptionHandler struct {
|
type SubscriptionHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
|
eventBus *hub.Hub
|
||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
mailSrvc services.IMailService
|
mailSrvc services.IMailService
|
||||||
keyValueSrvc services.IKeyValueService
|
keyValueSrvc services.IKeyValueService
|
||||||
|
@ -46,6 +51,7 @@ func NewSubscriptionHandler(
|
||||||
keyValueService services.IKeyValueService,
|
keyValueService services.IKeyValueService,
|
||||||
) *SubscriptionHandler {
|
) *SubscriptionHandler {
|
||||||
config := conf.Get()
|
config := conf.Get()
|
||||||
|
eventBus := conf.EventBus()
|
||||||
|
|
||||||
if config.Subscriptions.Enabled {
|
if config.Subscriptions.Enabled {
|
||||||
stripe.Key = config.Subscriptions.StripeSecretKey
|
stripe.Key = config.Subscriptions.StripeSecretKey
|
||||||
|
@ -59,13 +65,32 @@ func NewSubscriptionHandler(
|
||||||
logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice)
|
logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SubscriptionHandler{
|
handler := &SubscriptionHandler{
|
||||||
config: config,
|
config: config,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
mailSrvc: mailService,
|
mailSrvc: mailService,
|
||||||
keyValueSrvc: keyValueService,
|
keyValueSrvc: keyValueService,
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUserDelete := eventBus.Subscribe(0, conf.EventUserDelete)
|
||||||
|
go func(sub *hub.Subscription) {
|
||||||
|
for m := range sub.Receiver {
|
||||||
|
user := m.Fields[conf.FieldPayload].(*models.User)
|
||||||
|
if !user.HasActiveSubscription() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logbuch.Info("cancelling subscription for user '%s' (email '%s', stripe customer '%s') upon account deletion", user.ID, user.Email, user.StripeCustomerId)
|
||||||
|
if err := handler.cancelUserSubscription(user); err == nil {
|
||||||
|
logbuch.Info("successfully cancelled subscription for user '%s' (email '%s', stripe customer '%s')", user.ID, user.Email, user.StripeCustomerId)
|
||||||
|
} else {
|
||||||
|
conf.Log().Error("failed to cancel subscription for user '%s' (email '%s', stripe customer '%s') - %v", user.ID, user.Email, user.StripeCustomerId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(&onUserDelete)
|
||||||
|
|
||||||
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stripe.com/docs/billing/quickstart?lang=go
|
// https://stripe.com/docs/billing/quickstart?lang=go
|
||||||
|
@ -194,8 +219,7 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
|
||||||
// example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k
|
// example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k
|
||||||
subscription, err := h.parseSubscriptionEvent(w, r, event)
|
subscription, err := h.parseSubscriptionEvent(w, r, event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
return // status code already written
|
||||||
return
|
|
||||||
}
|
}
|
||||||
logbuch.Info("received stripe subscription event of type '%s' for subscription '%s' (customer '%s').", event.Type, subscription.ID, subscription.Customer.ID)
|
logbuch.Info("received stripe subscription event of type '%s' for subscription '%s' (customer '%s').", event.Type, subscription.ID, subscription.Customer.ID)
|
||||||
|
|
||||||
|
@ -208,14 +232,14 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
|
||||||
customer, err := stripeCustomer.Get(subscription.Customer.ID, nil)
|
customer, err := stripeCustomer.Get(subscription.Customer.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("failed to fetch stripe customer with id '%s', %v", subscription.Customer.ID, err)
|
conf.Log().Request(r).Error("failed to fetch stripe customer with id '%s', %v", subscription.Customer.ID, err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusOK) // don't make stripe retry the event
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := h.userSrvc.GetUserByEmail(customer.Email)
|
u, err := h.userSrvc.GetUserByEmail(customer.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("failed to get user with email '%s' as stripe customer '%s' for processing event for subscription %s, %v", customer.Email, subscription.Customer.ID, subscription.ID, err)
|
conf.Log().Request(r).Error("failed to get user with email '%s' as stripe customer '%s' for processing event for subscription %s, %v", customer.Email, subscription.Customer.ID, subscription.ID, err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusOK) // don't make stripe retry the event
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user = u
|
user = u
|
||||||
|
@ -223,7 +247,7 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
if err := h.handleSubscriptionEvent(subscription, user); err != nil {
|
if err := h.handleSubscriptionEvent(subscription, user); err != nil {
|
||||||
conf.Log().Request(r).Error("failed to handle subscription event %s (%s) for user %s, %v", event.ID, event.Type, user.ID, err)
|
conf.Log().Request(r).Error("failed to handle subscription event %s (%s) for user %s, %v", event.ID, event.Type, user.ID, err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusOK) // don't make stripe retry the event
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,16 +255,14 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
|
||||||
// example payload: https://pastr.de/p/d01iniw9naq9hkmvyqtxin2w
|
// example payload: https://pastr.de/p/d01iniw9naq9hkmvyqtxin2w
|
||||||
checkoutSession, err := h.parseCheckoutSessionEvent(w, r, event)
|
checkoutSession, err := h.parseCheckoutSessionEvent(w, r, event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
return // status code already written
|
||||||
return
|
|
||||||
}
|
}
|
||||||
logbuch.Info("received stripe checkout session event of type '%s' for session '%s' (customer '%s' with email '%s').", event.Type, checkoutSession.ID, checkoutSession.Customer.ID, checkoutSession.CustomerEmail)
|
logbuch.Info("received stripe checkout session event of type '%s' for session '%s' (customer '%s' with email '%s').", event.Type, checkoutSession.ID, checkoutSession.Customer.ID, checkoutSession.CustomerEmail)
|
||||||
|
|
||||||
user, err := h.userSrvc.GetUserById(checkoutSession.ClientReferenceID)
|
user, err := h.userSrvc.GetUserById(checkoutSession.ClientReferenceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("failed to find user with id '%s' to update associated stripe customer (%s)", user.ID, checkoutSession.Customer.ID)
|
conf.Log().Request(r).Error("failed to find user with id '%s' to update associated stripe customer (%s)", user.ID, checkoutSession.Customer.ID)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
return // status code already written
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.StripeCustomerId == "" {
|
if user.StripeCustomerId == "" {
|
||||||
|
@ -325,6 +347,16 @@ func (h *SubscriptionHandler) parseCheckoutSessionEvent(w http.ResponseWriter, r
|
||||||
return &checkoutSession, nil
|
return &checkoutSession, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) cancelUserSubscription(user *models.User) error {
|
||||||
|
// TODO: directly store subscription id with user object
|
||||||
|
subscription, err := h.findCurrentStripeSubscription(user.StripeCustomerId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stripeSubscription.Cancel(subscription.ID, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.Customer, error) {
|
func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.Customer, error) {
|
||||||
params := &stripe.CustomerSearchParams{
|
params := &stripe.CustomerSearchParams{
|
||||||
SearchParams: stripe.SearchParams{
|
SearchParams: stripe.SearchParams{
|
||||||
|
@ -344,6 +376,24 @@ func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.C
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) findCurrentStripeSubscription(customerId string) (*stripe.Subscription, error) {
|
||||||
|
paramStatus := "active"
|
||||||
|
params := &stripe.SubscriptionListParams{
|
||||||
|
Customer: &customerId,
|
||||||
|
Price: &h.config.Subscriptions.StandardPriceId,
|
||||||
|
Status: ¶mStatus,
|
||||||
|
CurrentPeriodEndRange: &stripe.RangeQueryParams{
|
||||||
|
GreaterThan: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params.Filters.AddFilter("limit", "", "1")
|
||||||
|
|
||||||
|
if result := stripeSubscription.List(params); result.Next() {
|
||||||
|
return result.Subscription(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no active subscription found for customer '%s'", customerId)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SubscriptionHandler) clearSubscriptionNotificationStatus(userId string) {
|
func (h *SubscriptionHandler) clearSubscriptionNotificationStatus(userId string) {
|
||||||
key := fmt.Sprintf("%s_%s", conf.KeySubscriptionNotificationSent, userId)
|
key := fmt.Sprintf("%s_%s", conf.KeySubscriptionNotificationSent, userId)
|
||||||
if err := h.keyValueSrvc.DeleteString(key); err != nil {
|
if err := h.keyValueSrvc.DeleteString(key); err != nil {
|
||||||
|
|
|
@ -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,338 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return NewWakatimeHeartbeatImporter(w.apiKey).Import(user, minFrom, maxTo)
|
||||||
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()
|
|
||||||
|
|
||||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if res.StatusCode >= 400 {
|
|
||||||
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
|
||||||
return nil, 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
|
|
||||||
}
|
}
|
||||||
|
|
160
services/imports/wakatime_dump.go
Normal file
160
services/imports/wakatime_dump.go
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 && res.StatusCode == http.StatusBadRequest {
|
||||||
|
var datadumpError wakatime.DataDumpResultErrorModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&datadumpError); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// in case of this error message, a dump had already been requested before and can simply be downloaded now
|
||||||
|
// -> just keep going as usual (kick off poll loop), otherwise yield error
|
||||||
|
if datadumpError.Error == "Wait for your current export to expire before creating another." {
|
||||||
|
logbuch.Info("failed to request new dump, because other non-expired dump already existing, using that one")
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var readyPollTimer *artifex.DispatchTicker
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
checkDumpAvailable := func(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
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(datadumpData.Data) < 1 {
|
||||||
|
return false, nil, errors.New("no dumps available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return datadumpData.Data[0].Status == "Completed", datadumpData.Data[0], 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 := checkDumpAvailable(&u)
|
||||||
|
if err != nil {
|
||||||
|
onDumpFailed(err, &u)
|
||||||
|
} else if ok {
|
||||||
|
logbuch.Info("waiting for data dump '%s' for user '%s' to become downloadable (%.2f percent complete)", dump.Id, u.ID, dump.PercentComplete)
|
||||||
|
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()
|
||||||
|
}
|
|
@ -223,11 +223,13 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exclude unknown language (will also exclude browsing time by chrome-wakatime plugin)
|
||||||
|
total := summary.TotalTime() - summary.TotalTimeByKey(models.SummaryLanguage, models.UnknownSummaryKey)
|
||||||
return &models.LeaderboardItem{
|
return &models.LeaderboardItem{
|
||||||
User: user,
|
User: user,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Interval: (*interval)[0],
|
Interval: (*interval)[0],
|
||||||
Total: summary.TotalTime(),
|
Total: total,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -268,16 +268,25 @@ func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *MiscService) existsUsersTotalTime() bool {
|
func (srv *MiscService) existsUsersTotalTime() bool {
|
||||||
results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime)
|
results, err := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to fetch latest time key-values, %v", err)
|
||||||
|
}
|
||||||
return len(results) > 0
|
return len(results) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *MiscService) existsUsersFirstData() bool {
|
func (srv *MiscService) existsUsersFirstData() bool {
|
||||||
results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat)
|
results, err := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to fetch first heartbeats key-values, %v", err)
|
||||||
|
}
|
||||||
return len(results) > 0
|
return len(results) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *MiscService) existsSubscriptionNotifications() bool {
|
func (srv *MiscService) existsSubscriptionNotifications() bool {
|
||||||
results, _ := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent)
|
results, err := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent)
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to fetch notifications key-values, %v", err)
|
||||||
|
}
|
||||||
return len(results) > 0
|
return len(results) > 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
@ -214,6 +203,7 @@ func (srv *UserService) Delete(user *models.User) error {
|
||||||
|
|
||||||
user.ReportsWeekly = false
|
user.ReportsWeekly = false
|
||||||
srv.notifyUpdate(user)
|
srv.notifyUpdate(user)
|
||||||
|
srv.notifyDelete(user)
|
||||||
|
|
||||||
return srv.repository.Delete(user)
|
return srv.repository.Delete(user)
|
||||||
}
|
}
|
||||||
|
@ -232,3 +222,10 @@ func (srv *UserService) notifyUpdate(user *models.User) {
|
||||||
Fields: map[string]interface{}{config.FieldPayload: user},
|
Fields: map[string]interface{}{config.FieldPayload: user},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *UserService) notifyDelete(user *models.User) {
|
||||||
|
srv.eventBus.Publish(hub.Message{
|
||||||
|
Name: config.EventUserDelete,
|
||||||
|
Fields: map[string]interface{}{config.FieldPayload: user},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -134,10 +134,9 @@ function draw(subselection) {
|
||||||
},
|
},
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
onClick: (event, data) => {
|
onClick: (event, data) => {
|
||||||
const idx = data[0].index
|
|
||||||
const name = wakapiData.projects[idx].key
|
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('project', name)
|
const name = wakapiData.projects[data[0].index].key
|
||||||
|
url.searchParams.set('project', name === 'unknown' ? '-' : name)
|
||||||
window.location.href = url.href
|
window.location.href = url.href
|
||||||
},
|
},
|
||||||
onHover: (event, elem) => {
|
onHover: (event, elem) => {
|
||||||
|
|
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 {
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -78,10 +81,28 @@ 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 {
|
||||||
|
message := "<body omitted or empty>"
|
||||||
|
contentType := res.Header.Get("content-type")
|
||||||
|
if strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "application/json") {
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
res.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
message = string(body)
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("got response status %d for '%s %s' - %s", res.StatusCode, res.Request.Method, res.Request.URL.String(), message)
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
|
@ -70,7 +70,13 @@
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="mb-8 w-full">
|
<div class="mb-8 w-full">
|
||||||
<h1 class="font-semibold text-3xl text-white">Project "{{ .GetProjectFilter }}"</h1>
|
<h1 class="font-semibold text-3xl text-white">
|
||||||
|
{{ if eq .GetProjectFilter "-" }}
|
||||||
|
Unknown project
|
||||||
|
{{ else }}
|
||||||
|
Project "{{ .GetProjectFilter }}"
|
||||||
|
{{ end }}
|
||||||
|
</h1>
|
||||||
<div class="flex space-x-4 items-center">
|
<div class="flex space-x-4 items-center">
|
||||||
<h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
|
<h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
|
||||||
<div v-cloak v-show="currentInterval">
|
<div v-cloak v-show="currentInterval">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user