Compare commits

...

52 Commits

Author SHA1 Message Date
Ferdinand Mütsch 05bc55a488 docs: instructions how to set up browser extension [skip ci] 2023-07-30 08:17:14 +02:00
Ferdinand Mütsch a9364e3d9e Merge remote-tracking branch 'origin/master' 2023-07-28 12:08:54 +02:00
Ferdinand Mütsch ec65847d0c fix: make stats endpoint default to user-chosen time range (resolve #508)
chore: include more properties in status model for better compatibility
2023-07-28 12:08:47 +02:00
Ferdinand Mütsch eca443be35
Merge pull request #507 from cbrand/master
fix failing migration which prohibits startup
2023-07-21 08:50:42 +02:00
Christoph Brand 04ec44dcef fix: failing migration
Fix an issue in migration which results in the following error message
due to wrong and or precedence configuration:
```
panic: runtime error: index out of range [0] with length 0
```
2023-07-20 18:44:26 +02:00
Ferdinand Mütsch 938290b2da Merge remote-tracking branch 'origin/master' 2023-07-19 18:36:35 +02:00
Ferdinand Mütsch c8b88ccef5 chore: log response body of failed http requests 2023-07-19 18:36:27 +02:00
Ferdinand Mütsch bc2d05bd85
ci: skip multi-platform build step on pushes and prs [skip ci] 2023-07-14 08:50:23 +02:00
Ferdinand Mütsch 3785867c3a
Merge pull request #504 from muety/502-imports
Simplify import checks
2023-07-14 08:47:54 +02:00
Ferdinand Mütsch 56de275781 chore: simplify import checks
fix: minor fixes
2023-07-13 20:48:56 +02:00
Edward 583ddcab7a
refactor: remove repeated code in readyPollTimer 2023-07-14 00:33:55 +08:00
Edward 7b0bbcefe6
fix(import): data dump already exists
handle the import when there is already an active data dump exists.

Resolves #502
2023-07-13 23:54:48 +08:00
Ferdinand Mütsch 5f1ca4ed69 ci: set minimum go version to 1.20 [skip-ci] 2023-07-09 20:32:26 +02:00
Ferdinand Mütsch c06b2b8aca chore: bump to go 1.20 2023-07-09 20:30:00 +02:00
Ferdinand Mütsch 45a003185e chore: minor code style and cleanup 2023-07-09 20:16:34 +02:00
Ferdinand Mütsch 3063e80692 refactor: use wakatime dump api for data imports (resolve #323) 2023-07-09 18:03:41 +02:00
Ferdinand Mütsch 38286c7f3a fix: correctly parse os and editor for chrome plugin
fix: handle last project special keyword
2023-07-09 10:28:23 +02:00
Ferdinand Mütsch 37e6acd058
Merge pull request #501 from muety/ci/runner-version
ci(release): update to ubuntu-latest
2023-07-09 09:26:27 +02:00
Steven Tang 07b24fe3b1
ci: update to ubuntu-latest 2023-07-09 13:42:49 +10:00
Ferdinand Mütsch 78f327dbeb docs: add instructions for using export script 2023-07-08 20:48:38 +02:00
Ferdinand Mütsch 2af82f529a chore: testing scripts for cockroachdb 2023-07-08 20:33:07 +02:00
Ferdinand Mütsch 5278dba4f4 feat: add per weekday stats to report (resolve #493) 2023-07-08 19:54:15 +02:00
Ferdinand Mütsch 35ef323b19 security: migrate to argon2id password hashing
fix: support super long passwords (resolve #494)
2023-07-08 19:15:59 +02:00
Ferdinand Mütsch a8e2bc671d fix: badge endpoint caching (resolve #496) 2023-07-08 18:44:40 +02:00
Ferdinand Mütsch 7b60c44ac6 chore: update sentry sdk 2023-07-08 18:36:25 +02:00
Ferdinand Mütsch 055d006379 chore: upgrade dependencies 2023-07-08 18:33:08 +02:00
Ferdinand Mütsch 1a6ee55d14 fix: respect errors thrown in misc functions 2023-05-15 21:25:03 +02:00
Miles Liu 74390bfccf
fix: invalid search query in Postgres 2023-05-15 14:33:12 +08:00
Ferdinand Mütsch 8de56a4c7b docs: limit readme stats preview to past week [skip ci] 2023-05-08 19:21:19 +02:00
Ferdinand Mütsch a6915a187a fix: properly handle error responses during wakatime import 2023-04-21 09:43:10 +02:00
Ferdinand Mütsch df25183035 fix: return ok status even upon subscription event handling failure [skip ci] 2023-04-10 18:49:29 +02:00
Ferdinand Mütsch b33c71b41f chore: upgrade dependencies 2023-04-10 10:04:48 +02:00
Ferdinand Mütsch a9e1c4b589 docs: update readme [skip ci] 2023-04-09 17:40:01 +02:00
Ferdinand Mütsch dc4eefbede fix: exclude unknown languages from leaderboard time 2023-04-09 17:35:55 +02:00
Ferdinand Mütsch a20456bb8e fix: cancel active subscription upon user account deletion 2023-04-09 17:29:57 +02:00
Steven Tang 44c481b9e0
ci: upgrade docker action 2023-04-04 23:00:06 +10:00
Ferdinand Mütsch beced39923 fix: include query params with relayed request 2023-04-04 00:15:56 +02:00
Ferdinand Mütsch 083fbf8633 fix: view details of unknown projects on dashboard 2023-04-03 20:30:23 +02:00
Ferdinand Mütsch ca3320b174
Merge pull request #479 from muety/cli-version
feat: -version flag
2023-04-03 15:10:26 +02:00
Steven Tang 406f5147c8
feat: -version flag 2023-04-03 22:46:31 +10:00
Steven Tang d061a4ef1b
Revert "ci: add workflow_dispatch to docker.yml"
This reverts commit 65c2d9a17f.
2023-03-27 21:53:18 +11:00
Steven Tang 65c2d9a17f
ci: add workflow_dispatch to docker.yml 2023-03-27 21:28:47 +11:00
Steven Tang de2702241b
ci: consistent trigger for release docker 2023-03-25 11:53:15 +11:00
Ferdinand Mütsch 31664c25a8 chore: exclude avatar routes from logging [skip ci] 2023-03-24 23:16:36 +01:00
Ferdinand Mütsch c82186046c chore: add support indicator for users 2023-03-24 23:12:51 +01:00
Ferdinand Mütsch 24fec41ec6 fix: top files picker 2023-03-22 20:49:47 +01:00
Ferdinand Mütsch 4ee3da6f7e feat: implement file statistics (resolve #80) 2023-03-22 20:45:27 +01:00
Ferdinand Mütsch 0cf09a0871 fix: updating subscription renewal date 2023-03-21 18:05:34 +01:00
Ferdinand Mütsch bfeebafb2f fix: support user agents set by browser plugin (see #237) 2023-03-16 21:02:28 +01:00
Ferdinand Mütsch c9f2518fbc fix: badges broken (resolve #475) 2023-03-15 21:47:12 +01:00
Ferdinand Mütsch 46a248ac30
docs: add note about necessity to enable public data for github readme integration (resolve #474) [skip-ci] 2023-03-12 19:00:33 +01:00
Ferdinand Mütsch fde45a5138 fix: include missing mail headers (resolve #472) 2023-03-06 20:31:31 +01:00
89 changed files with 3714 additions and 2472 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -12,7 +12,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: ^1.19
go-version: ^1.20
id: go
- name: Check out code into the Go module directory
@ -39,7 +39,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: ^1.19
go-version: ^1.20
- name: Check out code into the Go module directory
uses: actions/checkout@v3
@ -75,33 +75,6 @@ jobs:
with:
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:
name: Migration tests
runs-on: ubuntu-latest
@ -115,7 +88,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: ^1.19
go-version: ^1.20
id: go
- name: Check out code into the Go module directory

View File

@ -1,10 +1,9 @@
name: Publish Docker Image
on:
push:
tags:
- '*.*.*'
- '!*.*.*-*'
release:
types:
- published
jobs:
docker-publish:
@ -21,19 +20,19 @@ jobs:
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -41,7 +40,7 @@ jobs:
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ github.repository }}
@ -54,7 +53,7 @@ jobs:
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile

View File

@ -12,10 +12,10 @@ jobs:
fail-fast: false
matrix:
include:
- platform: ubuntu-18.04
- platform: ubuntu-latest
GOOS: linux
GOARCH: amd64
- platform: ubuntu-18.04
- platform: ubuntu-latest
GOOS: linux
GOARCH: arm64
- platform: windows-latest
@ -34,7 +34,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: ^1.19
go-version: ^1.20
id: go
- name: Check out code into the Go module directory

View File

@ -47,10 +47,6 @@ Installation instructions can be found below and in the [Wiki](https://github.co
* ✅ Lightning fast
* ✅ 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?
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.
@ -256,9 +252,9 @@ Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing,
### GitHub Readme Stats integrations
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.
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>
<summary>Click to view code</summary>
@ -272,7 +268,7 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
### Github Readme Metrics integration
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi. To use this, don't forget to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
Preview:
@ -298,6 +294,44 @@ Preview:
</details>
<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
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
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
MIT @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -2,11 +2,9 @@ package config
import (
"encoding/json"
"flag"
"fmt"
"github.com/robfig/cron/v3"
"io/ioutil"
"net/http"
"os"
"regexp"
"strconv"
"strings"
@ -17,11 +15,12 @@ import (
"github.com/jinzhu/configor"
"github.com/muety/wakapi/data"
"github.com/muety/wakapi/utils"
"github.com/robfig/cron/v3"
uuid "github.com/satori/go.uuid"
)
const (
defaultConfigPath = "config.yml"
DefaultConfigPath = "config.yml"
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
@ -52,6 +51,7 @@ const (
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
WakatimeApiDataDumpUrl = "/users/current/data_dumps"
)
const (
@ -65,7 +65,6 @@ var emailProviders = []string{
}
var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct {
@ -344,7 +343,7 @@ func readColors() map[string]map[string]string {
raw := data.ColorsFile
if IsDev(env) {
raw, _ = ioutil.ReadFile("data/colors.json")
raw, _ = os.ReadFile("data/colors.json")
}
var colors = make(map[string]map[string]string)
@ -376,12 +375,10 @@ func Get() *Config {
return cfg
}
func Load(version string) *Config {
func Load(configFlag string, version string) *Config {
config := &Config{}
flag.Parse()
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
if err := configor.New(&configor.Config{}).Load(config, configFlag); err != nil {
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.SessionKey = sessionKey
if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
}
config.Server.BasePath = strings.TrimSuffix(config.Server.BasePath, "/")
for k, v := range config.App.CustomLanguages {
if v == "" {

View File

@ -12,6 +12,7 @@ const (
TopicHeartbeat = "heartbeat.*"
TopicProjectLabel = "project_label.*"
EventUserUpdate = "user.update"
EventUserDelete = "user.delete"
EventHeartbeatCreate = "heartbeat.create"
EventProjectLabelCreate = "project_label.create"
EventProjectLabelDelete = "project_label.delete"

View File

@ -2,10 +2,11 @@ package config
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"math"
"runtime"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
)
var jobQueues map[string]*artifex.Dispatcher
@ -28,7 +29,9 @@ type JobQueueMetrics struct {
func init() {
jobQueues = make(map[string]*artifex.Dispatcher)
}
func StartJobs() {
InitQueue(QueueDefault, 1)
InitQueue(QueueProcessing, halfCPUs())
InitQueue(QueueReports, 1)

View File

@ -113,8 +113,7 @@ func initSentry(config sentryConfig, debug bool) {
AttachStacktrace: true,
EnableTracing: config.EnableTracing,
TracesSampler: func(ctx sentry.SamplingContext) float64 {
hub := sentry.GetHubFromContext(ctx.Span.Context())
txName := hub.Scope().Transaction()
txName := ctx.Span.Name
for _, ex := range excludedRoutes {
if strings.HasPrefix(txName, ex) {
return 0.0

File diff suppressed because it is too large Load Diff

74
go.mod
View File

@ -1,15 +1,16 @@
module github.com/muety/wakapi
go 1.19
go 1.20
require (
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-smtp v0.16.0
github.com/emersion/go-smtp v0.17.0
github.com/emvi/logbuch v1.2.0
github.com/getsentry/sentry-go v0.17.0
github.com/glebarez/sqlite v1.6.0
github.com/getsentry/sentry-go v0.22.0
github.com/glebarez/sqlite v1.9.0
github.com/go-chi/chi/v5 v5.0.8
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
@ -24,56 +25,57 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.8.1
github.com/stripe/stripe-go/v74 v74.6.0
github.com/swaggo/http-swagger v1.3.3
github.com/swaggo/swag v1.8.10
go.uber.org/atomic v1.10.0
golang.org/x/crypto v0.5.0
golang.org/x/sync v0.1.0
gorm.io/driver/mysql v1.4.5
gorm.io/driver/postgres v1.4.6
gorm.io/driver/sqlite v1.4.4
gorm.io/gorm v1.24.3
github.com/stretchr/testify v1.8.2
github.com/stripe/stripe-go/v74 v74.25.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.1
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.11.0
golang.org/x/sync v0.3.0
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.5.2
gorm.io/gorm v1.25.2
)
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/davecgh/go-spew v1.1.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/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.8 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // 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/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // 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/swaggo/files v1.0.0 // indirect
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d // indirect
golang.org/x/image v0.3.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/tools v0.5.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/tools v0.11.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.20.3 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

235
go.sum
View File

@ -1,35 +1,35 @@
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
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 v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4=
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/duke-git/lancet/v2 v2.1.13 h1:KOCCVrfh4pjuwl6td5MQ4OqvV73qFdoGxv20HWmyPaM=
github.com/duke-git/lancet/v2 v2.1.13/go.mod h1:hNcc06mV7qr+crH/0nP+rlC3TB0Q9g5OrVnO8/TGD4c=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/duke-git/lancet/v2 v2.2.3 h1:Lj4iWgvEbgktEjAfqxE1G2BoGm1mL7l3QHBlXRYptjE=
github.com/duke-git/lancet/v2 v2.2.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/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-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
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.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
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/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/getsentry/sentry-go v0.17.0 h1:UustVWnOoDFHBS7IJUB2QK/nB5pap748ZEp0swnQJak=
github.com/getsentry/sentry-go v0.17.0/go.mod h1:B82dxtBvxG0KaPD8/hfSV+VcHD+Lg/xUS4JuQn1P4cM=
github.com/glebarez/go-sqlite v1.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8=
github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak=
github.com/glebarez/sqlite v1.6.0 h1:ZpvDLv4zBi2cuuQPitRiVz/5Uh6sXa5d8eBu0xNTpAo=
github.com/glebarez/sqlite v1.6.0/go.mod h1:6D6zPU/HTrFlYmVDKqBJlmQvma90P6r7sRRdkUUZOYk=
github.com/getsentry/sentry-go v0.22.0 h1:XNX9zKbv7baSEI65l+H1GEJgSeIC1c7EN5kluWaP6dM=
github.com/getsentry/sentry-go v0.22.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc=
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/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
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.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
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.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
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.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-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.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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
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/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/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/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/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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
github.com/jackc/pgx/v5 v5.4.1 h1:oKfB/FhuVtit1bBM3zNRRsZ925ZkMN3HXL+LgLUM9lE=
github.com/jackc/pgx/v5 v5.4.1/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
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/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/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/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/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/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
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.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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
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.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/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/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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/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/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-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/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.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
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/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
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/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.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.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.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/stripe/stripe-go/v74 v74.6.0 h1:IQTT+psxj1hkXo6onQsyfw5Eopj7p6e0oltkWLlf0Tw=
github.com/stripe/stripe-go/v74 v74.6.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.25.0 h1:mGJp9L1ymxjFvq5MlmG6ynv/fAGX6LLU8MyMVsiRAMY=
github.com/stripe/stripe-go/v74 v74.25.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
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=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
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-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-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d h1:9Bio0JlZpJ1P4NXsK5i8Rf2MclrRzMGzJWOIkhZ5Um8=
golang.org/x/exp v0.0.0-20230125214544-b3c2aaf6208d/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
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/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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-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-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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
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-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-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.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-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-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-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-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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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-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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
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=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
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=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

107
helpers/interval.go Normal file
View 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
View 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)
}

View File

@ -3,7 +3,6 @@ package helpers
import (
"errors"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
@ -67,6 +66,9 @@ func ParseSummaryFilters(r *http.Request) *models.Filters {
if q := r.URL.Query().Get("branch"); q != "" {
filters.With(models.SummaryBranch, q)
}
if q := r.URL.Query().Get("entity"); q != "" {
filters.With(models.SummaryBranch, q)
}
return filters
}
@ -79,69 +81,3 @@ func extractUser(r *http.Request) *models.User {
}
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
}

50
main.go
View File

@ -2,15 +2,7 @@ package main
import (
"embed"
"github.com/go-chi/chi/v5"
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"
"flag"
"io/fs"
"log"
"net"
@ -19,23 +11,30 @@ import (
"strconv"
"time"
"github.com/muety/wakapi/static/docs"
"github.com/emvi/logbuch"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/services/mail"
"github.com/go-chi/chi/v5"
middleware "github.com/go-chi/chi/v5/middleware"
"github.com/lpar/gzipped/v2"
httpSwagger "github.com/swaggo/http-swagger"
_ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"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
@ -106,7 +105,15 @@ var (
// @name Authorization
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
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
@ -117,6 +124,7 @@ func main() {
} else {
logbuch.SetLevel(logbuch.LevelInfo)
}
logbuch.Info("Wakapi " + version)
// Set up GORM
gormLogger := logger.New(
@ -184,6 +192,7 @@ func main() {
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
// Schedule background tasks
go conf.StartJobs()
go aggregationService.Schedule()
go leaderboardService.Schedule()
go reportService.Schedule()
@ -235,6 +244,7 @@ func main() {
"/favicon",
"/service-worker.js",
"/api/health",
"/api/avatar",
}),
)
if config.Sentry.Dsn != "" {

View File

@ -1,12 +1,13 @@
package migrations
import (
"regexp"
"strings"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"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
@ -37,7 +38,7 @@ func init() {
}
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
}
} else {

47
mocks/summary_service.go Normal file
View File

@ -0,0 +1,47 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/types"
"github.com/stretchr/testify/mock"
"time"
)
type SummaryServiceMock struct {
mock.Mock
}
func (m *SummaryServiceMock) Aliased(t time.Time, t2 time.Time, u *models.User, r types.SummaryRetriever, f *models.Filters, b bool) (*models.Summary, error) {
args := m.Called(t, t2, u, r, f)
return args.Get(0).(*models.Summary), args.Error(1)
}
func (m *SummaryServiceMock) Retrieve(t time.Time, t2 time.Time, u *models.User, f *models.Filters) (*models.Summary, error) {
args := m.Called(t, t2, u, f)
return args.Get(0).(*models.Summary), args.Error(1)
}
func (m *SummaryServiceMock) Summarize(t time.Time, t2 time.Time, u *models.User, f *models.Filters) (*models.Summary, error) {
args := m.Called(t, t2, u, f)
return args.Get(0).(*models.Summary), args.Error(1)
}
func (m *SummaryServiceMock) GetLatestByUser() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *SummaryServiceMock) DeleteByUser(s string) error {
args := m.Called(s)
return args.Error(0)
}
func (m *SummaryServiceMock) DeleteByUserBefore(s string, t time.Time) error {
args := m.Called(s, t)
return args.Error(0)
}
func (m *SummaryServiceMock) Insert(s *models.Summary) error {
args := m.Called(s)
return args.Error(0)
}

View File

@ -99,11 +99,6 @@ func (m *UserServiceMock) SetWakatimeApiCredentials(user *models.User, s1, s2 st
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) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)

View 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"`
}

View 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"`
}

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"math"
"time"
@ -14,19 +15,26 @@ type StatsViewModel struct {
}
type StatsData struct {
Username string `json:"username"`
UserId string `json:"user_id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
TotalSeconds float64 `json:"total_seconds"`
DailyAverage float64 `json:"daily_average"`
DaysIncludingHolidays int `json:"days_including_holidays"`
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"`
Username string `json:"username"`
UserId string `json:"user_id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Status string `json:"status"`
TotalSeconds float64 `json:"total_seconds"`
DailyAverage float64 `json:"daily_average"`
DaysIncludingHolidays int `json:"days_including_holidays"`
Range string `json:"range"`
HumanReadableRange string `json:"human_readable_range"`
HumanReadableTotal string `json:"human_readable_total"`
HumanReadableDailyAverage string `json:"human_readable_daily_average"`
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 {
@ -38,11 +46,16 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
UserId: summary.UserID,
Start: summary.FromTime.T(),
End: summary.ToTime.T(),
Status: "ok",
TotalSeconds: totalTime.Seconds(),
DailyAverage: totalTime.Seconds() / float64(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) {
data.DailyAverage = 0
}
@ -77,6 +90,8 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
branches[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryBranch))
}
// entities omitted intentionally
data.Editors = editors
data.Languages = languages
data.Machines = machines

View File

@ -35,6 +35,7 @@ type SummariesData struct {
OperatingSystems []*SummariesEntry `json:"operating_systems"`
Projects []*SummariesEntry `json:"projects"`
Branches []*SummariesEntry `json:"branches,omitempty"`
Entities []*SummariesEntry `json:"entities,omitempty"`
GrandTotal *SummariesGrandTotal `json:"grand_total"`
Range *SummariesRange `json:"range"`
}
@ -132,8 +133,8 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
var wg sync.WaitGroup
wg.Add(6)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Projects {
@ -141,6 +142,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Editors {
@ -148,6 +150,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Languages {
@ -155,6 +158,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.OperatingSystems {
@ -162,6 +166,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Machines {
@ -169,6 +174,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Branches {
@ -176,9 +182,20 @@ func newDataFrom(s *models.Summary) *SummariesData {
}
}(data)
wg.Add(1)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Entities {
data.Entities[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEntity))
}
}(data)
if s.Branches == nil {
data.Branches = nil
}
if s.Entities == nil {
data.Entities = nil
}
wg.Wait()
return data

View File

@ -5,6 +5,7 @@ import (
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"time"
"unicode"
)
type Duration struct {
@ -17,8 +18,24 @@ type Duration struct {
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Branch string `json:"branch"`
Entity string `json:"entity"`
NumHeartbeats int `json:"-" hash:"ignore"`
GroupHash string `json:"-" hash:"ignore"`
excludeEntity bool `json:"-" hash:"ignore"`
}
func (d *Duration) HashInclude(field string, v interface{}) (bool, error) {
if field == "Entity" {
return !d.excludeEntity, nil
}
if field == "Time" ||
field == "Duration" ||
field == "NumHeartbeats" ||
field == "GroupHash" ||
unicode.IsLower(rune(field[0])) {
return false, nil
}
return true, nil
}
func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
@ -32,11 +49,17 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
OperatingSystem: h.OperatingSystem,
Machine: h.Machine,
Branch: h.Branch,
Entity: h.Entity,
NumHeartbeats: 1,
}
return d.Hashed()
}
func (d *Duration) WithEntityIgnored() *Duration {
d.excludeEntity = true
return d
}
func (d *Duration) Hashed() *Duration {
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
if err != nil {
@ -60,6 +83,8 @@ func (d *Duration) GetKey(t uint8) (key string) {
key = d.Machine
case SummaryBranch:
key = d.Branch
case SummaryEntity:
key = d.Entity
}
if key == "" {

View File

@ -14,6 +14,7 @@ type Filters struct {
Machine OrFilter
Label OrFilter
Branch OrFilter
Entity OrFilter
}
type OrFilter []string
@ -24,7 +25,7 @@ func (f OrFilter) Exists() bool {
func (f OrFilter) MatchAny(search string) bool {
for _, s := range f {
if s == search {
if s == search || (s == "-" && search == "") {
return true
}
}
@ -65,6 +66,8 @@ func (f *Filters) WithMultiple(entity uint8, keys []string) *Filters {
f.Label = append(f.Label, keys...)
case SummaryBranch:
f.Branch = append(f.Branch, keys...)
case SummaryEntity:
f.Entity = append(f.Entity, keys...)
}
return f
}
@ -84,6 +87,8 @@ func (f *Filters) One() (bool, uint8, OrFilter) {
return true, SummaryLabel, f.Label
} else if f.Branch != nil && f.Branch.Exists() {
return true, SummaryBranch, f.Branch
} else if f.Entity != nil && f.Entity.Exists() {
return true, SummaryEntity, f.Entity
}
return false, 0, OrFilter{}
}
@ -102,7 +107,7 @@ func (f *Filters) IsEmpty() bool {
func (f *Filters) Count() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
for i := SummaryProject; i <= SummaryEntity; i++ {
count += f.CountByEntity(i)
}
return count
@ -114,7 +119,7 @@ func (f *Filters) CountByEntity(entity uint8) int {
func (f *Filters) EntityCount() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
for i := SummaryProject; i <= SummaryEntity; i++ {
if c := f.CountByEntity(i); c > 0 {
count++
}
@ -138,6 +143,8 @@ func (f *Filters) ResolveEntity(entityId uint8) *OrFilter {
return &f.Label
case SummaryBranch:
return &f.Branch
case SummaryEntity:
return &f.Entity
default:
return &OrFilter{}
}
@ -209,6 +216,7 @@ func (f *Filters) WithAliases(resolve AliasReverseResolver) *Filters {
}
f.Branch = updated
}
// no aliases for entites / files
return f
}
@ -221,3 +229,7 @@ func (f *Filters) WithProjectLabels(resolve ProjectLabelReverseResolver) *Filter
}
return f
}
func (f *Filters) IsProjectDetails() bool {
return f != nil && f.Project != nil && f.Project.Exists()
}

View File

@ -2,6 +2,7 @@ package models
import (
"fmt"
"github.com/duke-git/lancet/v2/strutil"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"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
}
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) {
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
for ending, value := range languageMappings {
@ -63,6 +80,8 @@ func (h *Heartbeat) GetKey(t uint8) (key string) {
key = h.Machine
case SummaryBranch:
key = h.Branch
case SummaryEntity:
key = h.Entity
}
if key == "" {

View File

@ -1,27 +1,33 @@
package models
import (
"unicode"
)
// Support Wakapi and WakaTime range / interval identifiers
// See https://wakatime.com/developers/#summaries
var (
IntervalToday = &IntervalKey{"today", "Today"}
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"}
IntervalLastWeek = &IntervalKey{"Last Week"}
IntervalLastWeek = &IntervalKey{"last_week", "Last Week"}
IntervalThisMonth = &IntervalKey{"month", "This Month"}
IntervalLastMonth = &IntervalKey{"Last Month"}
IntervalThisYear = &IntervalKey{"year"}
IntervalLastMonth = &IntervalKey{"last_month", "Last Month"}
IntervalThisYear = &IntervalKey{"year", "This Year"}
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
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"}
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
IntervalAny = &IntervalKey{"any", "all_time"}
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months", "Last 6 Months"}
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year", "Last 12 Months"}
IntervalAny = &IntervalKey{"any", "all_time", "All Time"}
)
var AllIntervals = []*IntervalKey{
IntervalToday,
IntervalYesterday,
IntervalPastDay,
IntervalThisWeek,
IntervalLastWeek,
IntervalThisMonth,
@ -46,3 +52,12 @@ func (k *IntervalKey) HasAlias(s string) bool {
}
return false
}
func (k *IntervalKey) GetHumanReadable() string {
for _, s := range *k {
if unicode.IsUpper(rune(s[0])) {
return s
}
}
return ""
}

View File

@ -2,18 +2,22 @@ package models
import (
"fmt"
uuid "github.com/satori/go.uuid"
"strings"
"time"
)
const HtmlType = "text/html; charset=UTF-8"
const PlainType = "text/html; charset=UTF-8"
type Mail struct {
From MailAddress
To MailAddresses
Subject string
Body string
Type string
From MailAddress
To MailAddresses
Subject string
Body string
Type string
Date time.Time
MessageID string
}
func (m *Mail) WithText(text string) *Mail {
@ -28,17 +32,36 @@ func (m *Mail) WithHTML(html string) *Mail {
return m
}
func (m *Mail) Sanitized() *Mail {
if m.Type == "" {
m.Type = PlainType
}
if m.Date.IsZero() {
m.Date = time.Now()
}
if m.MessageID == "" {
m.MessageID = fmt.Sprintf("<%s@%s>", uuid.NewV4().String(), m.From.Domain())
}
return m
}
func (m *Mail) String() string {
return fmt.Sprintf("To: %s\r\n"+
"From: %s\r\n"+
"Subject: %s\r\n"+
"Message-ID: %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: %s\r\n"+
"Content-Transfer-Encoding: 8bit\r\n"+
"Date: %s\r\n"+
"\r\n"+
"%s\r\n",
strings.Join(m.To.RawStrings(), ", "),
m.From.String(),
m.Subject,
m.MessageID,
m.Type,
m.Date.Format(time.RFC1123Z),
m.Body,
)
}

View File

@ -1,6 +1,9 @@
package models
import "regexp"
import (
"regexp"
"strings"
)
const (
MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
@ -36,6 +39,14 @@ func (m MailAddress) Raw() string {
return ""
}
func (m MailAddress) Domain() string {
split := strings.Split(m.Raw(), "@")
if len(split) != 2 {
return ""
}
return split[1]
}
func (m MailAddress) Valid() bool {
return emailAddrRegex.Match([]byte(m))
}

View File

@ -3,8 +3,9 @@ package models
import "time"
type Report struct {
From time.Time
To time.Time
User *User
Summary *Summary
From time.Time
To time.Time
User *User
Summary *Summary
DailySummaries []*Summary
}

View File

@ -16,6 +16,7 @@ const (
SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
SummaryBranch uint8 = 6
SummaryEntity uint8 = 7
)
const UnknownSummaryKey = "unknown"
@ -34,6 +35,7 @@ type Summary struct {
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project filter is applied
Entities SummaryItems `json:"entities" gorm:"-"` // entities are not persisted, but calculated at runtime in case a project filter is applied
NumHeartbeats int `json:"-"`
}
@ -62,11 +64,11 @@ type SummaryParams struct {
}
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel, SummaryBranch}
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel, SummaryBranch, SummaryEntity}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryBranch}
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryBranch, SummaryEntity}
}
func PersistedSummaryTypes() []uint8 {
@ -81,6 +83,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
sort.Sort(sort.Reverse(s.Branches))
sort.Sort(sort.Reverse(s.Entities))
return s
}
@ -97,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
SummaryBranch: &s.Branches,
SummaryEntity: &s.Entities,
}
}
@ -116,6 +120,8 @@ func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return &s.Labels
case SummaryBranch:
return &s.Branches
case SummaryEntity:
return &s.Entities
}
return nil
}
@ -325,6 +331,7 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
s.Branches = processAliases(s.Branches)
// no aliases for entities / files
return s
}

8
models/types/types.go Normal file
View File

@ -0,0 +1,8 @@
package types
import (
"github.com/muety/wakapi/models"
"time"
)
type SummaryRetriever func(f, t time.Time, u *models.User, filters *models.Filters) (*models.Summary, error)

View File

@ -163,6 +163,10 @@ func (u *User) MinDataAge() time.Time {
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 {
return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat

View File

@ -1,10 +1,11 @@
package models
import (
conf "github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
"testing"
"time"
conf "github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
)
func TestUser_TZ(t *testing.T) {
@ -21,7 +22,7 @@ func TestUser_TZ(t *testing.T) {
}
func TestUser_MinDataAge(t *testing.T) {
c := conf.Load("")
c := conf.Load("", "")
var sut *User

View File

@ -1,6 +1,7 @@
package repositories
import (
"github.com/duke-git/lancet/v2/slice"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
@ -89,7 +90,13 @@ func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *mo
Order("time asc")
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 {

View File

@ -2,6 +2,7 @@ package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"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) {
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").
Where("`key` like ?", like).
Where(condition, like).
Find(&keyValues).
Error; err != nil {
return nil, err

View File

@ -125,26 +125,27 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
updateMap := map[string]interface{}{
"api_key": user.ApiKey,
"password": user.Password,
"email": user.Email,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
"share_languages": user.ShareLanguages,
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey,
"wakatime_api_url": user.WakatimeApiUrl,
"has_data": user.HasData,
"reset_token": user.ResetToken,
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
"subscribed_until": user.SubscribedUntil,
"stripe_customer_id": user.StripeCustomerId,
"api_key": user.ApiKey,
"password": user.Password,
"email": user.Email,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
"share_languages": user.ShareLanguages,
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey,
"wakatime_api_url": user.WakatimeApiUrl,
"has_data": user.HasData,
"reset_token": user.ResetToken,
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
"subscribed_until": user.SubscribedUntil,
"subscription_renewal": user.SubscriptionRenewal,
"stripe_customer_id": user.StripeCustomerId,
}
result := r.db.Model(user).Updates(updateMap)

View File

@ -34,7 +34,7 @@ func NewBadgeHandler(userService services.IUserService, summaryService services.
}
func (h *BadgeHandler) RegisterRoutes(router chi.Router) {
router.Get("/badge/{user}", h.Get)
router.Get("/badge/{user}/*", h.Get)
}
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
@ -51,7 +51,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
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)
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
respondSvg(w, cacheResult.([]byte))

154
routes/api/badge_test.go Normal file
View File

@ -0,0 +1,154 @@
package api
import (
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"io/ioutil"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
var (
user1 = models.User{
ID: "user1",
ShareDataMaxDays: 30,
ShareLanguages: true,
}
summary1 = models.Summary{
User: &user1,
UserID: "user1",
FromTime: models.CustomTime(time.Date(2023, 3, 14, 0, 0, 0, 0, time.Local)),
ToTime: models.CustomTime(time.Date(2023, 3, 14, 23, 59, 59, 0, time.Local)),
Languages: []*models.SummaryItem{
{
Type: models.SummaryLanguage,
Key: "go",
Total: 12 * time.Minute / time.Second,
},
},
}
)
func TestBadgeHandler_Get(t *testing.T) {
router := chi.NewRouter()
apiRouter := chi.NewRouter()
apiRouter.Use(middlewares.NewPrincipalMiddleware())
router.Mount("/api", apiRouter)
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", "user1").Return(&user1, nil)
summaryServiceMock := new(mocks.SummaryServiceMock)
summaryServiceMock.On("Aliased", mock.AnythingOfType("time.Time"), mock.AnythingOfType("time.Time"), &user1, mock.Anything, mock.Anything).Return(&summary1, nil)
badgeHandler := NewBadgeHandler(userServiceMock, summaryServiceMock)
badgeHandler.RegisterRoutes(apiRouter)
t.Run("when requesting badge", func(t *testing.T) {
t.Run("should return badge", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/badge/{user}/interval:week/language:go", nil)
req = withUrlParam(req, "user", "user1")
router.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
assert.True(t, strings.HasPrefix(string(data), "<svg"))
assert.Contains(t, string(data), "0 hrs 12 mins")
})
t.Run("should not return badge if shared interval exceeded", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/badge/{user}/interval:year/language:go", nil)
req = withUrlParam(req, "user", "user1")
router.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusForbidden, res.StatusCode)
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
assert.False(t, strings.HasPrefix(string(data), "<svg"))
})
t.Run("should not return badge if entity type not shared", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/badge/{user}/interval:year/project:foo", nil)
req = withUrlParam(req, "user", "user1")
router.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusForbidden, res.StatusCode)
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
assert.False(t, strings.HasPrefix(string(data), "<svg"))
})
})
}
func TestBadgeHandler_EntityPattern(t *testing.T) {
type test struct {
test string
key string
val string
}
pathPrefix := "/compat/shields/v1/current/today/"
tests := []test{
{test: pathPrefix + "project:wakapi", key: "project", val: "wakapi"},
{test: pathPrefix + "os:Linux", key: "os", val: "Linux"},
{test: pathPrefix + "editor:VSCode", key: "editor", val: "VSCode"},
{test: pathPrefix + "language:Java", key: "language", val: "Java"},
{test: pathPrefix + "machine:devmachine", key: "machine", val: "devmachine"},
{test: pathPrefix + "label:work", key: "label", val: "work"},
{test: pathPrefix + "foo:bar", key: "", val: ""}, // invalid entity
{test: pathPrefix + "project:01234", key: "project", val: "01234"}, // digits only
{test: pathPrefix + "project:anchr-web-ext", key: "project", val: "anchr-web-ext"}, // with dashes
{test: pathPrefix + "project:wakapi v2", key: "project", val: "wakapi v2"}, // with blank space
{test: pathPrefix + "project:project", key: "project", val: "project"},
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
}
sut := regexp.MustCompile(`(project|os|editor|language|machine|label):([^:?&/]+)`) // see entityFilterPattern in badge_utils.go
for _, tc := range tests {
var key, val string
if groups := sut.FindStringSubmatch(tc.test); len(groups) > 2 {
key, val = groups[1], groups[2]
}
assert.Equal(t, tc.key, key)
assert.Equal(t, tc.val, val)
}
}

View File

@ -1,6 +1,7 @@
package api
import (
"github.com/duke-git/lancet/v2/condition"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"net/http"
@ -87,11 +88,22 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
return
}
hb.OperatingSystem = opSys
hb.Editor = editor
hb.Machine = machineName
// TODO: unit test this
if hb.UserAgent != "" {
userAgent = hb.UserAgent
localOpSys, localEditor, _ := utils.ParseUserAgent(userAgent)
opSys = condition.TernaryOperator[bool, string](localOpSys != "", localOpSys, opSys)
editor = condition.TernaryOperator[bool, string](localEditor != "", localEditor, editor)
}
if hb.Machine != "" {
machineName = hb.Machine
}
hb.User = user
hb.UserID = user.ID
hb.Machine = machineName
hb.OperatingSystem = opSys
hb.Editor = editor
hb.UserAgent = userAgent
if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {

View File

@ -0,0 +1,17 @@
package api
import (
"context"
"github.com/go-chi/chi/v5"
"net/http"
"strings"
)
func withUrlParam(r *http.Request, key, value string) *http.Request {
r.URL.RawPath = strings.Replace(r.URL.RawPath, "{"+key+"}", value, 1)
r.URL.Path = strings.Replace(r.URL.Path, "{"+key+"}", value, 1)
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
return r
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models/types"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
"time"
@ -97,7 +98,7 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.Inter
User: user,
}
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
var retrieveSummary types.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}

View File

@ -1,43 +0,0 @@
package v1
import (
"github.com/stretchr/testify/assert"
"regexp"
"testing"
)
func TestBadgeHandler_EntityPattern(t *testing.T) {
type test struct {
test string
key string
val string
}
pathPrefix := "/compat/shields/v1/current/today/"
tests := []test{
{test: pathPrefix + "project:wakapi", key: "project", val: "wakapi"},
{test: pathPrefix + "os:Linux", key: "os", val: "Linux"},
{test: pathPrefix + "editor:VSCode", key: "editor", val: "VSCode"},
{test: pathPrefix + "language:Java", key: "language", val: "Java"},
{test: pathPrefix + "machine:devmachine", key: "machine", val: "devmachine"},
{test: pathPrefix + "label:work", key: "label", val: "work"},
{test: pathPrefix + "foo:bar", key: "", val: ""}, // invalid entity
{test: pathPrefix + "project:01234", key: "project", val: "01234"}, // digits only
{test: pathPrefix + "project:anchr-web-ext", key: "project", val: "anchr-web-ext"}, // with dashes
{test: pathPrefix + "project:wakapi v2", key: "project", val: "wakapi v2"}, // with blank space
{test: pathPrefix + "project:project", key: "project", val: "project"},
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
}
sut := regexp.MustCompile(`(project|os|editor|language|machine|label):([^:?&/]+)`) // see entityFilterPattern in badge_utils.go
for _, tc := range tests {
var key, val string
if groups := sut.FindStringSubmatch(tc.test); len(groups) > 2 {
key, val = groups[1], groups[2]
}
assert.Equal(t, tc.key, key)
assert.Equal(t, tc.val, val)
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/models/types"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"net/http"
@ -68,7 +69,7 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filt
Recompute: false,
}
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
var retrieveSummary types.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}

View File

@ -76,8 +76,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
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 == "" {
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())
@ -103,6 +109,10 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
}
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
if !requestedUser.ShareEditors {

View File

@ -3,6 +3,7 @@ package v1
import (
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models/types"
"net/http"
"time"
@ -90,7 +91,7 @@ func (h *StatusBarHandler) loadUserSummary(user *models.User, start, end time.Ti
Recompute: false,
}
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
var retrieveSummary types.SummaryRetriever = h.summarySrvc.Retrieve
if summaryParams.Recompute {
retrieveSummary = h.summarySrvc.Summarize
}

View File

@ -77,7 +77,7 @@ func TestUsersHandler_Get(t *testing.T) {
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
t.Errorf("unexpected error. Error: %s", err)
}
if !strings.Contains(string(data), "\"username\":\"AdminUser\"") {

View File

@ -93,7 +93,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
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)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid credentials"))
return
@ -252,7 +252,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
user.Password = setRequest.Password
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)
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"))

View File

@ -65,6 +65,7 @@ func (h *RelayHandler) Any(w http.ResponseWriter, r *http.Request) {
w.Write([]byte{})
return
}
targetUrl.RawQuery = r.URL.RawQuery
p := httputil.ReverseProxy{
Director: func(r *http.Request) {

View File

@ -1,6 +1,7 @@
package routes
import (
"github.com/duke-git/lancet/v2/strutil"
"github.com/muety/wakapi/helpers"
"html/template"
"net/http"
@ -34,7 +35,7 @@ func DefaultTemplateFuncs() template.FuncMap {
"title": strings.Title,
"join": strings.Join,
"add": add,
"capitalize": utils.Capitalize,
"capitalize": strutil.Capitalize,
"lower": strings.ToLower,
"toRunes": utils.ToRunes,
"localTZOffset": utils.LocalTZOffset,
@ -90,6 +91,9 @@ func typeName(t uint8) string {
if t == models.SummaryBranch {
return "branch"
}
if t == models.SummaryEntity {
return "entity"
}
return "unknown"
}

View File

@ -217,7 +217,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
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"
}
@ -226,7 +226,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
}
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
} else {
user.Password = hash
@ -513,23 +513,27 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
go func(user *models.User) {
start := time.Now()
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
importer := imports.NewWakatimeImporter(user.WakatimeApiKey)
countBefore, err := h.heartbeatSrvc.CountByUser(user)
if err != nil {
println(err)
}
countBefore, _ := h.heartbeatSrvc.CountByUser(user)
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 {
stream = importer.ImportAll(user)
stream, importError = importer.ImportAll(user)
} else {
// 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
batch := make([]*models.Heartbeat, 0)
batch := make([]*models.Heartbeat, 0, h.config.App.ImportBatchSize)
insert := func(batch []*models.Heartbeat) {
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 {
insert(batch)
batch = make([]*models.Heartbeat, 0)
batch = make([]*models.Heartbeat, 0, h.config.App.ImportBatchSize)
}
}
if len(batch) > 0 {
insert(batch)
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"github.com/emvi/logbuch"
"github.com/go-chi/chi/v5"
"github.com/leandro-lugaresi/hub"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
@ -16,6 +17,7 @@ import (
stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session"
stripeCustomer "github.com/stripe/stripe-go/v74/customer"
stripePrice "github.com/stripe/stripe-go/v74/price"
stripeSubscription "github.com/stripe/stripe-go/v74/subscription"
"github.com/stripe/stripe-go/v74/webhook"
"io/ioutil"
"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'
*/
// TODO: move all logic inside this controller into a separate service
type SubscriptionHandler struct {
config *conf.Config
eventBus *hub.Hub
userSrvc services.IUserService
mailSrvc services.IMailService
keyValueSrvc services.IKeyValueService
@ -46,6 +51,7 @@ func NewSubscriptionHandler(
keyValueService services.IKeyValueService,
) *SubscriptionHandler {
config := conf.Get()
eventBus := conf.EventBus()
if config.Subscriptions.Enabled {
stripe.Key = config.Subscriptions.StripeSecretKey
@ -59,13 +65,32 @@ func NewSubscriptionHandler(
logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice)
}
return &SubscriptionHandler{
handler := &SubscriptionHandler{
config: config,
userSrvc: userService,
mailSrvc: mailService,
keyValueSrvc: keyValueService,
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
@ -194,8 +219,7 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
// example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k
subscription, err := h.parseSubscriptionEvent(w, r, event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
return // status code already written
}
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)
if err != nil {
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
}
u, err := h.userSrvc.GetUserByEmail(customer.Email)
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)
w.WriteHeader(http.StatusInternalServerError)
w.WriteHeader(http.StatusOK) // don't make stripe retry the event
return
}
user = u
@ -223,7 +247,7 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
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)
w.WriteHeader(http.StatusInternalServerError)
w.WriteHeader(http.StatusOK) // don't make stripe retry the event
return
}
@ -231,16 +255,14 @@ func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request
// example payload: https://pastr.de/p/d01iniw9naq9hkmvyqtxin2w
checkoutSession, err := h.parseCheckoutSessionEvent(w, r, event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
return // status code already written
}
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)
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)
w.WriteHeader(http.StatusInternalServerError)
return
return // status code already written
}
if user.StripeCustomerId == "" {
@ -325,6 +347,16 @@ func (h *SubscriptionHandler) parseCheckoutSessionEvent(w http.ResponseWriter, r
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) {
params := &stripe.CustomerSearchParams{
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: &paramStatus,
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) {
key := fmt.Sprintf("%s_%s", conf.KeySubscriptionNotificationSent, userId)
if err := h.keyValueSrvc.DeleteString(key); err != nil {

View File

@ -3,6 +3,7 @@ package utils
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/types"
"github.com/muety/wakapi/services"
"net/http"
"strings"
@ -17,7 +18,7 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
}
func LoadUserSummaryByParams(ss services.ISummaryService, params *models.SummaryParams) (*models.Summary, error, int) {
var retrieveSummary services.SummaryRetriever = ss.Retrieve
var retrieveSummary types.SummaryRetriever = ss.Retrieve
if params.Recompute {
retrieveSummary = ss.Summarize
}

View File

@ -77,7 +77,8 @@ let icons = [
'mdi:code-json',
'mdi:bash',
'twemoji:frowning-face',
'ci:dot-03-m'
'ci:dot-03-m',
'jam:crown-f',
]
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))

View File

@ -51,6 +51,9 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
}
d1 := models.NewDurationFromHeartbeat(h)
if !filters.IsProjectDetails() {
d1 = d1.WithEntityIgnored() // only for efficiency
}
if list, ok := mapping[d1.GroupHash]; !ok || len(list) < 1 {
mapping[d1.GroupHash] = []*models.Duration{d1}

View File

@ -26,6 +26,8 @@ const (
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
TestEntity1 = "/home/bob/dev/wakapi.go"
TestEntity2 = "/home/bob/dev/SomethingElse.java"
TestBranchMaster = "master"
TestBranchDev = "dev"
MinUnixTime1 = 1601510400000 * 1e6

View File

@ -64,6 +64,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
for _, hb := range heartbeats {
if !hashes.Contain(hb.Hash) {
hb = hb.Sanitize()
filteredHeartbeats = append(filteredHeartbeats, hb)
hashes.Add(hb.Hash)
}
@ -238,6 +239,7 @@ func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbe
go srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
go srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
go srv.updateEntityUserCache(models.SummaryBranch, hb.Branch, hb.User)
go srv.updateEntityUserCache(models.SummaryEntity, hb.Entity, hb.User)
}
func (srv *HeartbeatService) notifyBatch(heartbeats []*models.Heartbeat) {

View File

@ -5,7 +5,7 @@ import (
"time"
)
type HeartbeatImporter interface {
Import(*models.User, time.Time, time.Time) <-chan *models.Heartbeat
ImportAll(*models.User) <-chan *models.Heartbeat
type DataImporter interface {
Import(*models.User, time.Time, time.Time) (<-chan *models.Heartbeat, error)
ImportAll(*models.User) (<-chan *models.Heartbeat, error)
}

View File

@ -1,338 +1,30 @@
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"
"strings"
"time"
)
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 WakatimeHeartbeatImporter struct {
ApiKey string
httpClient *http.Client
queue *artifex.Dispatcher
type WakatimeImporter struct {
apiKey string
}
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
return &WakatimeHeartbeatImporter{
ApiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
queue: config.GetQueue(config.QueueImports),
}
func NewWakatimeImporter(apiKey string) *WakatimeImporter {
return &WakatimeImporter{apiKey: apiKey}
}
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
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 := 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)
}
func (w *WakatimeImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) (<-chan *models.Heartbeat, error) {
if strings.Contains(user.WakaTimeURL(config.WakatimeApiUrl), "wakatime.com") {
return NewWakatimeDumpImporter(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
return NewWakatimeHeartbeatImporter(w.apiKey).Import(user, minFrom, maxTo)
}
func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.Heartbeat {
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 *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
func (w *WakatimeImporter) ImportAll(user *models.User) (<-chan *models.Heartbeat, error) {
if strings.Contains(user.WakaTimeURL(config.WakatimeApiUrl), "wakatime.com") {
return NewWakatimeDumpImporter(w.apiKey).ImportAll(user)
}
return NewWakatimeHeartbeatImporter(w.apiKey).ImportAll(user)
}

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

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

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

View File

@ -223,11 +223,13 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model
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{
User: user,
UserID: user.ID,
Interval: (*interval)[0],
Total: summary.TotalTime(),
Total: total,
}, nil
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
@ -58,13 +59,10 @@ func (s *MailWhaleSendingService) Send(mail *models.Mail) error {
req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret)
req.Header.Set("Content-Type", "application/json")
res, err := s.httpClient.Do(req)
_, err = utils.RaiseForStatus(s.httpClient.Do(req))
if err != nil {
return err
}
if res.StatusCode >= 400 {
return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode))
}
return nil
}

View File

@ -26,6 +26,8 @@ func NewSMTPSendingService(config conf.SMTPMailConfig) *SMTPSendingService {
}
func (s *SMTPSendingService) Send(mail *models.Mail) error {
mail = mail.Sanitized()
dial := smtp.Dial
if s.config.TLS {
dial = func(addr string) (*smtp.Client, error) {

View File

@ -268,16 +268,25 @@ func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User,
}
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
}
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
}
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
}

View File

@ -1,12 +1,14 @@
package services
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/duke-git/lancet/v2/slice"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math/rand"
"time"
)
@ -100,17 +102,34 @@ func (srv *ReportService) SendReport(user *models.User, duration time.Duration)
end := time.Now().In(user.TZ())
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 {
config.Log().Error("failed to generate report for '%s' - %v", user.ID, 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{
From: start,
To: end,
User: user,
Summary: summary,
From: start,
To: end,
User: user,
Summary: fullSummary,
DailySummaries: dailySummaries,
}
if err := srv.mailService.SendReport(user, report); err != nil {

View File

@ -3,6 +3,7 @@ package services
import (
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/types"
"github.com/muety/wakapi/utils"
"time"
)
@ -88,7 +89,7 @@ type IDurationService interface {
}
type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, *models.Filters, bool) (*models.Summary, error)
Aliased(time.Time, time.Time, *models.User, types.SummaryRetriever, *models.Filters, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
@ -138,7 +139,6 @@ type IUserService interface {
Delete(*models.User) error
ResetApiKey(*models.User) (*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)
FlushCache()
FlushUserCache(string)

View File

@ -7,6 +7,7 @@ import (
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/types"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"sort"
@ -24,8 +25,6 @@ type SummaryService struct {
projectLabelService IProjectLabelService
}
type SummaryRetriever func(f, t time.Time, u *models.User, filters *models.Filters) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
srv := &SummaryService{
config: config.Get(),
@ -50,7 +49,7 @@ func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationServ
// Public summary generation methods
// Aliased retrieves or computes a new summary based on the given SummaryRetriever and augments it with entity aliases and project labels
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, filters *models.Filters, skipCache bool) (*models.Summary, error) {
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f types.SummaryRetriever, filters *models.Filters, skipCache bool) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID, filters.Hash(), "--aliased")
if cacheResult, ok := srv.cache.Get(cacheKey); ok && !skipCache {
@ -85,8 +84,9 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
if withBranches := filters != nil && filters.Project != nil && filters.Project.Exists(); !withBranches {
if withDetails := filters != nil && filters.IsProjectDetails(); !withDetails {
summary.Branches = nil
summary.Entities = nil
}
srv.cache.SetDefault(cacheKey, summary)
@ -140,8 +140,9 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filt
}
types := models.PersistedSummaryTypes()
if filters != nil && filters.Project != nil && filters.Project.Exists() {
if filters != nil && filters.IsProjectDetails() {
types = append(types, models.SummaryBranch)
types = append(types, models.SummaryEntity)
}
typedAggregations := make(chan models.SummaryItemContainer)
@ -157,6 +158,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filt
var osItems []*models.SummaryItem
var machineItems []*models.SummaryItem
var branchItems []*models.SummaryItem
var entityItems []*models.SummaryItem
for i := 0; i < len(types); i++ {
item := <-typedAggregations
@ -173,6 +175,8 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filt
machineItems = item.Items
case models.SummaryBranch:
branchItems = item.Items
case models.SummaryEntity:
entityItems = item.Items
}
}
@ -191,6 +195,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filt
OperatingSystems: osItems,
Machines: machineItems,
Branches: branchItems,
Entities: entityItems,
NumHeartbeats: durations.TotalNumHeartbeats(),
}
@ -303,6 +308,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
Branches: make([]*models.SummaryItem, 0),
Entities: make([]*models.SummaryItem, 0),
}
var processed = map[time.Time]bool{}
@ -333,6 +339,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
finalSummary.Branches = srv.mergeSummaryItems(finalSummary.Branches, s.Branches)
finalSummary.Entities = srv.mergeSummaryItems(finalSummary.Entities, s.Entities)
finalSummary.NumHeartbeats += s.NumHeartbeats
processed[hash] = true

View File

@ -43,6 +43,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Branch: TestBranchMaster,
Entity: TestEntity1,
Time: models.CustomTime(suite.TestStartTime),
Duration: 150 * time.Second,
NumHeartbeats: 2,
@ -55,6 +56,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Branch: TestBranchMaster,
Entity: TestEntity1,
Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)),
Duration: 20 * time.Second,
NumHeartbeats: 1,
@ -67,6 +69,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Branch: TestBranchDev,
Entity: TestEntity1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
Duration: 15 * time.Second,
NumHeartbeats: 3,
@ -154,6 +157,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
assert.Equal(suite.T(), 185*time.Second, result.TotalTimeBy(models.SummaryLanguage))
assert.Equal(suite.T(), 185*time.Second, result.TotalTimeBy(models.SummaryEditor))
assert.Zero(suite.T(), result.TotalTimeBy(models.SummaryBranch)) // no filters -> no branches contained
assert.Zero(suite.T(), result.TotalTimeBy(models.SummaryEntity)) // no filters -> no entities contained
assert.Zero(suite.T(), result.TotalTimeBy(models.SummaryLabel))
assert.Equal(suite.T(), 170*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 15*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
@ -477,6 +481,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
result, _ := sut.Aliased(from, to, suite.TestUser, sut.Summarize, filters, false)
assert.NotNil(suite.T(), result.Branches) // project filters were applied -> include branches
assert.NotNil(suite.T(), result.Entities) // project filters were applied -> include entities
effectiveFilters := suite.DurationService.Calls[0].Arguments[3].(*models.Filters)
assert.Contains(suite.T(), effectiveFilters.Project, TestProject1) // because actually requested

View File

@ -157,7 +157,7 @@ func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*model
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
} else {
u.Password = hash
@ -194,17 +194,6 @@ func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey stri
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) {
return srv.repository.UpdateField(user, "reset_token", uuid.NewV4())
}
@ -214,6 +203,7 @@ func (srv *UserService) Delete(user *models.User) error {
user.ReportsWeekly = false
srv.notifyUpdate(user)
srv.notifyDelete(user)
return srv.repository.Delete(user)
}
@ -232,3 +222,10 @@ func (srv *UserService) notifyUpdate(user *models.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},
})
}

View File

@ -125,8 +125,12 @@ body {
@apply border-green-700 bg-gray-800;
}
.text-gold {
color: #DAA520;
}
.leaderboard-gold {
border-color: #ffd700;
border-color: #DAA520;
}
.leaderboard-silver {

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.

View File

@ -13,6 +13,7 @@ const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine')
const labelsCanvas = document.getElementById('chart-label')
const branchesCanvas = document.getElementById('chart-branches')
const entitiesCanvas = document.getElementById('chart-entities')
const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container')
@ -21,10 +22,11 @@ const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container')
const labelContainer = document.getElementById('label-container')
const branchContainer = document.getElementById('branch-container')
const entityContainer = document.getElementById('entity-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer, branchContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas, branchesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels, wakapiData.branches]
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer, branchContainer, entityContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas, branchesCanvas, entitiesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels, wakapiData.branches, wakapiData.entities]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -132,10 +134,9 @@ function draw(subselection) {
},
maintainAspectRatio: false,
onClick: (event, data) => {
const idx = data[0].index
const name = wakapiData.projects[idx].key
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
},
onHover: (event, elem) => {
@ -378,6 +379,62 @@ function draw(subselection) {
})
: null
let entityChart = entitiesCanvas && !entitiesCanvas.classList.contains('hidden') && shouldUpdate(7)
? new Chart(entitiesCanvas.getContext('2d'), {
//type: 'horizontalBar',
type: "bar",
data: {
datasets: [{
data: wakapiData.entities
.slice(0, Math.min(showTopN[7], wakapiData.entities.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.entities.map((p, i) => {
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.entities.map((p, i) => {
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
}],
labels: wakapiData.entities
.slice(0, Math.min(showTopN[7], wakapiData.entities.length))
.map(p => extractFile(p.key))
},
options: {
indexAxis: 'y',
scales: {
xAxes: {
title: {
display: true,
text: 'Duration (hh:mm:ss)',
},
ticks: {
callback: (label) => label.toString().toHHMMSS(),
}
}
},
plugins: {
legend: {
display: false,
},
tooltip: getTooltipOptions('entities'),
},
maintainAspectRatio: false,
onClick: (event, data) => {
const idx = data[0].index
const name = wakapiData.entities[idx].key
const url = new URL(window.location.href)
url.searchParams.set('project', name)
window.location.href = url.href
},
onHover: (event, elem) => {
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
}
}
})
: null
charts[0] = projectChart ? projectChart : charts[0]
charts[1] = osChart ? osChart : charts[1]
charts[2] = editorChart ? editorChart : charts[2]
@ -385,6 +442,7 @@ function draw(subselection) {
charts[4] = machineChart ? machineChart : charts[4]
charts[5] = labelChart ? labelChart : charts[5]
charts[6] = branchChart ? branchChart : charts[6]
charts[7] = entityChart ? entityChart : charts[7]
}
function parseTopN() {
@ -447,6 +505,11 @@ function swapCharts(showEntity, hideEntity) {
document.getElementById(`${hideEntity}-container`).parentElement.classList.add('hidden')
}
function extractFile(filePath) {
const delimiter = filePath.includes('\\') ? '\\' : '/' // windows style path?
return filePath.split(delimiter).at(-1)
}
window.addEventListener('load', function () {
topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()

View 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

View File

@ -9,8 +9,6 @@ services:
POSTGRES_DB: "wakapi"
PGPORT: 55432
network_mode: host
volumes:
- wakapi-postgres:/var/lib/postgresql/data
mysql:
image: mysql:8
@ -21,8 +19,6 @@ services:
MYSQL_DATABASE: "wakapi"
MYSQL_ROOT_PASSWORD: example
network_mode: host
volumes:
- wakapi-mysql:/var/lib/mysql
mariadb:
image: mariadb:10
@ -33,10 +29,8 @@ services:
MARIADB_DATABASE: "wakapi"
MARIADB_ROOT_PASSWORD: example
network_mode: host
volumes:
- wakapi-mariadb:/var/lib/mysql
volumes:
wakapi-postgres: {}
wakapi-mysql: {}
wakapi-mariadb: {}
cockroach:
image: cockroachdb/cockroach
entrypoint: '/cockroach/cockroach start-single-node --insecure --sql-addr=:56257'
network_mode: host

View File

@ -46,9 +46,8 @@ trap cleanup EXIT
# Initialise test data
case $1 in
postgres|mysql|mariadb)
postgres|mysql|mariadb|cockroach)
docker compose -f "$script_dir/docker-compose.yml" down
docker volume rm "testing_wakapi-$1"
docker_down=1
docker compose -f "$script_dir/docker-compose.yml" up --wait -d "$1"
@ -61,8 +60,10 @@ case $1 in
db_port=0
if [ "$1" == "postgres" ]; then
db_port=55432
elif [ "$1" == "cockroach" ]; then
db_port=56257
else
db_port=53306
db_port=26257
fi
for _ in $(seq 0 30); do
@ -90,8 +91,8 @@ wait_for_wakapi () {
counter=0
echo "Waiting for Wakapi to come up ..."
until curl --output /dev/null --silent --get --fail http://localhost:3000/api/health; do
if [ "$counter" -ge 5 ]; then
echo "Waited for 5s, but Wakapi failed to come up ..."
if [ "$counter" -ge 30 ]; then
echo "Waited for 30s, but Wakapi failed to come up ..."
exit 1
fi

View File

@ -3,6 +3,7 @@ package utils
import (
"encoding/base64"
"errors"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
"net/http"
"regexp"
@ -42,9 +43,22 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) {
return string(keyBytes), err
}
func CompareBcrypt(wanted, actual, pepper string) bool {
plainPassword := []byte(strings.TrimSpace(actual) + pepper)
err := bcrypt.CompareHashAndPassword([]byte(wanted), plainPassword)
// password hashing
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
}
@ -56,3 +70,18 @@ func HashBcrypt(plain, pepper string) (string, error) {
}
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
}

View File

@ -49,6 +49,18 @@ func TestCommon_ParseUserAgent(t *testing.T) {
"emacs",
nil,
},
{
"Chrome/111.0.0.0 chrome-wakatime/3.0.6",
"",
"chrome",
nil,
},
{
"Chrome/114.0.0.0 linux_x86-64 chrome-wakatime/3.0.17",
"linux",
"chrome",
nil,
},
}
for _, test := range tests {

View File

@ -1,7 +1,10 @@
package utils
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
@ -78,10 +81,28 @@ func ParsePageParamsWithDefault(r *http.Request, page, size int) *PageParams {
}
func ParseUserAgent(ua string) (string, string, error) {
re := regexp.MustCompile(`(?iU)^wakatime\/(?: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)
if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string")
}
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
}

View File

@ -1,14 +1,9 @@
package utils
import (
"fmt"
"strings"
)
func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
}
func SplitMulti(s string, delimiters ...string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
for _, d := range delimiters {

View File

@ -63,7 +63,12 @@
{{ else }}
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-green-700" style="width: 24px; height: 24px" data-icon="ic:round-person"></span>
{{ end }}
<strong class="text-ellipsis truncate">@{{ $item.UserID }}</strong>
<div>
<strong class="text-ellipsis truncate">@{{ $item.UserID }}</strong>
{{ if $item.User.HasActiveSubscription }}
<span class="iconify inline text-gold ml-1" data-icon="jam:crown-f" style="margin-bottom: -2px" title="{{ $item.UserID }} is a supporter of Wakapi!"></span>
{{ end }}
</div>
</div>
<div class="flex-1 mx-1 hidden sm:inline-block truncate leading-6 align-middle">
{{ range $i, $lang := (index $.UserLanguages $item.UserID) }}

View File

@ -32,6 +32,20 @@
</tbody>
</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>
<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>

View File

@ -66,7 +66,12 @@
<div class="shrink-0 menu-item relative" @click="state.showDropdownUser = !state.showDropdownUser" data-trigger-for="showDropdownUser">
<div class="hidden md:flex flex flex-col text-right">
<a class="text-gray-300">{{ .User.ID }}</a>
<div>
{{ if .User.HasActiveSubscription }}
<span class="iconify inline text-gold mr-1" data-icon="jam:crown-f" style="margin-bottom: -2px" title="Thanks for supporting Wakapi!"></span>
{{ end }}
<a class="text-gray-300">{{ .User.ID }}</a>
</div>
{{ if .User.Email }}
<span class="text-xxs text-gray-500">{{ .User.Email }}</span>
{{ end }}

View File

@ -70,7 +70,13 @@
</div>
{{ else }}
<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">
<h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
<div v-cloak v-show="currentInterval">
@ -169,7 +175,7 @@
</div>
</div>
<div style="max-width: 100vw;">
<div style="max-width: 100vw;" class="{{ if .IsProjectDetails }} hidden {{ end }}">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if .IsProjectDetails }} hidden {{ end }}" id="label-container" style="max-height: 300px">
<div class="flex justify-between text-lg" style="margin-bottom: -10px">
<span class="font-semibold whitespace-nowrap">Labels</span>
@ -186,6 +192,19 @@
</div>
</div>
</div>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if not .IsProjectDetails }} hidden {{ end }} col-span-2" id="entity-container" style="max-height: 500px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Files</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="entity-top-picker" data-entity="7" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-entities" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
{{ else }}
@ -238,8 +257,10 @@
wakapiData.labels = {{ .Labels | json }}
{{ if .IsProjectDetails }}
wakapiData.branches = {{ .Branches | json }}
wakapiData.entities = {{ .Entities | json }}
{{ else }}
wakapiData.branches = []
wakapiData.entities = []
{{ end }}
</script>
<script src="assets/js/summary.js"></script>

207
yarn.lock
View File

@ -4,12 +4,12 @@
"@iconify/json-tools@^1.0.10":
version "1.0.10"
resolved "https://registry.yarnpkg.com/@iconify/json-tools/-/json-tools-1.0.10.tgz#d9a7050dbbe8bb29d684d4b3f9446ed2d0bea3cc"
resolved "https://registry.npmjs.org/@iconify/json-tools/-/json-tools-1.0.10.tgz"
integrity sha512-LFelJDOLZ6JHlmlAkgrvmcu4hpNPB91KYcr4f60D/exzU1eNOb4/KCVHIydGHIQFaOacIOD+Xy+B7P1z812cZg==
"@iconify/json@^2.1.136":
version "2.1.136"
resolved "https://registry.yarnpkg.com/@iconify/json/-/json-2.1.136.tgz#f5601e37ef3d1e29532b09ad9643224a7f78692d"
resolved "https://registry.npmjs.org/@iconify/json/-/json-2.1.136.tgz"
integrity sha512-tO5hV+yXn87+OCQqiVzis6i4YQiRX4044ZjubP6GmbeclE6tsypK+by/tXjbm90GTX0jhsOJ6YLzWl3szivywg==
dependencies:
"@iconify/types" "*"
@ -17,25 +17,25 @@
"@iconify/types@*":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57"
resolved "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz"
integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
dependencies:
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
"@nodelib/fs.walk@^1.2.3":
version "1.2.8"
resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz"
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
dependencies:
"@nodelib/fs.scandir" "2.1.5"
@ -43,7 +43,7 @@
acorn-node@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
resolved "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz"
integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
dependencies:
acorn "^7.0.0"
@ -52,29 +52,29 @@ acorn-node@^1.8.2:
acorn-walk@^7.0.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn@^7.0.0:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
ansi-regex@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed"
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz"
integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==
ansi-styles@^3.2.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz"
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
dependencies:
color-convert "^1.9.0"
anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
dependencies:
normalize-path "^3.0.0"
@ -82,34 +82,34 @@ anymatch@~3.1.2:
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz"
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
dependencies:
fill-range "^7.0.1"
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
chokidar-cli@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/chokidar-cli/-/chokidar-cli-3.0.0.tgz#29283666063b9e167559d30f247ff8fc48794eb7"
resolved "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz"
integrity sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==
dependencies:
chokidar "^3.5.2"
@ -119,7 +119,7 @@ chokidar-cli@^3.0.0:
chokidar@^3.5.2, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
@ -134,7 +134,7 @@ chokidar@^3.5.2, chokidar@^3.5.3:
cliui@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
resolved "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz"
integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
dependencies:
string-width "^3.1.0"
@ -143,39 +143,39 @@ cliui@^5.0.0:
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
dependencies:
color-name "1.1.3"
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
defined@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf"
resolved "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz"
integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==
detective@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034"
resolved "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz"
integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==
dependencies:
acorn-node "^1.8.2"
@ -184,22 +184,22 @@ detective@^5.2.1:
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
dlv@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz"
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
emoji-regex@^7.0.1:
version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz"
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
fast-glob@^3.2.12:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz"
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
@ -210,105 +210,100 @@ fast-glob@^3.2.12:
fastq@^1.6.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
resolved "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz"
integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
dependencies:
reusify "^1.0.4"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
dependencies:
to-regex-range "^5.0.1"
find-up@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
resolved "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz"
integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
dependencies:
locate-path "^3.0.0"
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.9.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz"
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
dependencies:
has "^1.0.3"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-fullwidth-code-point@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz"
integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
lilconfig@^2.0.5, lilconfig@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz"
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz"
integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
dependencies:
p-locate "^3.0.0"
@ -316,22 +311,22 @@ locate-path@^3.0.0:
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz"
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
dependencies:
braces "^3.0.2"
@ -339,76 +334,76 @@ micromatch@^4.0.4, micromatch@^4.0.5:
minimist@^1.2.6:
version "1.2.7"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
nanoid@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
p-limit@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
dependencies:
p-try "^2.0.0"
p-locate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
resolved "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz"
integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
dependencies:
p-limit "^2.0.0"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
path-exists@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
pathe@^0.3.0:
version "0.3.9"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-0.3.9.tgz#4baff768f37f03e3d9341502865fb93116f65191"
resolved "https://registry.npmjs.org/pathe/-/pathe-0.3.9.tgz"
integrity sha512-6Y6s0vT112P3jD8dGfuS6r+lpa0qqNrLyHPOwvXMnyNTQaYiwgau2DP3aNDsR13xqtGj7rrPo+jFUATpU6/s+g==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
postcss-import@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"
resolved "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz"
integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==
dependencies:
postcss-value-parser "^4.0.0"
@ -417,14 +412,14 @@ postcss-import@^14.1.0:
postcss-js@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00"
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz"
integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==
dependencies:
camelcase-css "^2.0.1"
postcss-load-config@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz"
integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
dependencies:
lilconfig "^2.0.5"
@ -432,14 +427,14 @@ postcss-load-config@^3.1.4:
postcss-nested@6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.0.tgz#1572f1984736578f360cffc7eb7dca69e30d1735"
resolved "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz"
integrity sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==
dependencies:
postcss-selector-parser "^6.0.10"
postcss-selector-parser@^6.0.10:
version "6.0.10"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz"
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
dependencies:
cssesc "^3.0.0"
@ -447,12 +442,12 @@ postcss-selector-parser@^6.0.10:
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.18:
postcss@^8.0.0, postcss@^8.2.14, postcss@^8.3.3, postcss@^8.4.18, postcss@>=8.0.9:
version "8.4.19"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz"
integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
dependencies:
nanoid "^3.3.4"
@ -461,41 +456,41 @@ postcss@^8.4.18:
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
quick-lru@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
resolved "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz"
integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
dependencies:
pify "^2.3.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
resolve@^1.1.7, resolve@^1.22.1:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
@ -504,29 +499,29 @@ resolve@^1.1.7, resolve@^1.22.1:
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
dependencies:
queue-microtask "^1.2.2"
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
string-width@^3.0.0, string-width@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz"
integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
dependencies:
emoji-regex "^7.0.1"
@ -535,19 +530,19 @@ string-width@^3.0.0, string-width@^3.1.0:
strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
dependencies:
ansi-regex "^4.1.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tailwindcss@^3.1.8:
version "3.2.4"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.2.4.tgz#afe3477e7a19f3ceafb48e4b083e292ce0dc0250"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz"
integrity sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==
dependencies:
arg "^5.0.2"
@ -576,24 +571,24 @@ tailwindcss@^3.1.8:
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz"
integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
wrap-ansi@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz"
integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
dependencies:
ansi-styles "^3.2.0"
@ -602,22 +597,22 @@ wrap-ansi@^5.1.0:
xtend@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
yaml@^1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yargs-parser@^13.1.2:
version "13.1.2"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz"
integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
dependencies:
camelcase "^5.0.0"
@ -625,7 +620,7 @@ yargs-parser@^13.1.2:
yargs@^13.3.0:
version "13.3.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz"
integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
dependencies:
cliui "^5.0.0"