1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

317 Commits

Author SHA1 Message Date
75cc071222 fix: sign up api test 2023-01-02 18:28:41 +01:00
e8310cfa69 fix: ci tests 2023-01-02 18:18:58 +01:00
746608c062 refactor: flash messages framework (resolve #446) 2023-01-02 18:05:28 +01:00
a1444bca8c chore: validate email addresses with dns 2023-01-02 15:31:28 +01:00
ef5b49ebd8 chore: clear user cache upon logout 2023-01-02 14:53:21 +01:00
fb5b2f52c7 fix: make wakatime relay middleware accept single heartbeat format (resolve #445) 2023-01-02 11:33:47 +01:00
a49abfe0de docs: update readme [ci-skip] 2023-01-02 11:16:39 +01:00
cf5a515952 fix: sentry middleware interface conversion 2023-01-02 10:55:57 +01:00
c1f1b05fa8 chore: add data privacy notice 2023-01-01 22:08:10 +01:00
9166c98df7 chore: script to send mass mail via mailwhale 2022-12-31 16:36:33 +01:00
bfd2832846 fix: minor fixes 2022-12-31 16:03:44 +01:00
814e74a41e fix: don't require db param for api test script 2022-12-30 14:07:43 +01:00
f755275309 fix: tests 2022-12-30 13:41:27 +01:00
731598fa38 fix: critical bug with data retention / cleanup 2022-12-30 13:32:05 +01:00
8e521741f8 refactor(subscriptions): store stripe customer id with user 2022-12-30 13:14:24 +01:00
3aac5e9062 fix: tests 2022-12-29 17:26:15 +01:00
50c54685ec feat: subscription expiry notification mails 2022-12-29 17:12:34 +01:00
dc0bcbe65d chore: cap data import according to max data retention time 2022-12-29 12:33:21 +01:00
bafbc34706 refactor: minor code refactorings 2022-12-29 11:55:09 +01:00
8ca1404f8b fix: dont clean data for subscribed users 2022-12-29 11:17:24 +01:00
195755581b chore: require email address for subscriptions 2022-12-29 11:17:24 +01:00
8a94fef06b feat: implement computation of users first heartbeats data time 2022-12-29 11:17:24 +01:00
ebcf87ea93 feat(wip): polish settings ui for subscriptions 2022-12-29 11:17:24 +01:00
0e83ab02fa feat(wip): implement stripe webhooks 2022-12-29 11:17:24 +01:00
05ea05cdf4 feat(wip): implement stripe webhooks 2022-12-29 11:17:24 +01:00
f39ecc46bd feat(wip): stripe integration for subscriptions 2022-12-29 11:17:24 +01:00
333c1b5dd0 feat(subscriptions): introduce config options and user attribute to support subscriptions 2022-12-29 11:17:24 +01:00
52d45d4644 Merge pull request #448 from muety/test-migrations
Basic migration tests for mysql/mariadb/postgres
2022-12-27 15:35:45 +01:00
f46f24f0be test: ref the config conditional 2022-12-27 20:58:17 +11:00
9cce0ac2e1 test: revert to docker compose 2022-12-27 20:42:55 +11:00
497046d0a4 test: address pr comments 2022-12-27 20:41:18 +11:00
03af194385 test: more efficient database ready detection 2022-12-27 15:35:11 +11:00
ad704cef5c test: migration testing for mysql/mariadb/postgres 2022-12-27 15:19:30 +11:00
cd5c511474 fix: enable experimental column altering for cockroachdb (see #442) 2022-12-16 12:33:01 +01:00
8a26e24081 chore: minor changes 2022-12-06 21:01:21 +01:00
db6dde32cd Merge branch 'upgrade-testing' 2022-12-06 21:00:04 +01:00
394215e53b Merge branch 'allow-mysql-socket' 2022-12-06 20:50:08 +01:00
27586f3a54 Merge branch 'remove-config-file-requirement' 2022-12-06 20:46:35 +01:00
9e9e9fbef9 check still for empty string 2022-12-06 12:19:18 +00:00
bc9132f84d fix: remove config file requirement, fixes #435 2022-12-06 12:17:28 +00:00
e7b6a87153 feat: allow using mysql socket, fixes #433 2022-12-06 12:10:18 +00:00
0a2cba647c fix: disabling tcp webserver sockets, fixes #434 2022-12-06 11:55:33 +00:00
f5395e36ad ci: SQLite upgrade testing comments 2022-12-06 18:39:09 +11:00
9f38246fe2 Merge pull request #425 from Daste745/persistent-summary-interval
Persistent summary time interval
2022-12-05 19:27:07 +01:00
5242df2b7d ci: upgrade testing for SQLite 2022-12-04 18:06:48 +11:00
10648d66ad Remove redundant logic in client-side javascript 2022-12-03 12:51:33 +01:00
97fab3e109 Redirect to correct summary page if interval cookie is set
This adds an additional 302 redirect when the user doesn't specify an
`interval` as a query param, but has the `wakapi_summary_interval`
cookie set.
2022-12-03 12:47:05 +01:00
0f3b41c2dd fix(ci): adapt docker and gha build to use go 1.19 2022-12-03 00:32:30 +01:00
5ae7527b7b feat: implement data retention mechanism 2022-12-01 20:26:03 +01:00
2db065d47a Merge branch 'muety/427-job-processing' 2022-12-01 15:55:40 +01:00
0e5c5a56d2 chore: dependency upgrades 2022-12-01 15:31:19 +01:00
a4b89d3a69 fix: concurrency bugs with summary aggregation and user counting 2022-12-01 14:13:52 +01:00
aab9e98ebd fix: error handling for user counting
fix: make user counting thread-safe
2022-12-01 13:46:06 +01:00
d4945c982f fix: tests 2022-12-01 11:11:45 +01:00
964405f349 chore: refine report scheduling 2022-12-01 10:57:51 +01:00
21f6809f05 refactor: split utility functions into utils and helpers 2022-12-01 10:57:07 +01:00
c5fda02900 docs: update default cron expressions 2022-12-01 10:10:39 +01:00
10e432c185 Merge pull request #431 from rummik/patch-1
Increase avatar entropy
2022-11-29 23:07:28 +01:00
f121112d09 Increase avatar entropy 2022-11-26 16:26:03 -05:00
c13fc96a16 refactor: use job queue for data imports 2022-11-20 11:09:51 +01:00
61f13fce20 fix: prometheus metrics types 2022-11-20 10:59:06 +01:00
99e50b1062 chore: logging 2022-11-20 10:12:34 +01:00
4ce75c2acb chore: clean up dependencies 2022-11-20 10:11:23 +01:00
fcca881cfc refactor: move more background jobs to using job queue 2022-11-20 10:10:24 +01:00
e2ef54152d refactor(wip): introduce job processing system
refactor: adapt report generation scheduling
2022-11-19 22:21:51 +01:00
ebe1836ac6 Write a Set-Cookie header with the last used summary interval 2022-11-19 09:52:44 +01:00
b1a12a5759 Merge pull request #430 from xiecang/bugfix/send_smtp_mail
fix: ignore error "starttls command has been already sent"
2022-11-17 08:31:24 +01:00
ae407fffca fix: ignore error "starttls command has been already sent" 2022-11-16 12:29:20 +08:00
94e0d06e5d fix: user agents and machine names in wakatime import 2022-11-15 23:53:30 +01:00
088bd17803 chore: update iconify 2022-11-13 20:20:41 +01:00
2976203ecc fix: missing icons 2022-11-13 20:11:53 +01:00
e75bd94531 fix: include cumulative total key in wakatime summary compat endpoint (resolve #426) 2022-11-13 19:52:53 +01:00
4cc8c21f67 fix: importing data from wakapi instance (resolve #428) 2022-11-13 19:27:44 +01:00
f182b804bb chore: add additional language icons
fix: support ipynb, cjs, tsx file endings
2022-11-11 16:13:41 +01:00
e89ce076fd Read the persisted summary interval from a cookie
This cookie will be read only if the `interval` or `from` query params
are not set. If the cookie is also unset, it will still default to
the "today" interval.

TODO: The cookie still needs to be set on the client
with a `Set-Cookie` response header.
2022-11-05 19:30:42 +01:00
ba81c07345 Display persistent summary interval into the front-end time picker 2022-11-05 19:23:39 +01:00
9586dbf781 fix: make intervals robust to daylight saving time shift 2022-10-31 23:24:54 +01:00
c8ea1a503f Merge pull request #424 from f0x52/postgres-dsn
Add postgres DSN config option
2022-10-31 19:22:03 +01:00
f0x
ebbc21f0b1 add postgres DSN config option 2022-10-31 18:07:16 +01:00
6e5bc38e5e fix: index migration for sqlite 2022-10-28 10:32:47 +02:00
9424c49760 fix: composite index on heartbeats table 2022-10-28 09:54:11 +02:00
efd6ba36e3 fix: errors during leaderboard generation 2022-10-20 08:33:12 +02:00
b1d7f87095 chore: add maximum default leaderboard length 2022-10-19 18:28:30 +02:00
ffbcfc7467 fix: cache key 2022-10-19 17:23:40 +02:00
41f6db8f34 feat(wip): leaderboard pagination (resolve #417) [ci-skip] 2022-10-16 19:38:43 +02:00
8a21be4306 fix: ignore rank column in migrations 2022-10-16 18:59:00 +02:00
31ca4a1e02 chore: logging 2022-10-16 17:42:32 +02:00
7cab2b0be7 chore: add clarification on relaying to other wakapi instance (resolve #420) [skip-ci] 2022-10-15 11:08:44 +02:00
777997c883 fix: swagger ui (resolve #421) 2022-10-14 12:00:56 +02:00
060a33263a chore: update dependencies 2022-10-09 10:16:27 +02:00
33d259592c chore: improve summary id fixing migration (see #416) 2022-10-09 10:16:18 +02:00
fbae5f8757 Tailwind 3 & Footer alignment (#419)
* ui: footer alignment
* chore: upgrade tailwind to v3
* fix: tailwind 3 class renames
* ui(fix): alias green to emerald for tailwind 3
2022-10-09 10:53:52 +11:00
bc99dc990a fix: case sensitivity with leaderboard languages (resolve #418) 2022-10-07 08:58:51 +02:00
1e9d3f9e80 Merge branch '182-leaderboards' 2022-10-06 20:43:55 +02:00
2ce720c20f fix: leaderboard responsiveness 2022-10-06 20:42:05 +02:00
ef87445e43 chore: display leaderboard update time 2022-10-06 15:30:32 +02:00
dec5849661 fix: replace mysql backticks 2022-10-06 15:23:59 +02:00
5609c0ada3 chore: empty leaderboard placeholder 2022-10-06 15:17:37 +02:00
1632cea949 fix: clear leaderboard after user opted out 2022-10-06 14:52:06 +02:00
23759d526a feat: settings option to opt in to leaderboards 2022-10-06 14:47:22 +02:00
82a565738f test: adapt mocks 2022-10-06 14:34:46 +02:00
1989a69926 feat: show users top languages
feat: language icons
2022-10-05 23:36:57 +02:00
7a07c9d4fc feat: top languages by user 2022-10-05 21:52:10 +02:00
a27fe04919 feat: leaderboard aggregation functionality
feat: leaderboard ui design
2022-10-03 23:53:47 +02:00
1d7ff4bc2a refactor: use query param for leaderboard controls 2022-10-03 20:38:19 +02:00
b3fa032bde feat(wip): leaderboard ui 2022-10-03 10:53:27 +02:00
94377a8dea fix: summary items id type (see #416) 2022-10-02 11:31:32 +02:00
dba4da8641 chore: caching for leaderboard 2022-10-02 10:31:01 +02:00
4a22a19cb0 chore: generate leaderboard when enabled in user settings 2022-10-02 10:13:39 +02:00
13a3d9f03a feat: leaderboard generation and querying 2022-10-02 00:01:39 +02:00
beffe71ea6 feat: add leaderboard data model 2022-09-30 17:19:32 +02:00
0ab7faf7b6 Merge remote-tracking branch 'origin/master' 2022-09-30 15:28:15 +02:00
a2ac049578 fix: heartbeat entity character length (resolve #415) 2022-09-30 15:28:11 +02:00
b287c4ca36 Merge pull request #414 from muety/cgo
Remove gcc dependency in release
2022-09-30 14:49:58 +02:00
018cc50fb8 chore(release): reinclude ci conditions 2022-09-30 22:44:25 +10:00
1d4156bdfe chore(build): remove gcc dependency 2022-09-30 22:35:03 +10:00
147c79db60 fix: label on settings page 2022-09-30 14:12:07 +02:00
f204ca888d fix: swagger docs path [skip ci] 2022-09-30 11:02:32 +02:00
e28070b288 fix(ci): build env vars on windows 2022-09-30 00:02:30 +02:00
4d217a83c1 chore: update readme and dockerfile 2022-09-30 00:02:04 +02:00
9e0581b311 chore: update dependencies after sqlite pure go migration 2022-09-29 23:41:57 +02:00
ffb529f4cf Merge branch 'sqlite-go-replacement'
# Conflicts:
#	go.mod
#	go.sum
2022-09-29 23:37:40 +02:00
c9aac2a273 fix: swagger docs base path (resolve #412) 2022-09-29 23:33:49 +02:00
dd8658e33e refactor: replace sqlite with pure go implementation 2022-09-08 21:13:13 +02:00
e399af1f1f fix(build): enable cgo, downgrade release to ubuntu-18.04, add -w -s (#404)
* fix(build): releases with cgo
* build: downgrade to ubuntu-18.04
* build: add -w -s flags
2022-09-08 17:51:26 +10:00
4c1f4ed39b docs: add eget option [ci skip] 2022-09-07 10:50:26 +02:00
7e5c00d0ae chore: update quick run script (resolve #400) 2022-09-06 22:28:49 +02:00
cec2a84e2d Merge pull request #402 from muety/release-arch
Add more architectures to release binaries (resolve #401)
2022-09-06 22:16:35 +02:00
ffb0b84d78 build: re-enable release steps 2022-09-06 20:51:05 +10:00
8a7333b899 build: test release architectures 2022-09-06 20:23:47 +10:00
dd3b9c9b9c Merge pull request #398 from marvinscham/patch-1
Add last_6_months interval identifier
2022-08-21 10:24:03 +02:00
d2b62e21c5 Fix indentation 2022-08-20 07:07:44 +02:00
9505773165 Add last_6_months interval identifier 2022-08-19 17:14:00 +02:00
4bfc8a9e9f Merge pull request #397 from muety/docker-simplify
Simplify Dockerfile
2022-08-19 13:59:33 +02:00
df5fe6e623 fix: initialise data/ dir in Docker 2022-08-19 21:55:58 +10:00
037ad7b9b1 fix: check database open 2022-08-19 21:48:03 +10:00
ec10cc922c perf: strip binaries 2022-08-19 19:53:02 +10:00
acb76e1ab1 ref: simplify Dockerfile stages 2022-08-19 19:37:05 +10:00
252a304ba8 Merge pull request #396 from HellsCrimson/master
add last_year and all_time interval identifier like in wakatime docs
2022-08-17 18:50:07 +10:00
c863cf6dc5 add last_year and all_time interval identifier like in wakatime docs 2022-08-16 23:28:48 +02:00
373d969734 feat: introduce newsbox for front page (resolve #393) 2022-08-13 10:28:36 +02:00
99a3e8f5da Merge pull request #394 from muety/nil-fix
fix: 500 on POST /api/heartbeats
2022-08-12 14:37:33 +02:00
4302cfcbd6 fix: 500 on POST /api/heartbeats 2022-08-12 17:25:43 +10:00
7cca0055fe Merge pull request #388 from muety/automated-version
feat: automatic version.txt updates
2022-07-14 14:31:06 +02:00
20993a1182 feat: automatic version.txt updates
Resolves #387
2022-07-09 13:58:16 +10:00
d5a85639b1 fix: broken summary aggregation (resolve #385) 2022-07-03 20:39:16 +02:00
b6a8185957 Merge branch 'Enthys_master' 2022-07-02 00:30:15 +02:00
c5da5e4622 fix: bug in same day comparison 2022-07-02 00:28:56 +02:00
a0f69a371f fix: api tests time zone bug (resolve #355) 2022-07-01 23:31:51 +02:00
2f0cb112dd test: user api retrieving user information 2022-06-30 10:36:13 +03:00
2173954b84 docs: instructions for go install [ci skip] 2022-06-29 23:39:35 +02:00
991e64b961 fix: returning current user instead of requested one 2022-06-29 10:46:46 +03:00
affff0c386 fix: admin users can't fetch other user data 2022-06-28 13:01:35 +03:00
099cdaddbc chore: add example systemd service unit file [ci skip] 2022-06-22 00:18:50 +02:00
409405117e Merge pull request #377 from NChechulin/master
elaborate on cloud server API URL [ci skip]
2022-05-29 15:06:42 +02:00
af89ecc9c1 update client configuration URLs for the new version 2022-05-29 15:43:59 +03:00
be354fa790 elaborate on cloud server API URL
Personally, for me, it was slightly unclear which URL has to be pasted.
2022-05-28 11:05:12 +03:00
a1c4c5da6b Merge pull request #376 from Daste745/avatar_url_template_env
Add an env configuration option for AvatarURLTemplate
2022-05-22 23:06:25 +02:00
33509beaf7 Enable env configuration for AvatarURLTemplate
Added an `env:"WAKAPI_AVATAR_URL_TEMPLATE"` option for the Avatar URL Template configuration setting.

I wanted to configure this on my instance, but the only way now is through the yaml config file.
2022-05-22 02:04:25 +02:00
ab6ccbdfbe README: Mention the WAKAPI_AVATAR_URL_TEMPLATE configuration variable 2022-05-22 02:03:04 +02:00
77e6cd9faa Merge pull request #375 from daief/master
Github Actions add a release for macOS
2022-05-20 09:40:26 +02:00
34bc38cecf chore: add descriptive names for workflows 2022-05-20 09:34:54 +02:00
69d3e0494b chore: add a new endline 2022-05-20 12:17:35 +08:00
a3136ebb13 chore: fix upload relese 2022-05-20 12:01:34 +08:00
4a4d0dad4b chore: merge mapi, test, build to ci.yml & keep release in release.yml 2022-05-20 11:46:29 +08:00
3b87511f48 chore: try use include 2022-05-19 14:12:00 +08:00
f5fba04097 chore: try keep same name 2022-05-19 13:50:43 +08:00
ad566993ad chore: fix release.yml 2022-05-19 13:10:23 +08:00
5f1e498454 chore: fix release.yml 2022-05-19 13:09:50 +08:00
2e0f79df3b chore: try merge release action 2022-05-19 13:06:11 +08:00
4a4e19fcbd fix: error when querying with label filter (resolve #374) 2022-05-16 23:23:18 +02:00
45d4ba89f5 fix: update swaggo dependency 2022-05-13 16:19:35 +02:00
29b3e619ca chore: update mapi org 2022-05-13 16:13:55 +02:00
1a85ebc0f7 Merge branch 'mapi' of https://github.com/mayhemheroes/wakapi into mayhemheroes-mapi 2022-05-13 16:12:45 +02:00
4bd58789f4 fix: server error when passing empty heartbeats slice
fix: do not allow to set id for diagnostics inputs
chore: remove authentication for diagnostics endpoint from swagger docs
2022-05-13 16:12:18 +02:00
09d1124794 fix: work around invalid all_time_since_today data schema to fix failing import (resolve #370) 2022-05-12 00:59:42 +02:00
41584bdd82 add Mayhem for API as a github workflow 2022-05-11 10:27:37 -07:00
1b7baf6fc9 fix: explicitly set default value for unique columns (fix #367) 2022-05-07 23:17:15 +02:00
a76db3e95f fix: clear summary cache on new project label (resolve #369) 2022-05-07 09:37:10 +02:00
74a5226e73 Merge pull request #364 from bdeshi/fork
chore: remove hard-coded volume from Dockerfile
2022-04-24 20:28:22 +02:00
d245b1e5d0 chore: remove hard-coded volume from Dockerfile 2022-04-24 17:33:42 +06:00
d5eff46651 fix: summary page layout 2022-04-24 09:33:04 +02:00
30a65b4de9 chore: minor changes to vibrant colors toggling 2022-04-24 09:26:04 +02:00
9048a8eb7a Merge branch 'master' into fork 2022-04-24 03:56:18 +06:00
1f19c5e93c feat: make vibrantColors a localStorage setting 2022-04-24 03:39:08 +06:00
4b0a3cf0d6 fix: index error during summary generation (resolve #361)
chore(sentry): include stacktrace with panics
2022-04-20 21:36:39 +02:00
d778612242 fix: remove authentication requirement from diagnostics endpoint 2022-04-18 21:32:30 +02:00
ff7d595a86 chore: do not run expensive jobs initially but only scheduled 2022-04-18 21:16:27 +02:00
9d7688957f chore: explicit width and height for front page images [ci skip] 2022-04-18 19:28:30 +02:00
179042f81b refactor: use cross join instead of subquery for populating summary items (see #350) 2022-04-18 17:15:09 +02:00
e6441f124c chore: adapt tests 2022-04-18 16:14:58 +02:00
15c391d1d4 chore: downgrade postgres driver 2022-04-18 16:07:52 +02:00
91c765202c fix: prevent large difference between aggregated and recomputed summaries (resolve #354) 2022-04-18 16:06:32 +02:00
5276f68918 fix: double counting when using precise missing intervals 2022-04-18 15:18:01 +02:00
e774039831 chore: fall back to today badge on project page 2022-04-18 11:49:06 +02:00
40067d252e fix: non-ascii project badges (resolve #357)
chore: locally generated badges (resolve #348)
2022-04-18 11:39:26 +02:00
1a47243f70 chore: make summary items subquery unique by summary id 2022-04-13 00:05:18 +02:00
a1f6c2884b Merge remote-tracking branch 'origin/master' 2022-04-03 18:03:25 +02:00
977420c68d fix: failing heartbeats index auto-migration on sqlite (resolve #346) 2022-04-03 18:03:09 +02:00
3ae66a3898 docs: add public url parameter to readme (resolve #349) [ci skip] 2022-04-02 09:26:14 +02:00
5c5c462035 docs: update readme
- adds `app.vibrant_color` config description
- adds missing `server.public_url` config description
- removes trailing spaces
- makes letter cases consistent in headings
- misc. typo fix and adjustments
2022-03-28 03:12:54 +06:00
f6cc489425 feat: allow toggling vibrant color for all charts
- supports new config key `app.vibrant_color` or env `WAKAPI_VIBRANT_COLOR`
- updates and extends `data/colors.json` with editor and os colors
- fixes #343
2022-03-28 01:56:13 +06:00
5aae18e241 refactor: replace most custom date util functions by lancet ones
refactor: add precision mode to missing intervals function
2022-03-25 12:51:40 +01:00
8a731a252a chore: ditch lo module again 2022-03-25 11:34:16 +01:00
8fc0d78f64 refactor: include generics based utility lib and refactor some parts accordingly [ci-skip] 2022-03-20 16:40:14 +01:00
a675417ab9 chore: upgrade dependencies
chore: update dockerfile to build with go 1.18
2022-03-20 16:40:14 +01:00
408d9086e7 fix(ci): use go 1.18 in ci build 2022-03-20 16:39:38 +01:00
8f933d8648 chore: upgrade dependencies
chore: update dockerfile to build with go 1.18
2022-03-20 16:29:13 +01:00
bbc85de34b chore: metrics performance improvements 2022-03-19 10:30:32 +01:00
ec70d024fa fix: remove user property of diagnostics as sent without auth 2022-03-19 09:27:13 +01:00
eae45baf38 chore: allow heartbeats from one hour into the future to compensate for clock inaccuracies (see #342) 2022-03-19 09:02:15 +01:00
4cea50b5c8 chore: add user project index on heartbeats table 2022-03-19 08:57:33 +01:00
e4814431e0 feat: add database size metric 2022-03-18 18:20:13 +01:00
91b4cb2c13 fix: explicit milliseconds precision of timestamp columns 2022-03-18 13:48:28 +01:00
a3acdc7041 fix: duration aggregation for heartbeats with identical timestamps (resolve #340) 2022-03-18 12:29:43 +01:00
e7e5254673 feat: ability to clear all user data (resolve #339) 2022-03-17 11:55:13 +01:00
8e558d8dee chore: introduce heartbeat max age 2022-03-17 11:35:20 +01:00
b763c4acc6 fix(perf): speed up summary retrieval of all time interval (resolve #336) 2022-03-17 11:08:40 +01:00
d1bd7b96b8 fix: hotfix for #337 (resolve #33) 2022-03-16 18:29:19 +01:00
8c65da9031 chore: remove entity index again
chore: add migration note
2022-03-13 09:42:51 +01:00
647bf1781d chore: apply filters in database query (see #335) 2022-03-13 08:49:03 +01:00
85515d6cb5 Merge branch 'patch-1' 2022-03-06 12:00:29 +01:00
1258ec0438 docs: add smtp and mailwhale config details to readme 2022-03-06 12:00:19 +01:00
965d8e22b3 chore: fix typo in error message 2022-03-06 11:52:03 +01:00
ed6e51b4df add error when no authentication is configured 2022-03-04 17:03:04 +01:00
af879f8d57 fix: example for mail sender 2022-03-04 16:58:52 +01:00
f15efcd6f2 chore: bump version 2022-03-02 17:58:53 +01:00
22e91ad362 Merge pull request #327 from daief/patch-2
fix: wrong key
2022-03-02 17:56:53 +01:00
932ba111cc fix: wrong key 2022-03-02 22:29:09 +08:00
23d00d574b chore: easier setup instructions (resolve #325) 2022-03-02 08:55:58 +01:00
d4b15e7959 fix: href 2022-03-02 08:51:27 +01:00
42808fa38a fix: href to a 404 when service on a subpath
click project detail will redirect to a not exist page, when the service runs with a base path.

For example, the base path is `wakatime`,  and the dashboard uri will be `/wakatime/summary`. When click project detail, page will be redirect to `/wakatime/wakatime/summary?project=demo` but the correct detail page is `/wakatime/summary?project=demo`.

And i think `pushing a history stack` is better than `replace`, so that can back to dashboard by backwards.
2022-03-02 11:35:40 +08:00
52269c780f add missing expose to Dockerfile 2022-03-01 18:19:47 +11:00
302eb33b1b fix: branches chart (resolve #322) 2022-02-22 08:19:51 +01:00
784adec3c1 docs: update readme 2022-02-21 19:49:43 +01:00
d2cdd35fff Merge pull request #320 from muety/gitattributes
ref: add gitattributes file, remove unnecessary unicode characters
2022-02-20 21:27:02 +01:00
33d65fb33a ref: add .gitattributes file for line normalisation 2022-02-18 19:53:04 +11:00
6d762f5fd6 ref: remove unnecessary unicode characters 2022-02-18 19:52:55 +11:00
222024dabb chore: cache avatars in memory 2022-02-17 10:34:33 +01:00
660a09475e chore: include avatar rendering into wakapi itself 2022-02-17 09:53:37 +01:00
5cc932177f chore: update precompressed assets 2022-02-16 08:57:00 +01:00
ac9d96c563 Remove "Create Account" button when AllowSignup is set to false (#319)
Merge pull request #319
2022-02-16 08:56:27 +01:00
3758eecc96 docs: extend postman collection by get heartbeats compat endpoint
(cherry picked from commit e6c6d0eb0d8b4ac6acf68c17002e069b6fe66626)
2022-02-13 11:04:34 +01:00
e21788b8b5 chore: minor fixes 2022-02-13 11:03:10 +01:00
e7f3432113 feat: GET /heartbeat endpoint (resolves #241) 2022-02-13 11:03:10 +01:00
7159df30c2 feat: allow to configure custom api url for relay and import (resolve #105) 2022-01-21 12:35:05 +01:00
fce3a3ea20 docs: update docker run command to ghcr [ci skip] 2022-01-19 15:21:26 +01:00
bd2a8c5a7f fix: make cookie path respect server.base_path (resolve #310) 2022-01-17 08:25:50 +01:00
632a3d4a91 docs: update readme [ci skip] 2022-01-16 06:39:45 +01:00
8a344ce4a2 Merge pull request #308 from muety/docker-version
ci: major and major.minor tags for Docker publish
2022-01-16 06:32:14 +01:00
cbbb592143 ci: major and major.minor tags for Docker publish
Resolves #307
2022-01-16 14:53:55 +11:00
67f0d19a65 fix: allow to create labels for aliased projects (resolve #231) 2022-01-13 17:10:24 +01:00
03b104a390 chore: fix user agent parsing for unset wakatime version (resolve #306) [ci skip] 2022-01-12 21:23:36 +01:00
4a3fe48cce chore: indentation [ci skip] 2022-01-08 13:35:11 +01:00
1033343702 Merge remote-tracking branch 'origin/master' 2022-01-08 13:33:55 +01:00
31c462c275 chore: add caddyfile [ci skip] 2022-01-08 13:33:24 +01:00
0a7ebc4dc7 fix: allow to display more than ten entities and nine legend items (fix #303) 2022-01-07 16:10:27 +01:00
91768cf927 chore: version 2022-01-07 11:20:59 +01:00
e967a74e36 fix: allow project names with dots for badges (resolve #301) 2022-01-06 14:45:26 +01:00
8e6719f0b7 fix: missing project labels form [ci skip] 2022-01-03 17:48:59 +01:00
bcbd6236df fix: adapt badge filter entity regex to cover labels [ci skip] 2022-01-03 17:30:40 +01:00
d1cbabf662 ci: remove armv6 again 2022-01-03 17:06:35 +01:00
ad4d251154 feat: build linux/arm/v7,linux/arm/v6 2022-01-03 12:06:36 +11:00
2bb3b886c2 chore: update Docker to use golang:1.17-alpine 2022-01-03 12:05:09 +11:00
4f183ed637 fix: exec to replace environment.sh 2022-01-03 12:02:21 +11:00
b66f9b5cf5 perf: separate download stage in Dockerfile 2022-01-03 12:01:33 +11:00
bf7f93fcd4 perf: use --no-cache in Dockerfile 2022-01-03 11:16:17 +11:00
36c96dafca chore: update screenshot [ci skip] 2022-01-02 21:57:24 +01:00
8f87c4e283 fix: omit null branches property of wakatime summary 2022-01-02 21:29:16 +01:00
247aef5ef3 chore: fix api url in setup instructions
chore: bump version
2022-01-02 21:02:20 +01:00
c0dada7e7a chore: add precompressed app css 2022-01-02 20:28:55 +01:00
8b8c5675af fix: wrongly displayed timezone offset 2022-01-02 20:25:07 +01:00
f69dce39d8 fix: settings forms 2022-01-02 20:13:38 +01:00
c2d3426bcd feat: project details page with branch statistics (resolve #242) 2022-01-02 20:04:29 +01:00
bb0d0569fd chore: make time picker a standalone petite-vue component 2022-01-02 18:30:22 +01:00
c4c62f31e4 chore: move time picker to separate component 2022-01-02 14:38:04 +01:00
2bc53e6f11 feat: basic implementation of branch statistics 2022-01-02 13:39:20 +01:00
fd6c36832e fix: critical infinite loop at the date of switch to daylight saving time 2022-01-02 13:17:30 +01:00
6f9015d3d8 fix: neutered file system
docs: add filter params to api docs
2022-01-02 12:03:20 +01:00
cbcdd938eb chore: add api tests for filtering 2022-01-02 12:03:20 +01:00
bf82935849 chore: add more filtering unit tests 2022-01-02 12:03:20 +01:00
fe3ba79d54 chore: filter model tests 2022-01-02 12:03:20 +01:00
d80c1a4c4b feat: ability to filter by project labels 2022-01-02 12:03:20 +01:00
a279548c89 feat: comprehensive summary-level filtering (resolve #262) 2022-01-02 12:03:19 +01:00
8a3e6f0179 chore: more verbose logging with regard to reports 2022-01-02 12:02:17 +01:00
a72af7d57e chore: add title attribute for kpi texts 2022-01-02 12:02:16 +01:00
ec236909c9 chore: add migration for heartbeats count 2022-01-02 12:02:12 +01:00
92f6d44606 feat: total heartbeats per summary (resolve #283) 2022-01-02 12:02:12 +01:00
e14f8c1463 chore: minor performance improvements 2022-01-02 12:02:12 +01:00
80252ff701 docs: add readme link 2022-01-02 12:02:12 +01:00
374e578a7c feat: brotli precompressed assets (resolve #284) 2022-01-02 12:02:12 +01:00
aebfdc535d refactor: redefine tailwind build
chore: encapsulate commonly used css classes
chore: add npm scripts for building tailwind and iconify assets
2022-01-02 12:02:12 +01:00
c217f8e664 chore: move vue components to separate js files 2022-01-02 12:02:12 +01:00
ba54e7bb96 fix: responsiveness 2022-01-02 12:02:12 +01:00
1e505b91f3 chore: imprint and password reset pages 2022-01-02 12:02:12 +01:00
26825b07de refactor: migrate most non-chart-related js logic to petite-vue (resolve #282) 2022-01-02 12:02:12 +01:00
6a5f08dc95 fix: popups and dropdowns 2022-01-02 12:02:12 +01:00
62e3decf0f fix: url template vars 2022-01-02 12:02:12 +01:00
0557a5000f refactor(wip): finish settings page 2022-01-02 12:02:12 +01:00
7b7fa8bdf3 refactor(wip): redesign settings page 2022-01-02 12:02:12 +01:00
4e7322c985 fix: api tests 2022-01-02 12:02:12 +01:00
af0d2e84e1 refactor: replace roboto by source sans 3 font
chore: minor front page styling
2022-01-02 12:02:12 +01:00
44a2e609fb refactor: redesign login page
refactor: redesign signup page
refactor: redesign summary page
2022-01-02 12:02:12 +01:00
ee501ca3c5 fix: mocks 2022-01-02 12:02:12 +01:00
148f581906 fix: properly sort durations to prevent heartbeats from being counted twice 2022-01-02 12:02:12 +01:00
acf16421a6 chore: add quick start option 2022-01-02 12:02:12 +01:00
0039f67a2f fix: duration test 2022-01-02 12:02:12 +01:00
c8a07cee36 refactor: introduce concept of durations (resolve #261) 2022-01-02 12:02:11 +01:00
15c8838fea chore: bump version 2022-01-02 11:09:56 +01:00
f363135261 chore: minor code changes 2022-01-02 11:06:00 +01:00
d561ce1766 Merge branch 'master' of https://github.com/jabra98/wakapi into jabra98-master 2022-01-02 10:52:25 +01:00
6712f0a390 Merge pull request #291 from muety/swagger-api-patch
fix: swagger /api/api duplication
2022-01-02 10:49:32 +01:00
9950da3e7e fix: swagger /api/api duplication
Resolves #289
2022-01-02 11:22:58 +11:00
c7e12ba3b5 fix: consider all Machine/UserAgent entries 2022-01-01 20:33:58 +01:00
aaa907a7b2 Merge pull request #287 from RafDevX/patch-1 [ci skip]
Fix typos
2021-12-30 09:46:24 +01:00
0ee52662d3 fix misc. typos 2021-12-30 02:09:04 +00:00
e1daf1406e docs: mention blog post 2021-12-27 08:43:24 +01:00
235 changed files with 14242 additions and 112443 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
* text=auto
*.db -text
*.png -text
*.br -text
*.ico -text
*.woff2 -text

124
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,124 @@
name: ci
on:
push:
pull_request:
jobs:
test:
name: 'Unit- & API tests'
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -run ./... # skip scripts package, because not actually a package
- name: API Tests
run: |
npm -g install newman
./testing/run_api_tests.sh
mapi:
name: 'Automated pen-tests with Mayhem for API'
runs-on: ubuntu-latest
env:
CGO_ENABLED: 0
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.19
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Build
run: go build -v .
- name: start wakapi
run: ./wakapi --config config.default.yml &
- name: create a trivial testing user
run: sqlite3 wakapi_db.db "insert into users (id, api_key) values ('mapi', 'test-api-key')"
- name: Run Mayhem for API
uses: ForAllSecure/mapi-action@v1
continue-on-error: true
with:
mapi-token: ${{ secrets.MAPI_TOKEN }}
api-url: http://localhost:3000/api/
api-spec: static/docs/swagger.yaml
target: muety/wakapi
duration: 1min
sarif-report: mapi.sarif
run-args: |
--header-auth
Authorization: Basic dGVzdC1hcGkta2V5
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v1
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@v2
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Build
run: go build -v .
migration:
name: Migration tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres, mysql, mariadb]
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- run: ./testing/run_api_tests.sh ${{ matrix.db }} --migration

View File

@ -8,11 +8,17 @@ on:
jobs: jobs:
docker-publish: docker-publish:
name: 'Build and publish Docker image'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# https://stackoverflow.com/questions/58177786 - name: Checkout
- name: Get version uses: actions/checkout@v3
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set version
run: |
(git describe --tags --exact-match \
|| git symbolic-ref -q --short HEAD \
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
@ -33,18 +39,27 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v3
with:
images: |
ghcr.io/${{ github.repository }}
n1try/wakapi
tags: |
latest
alpine
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
context: .
file: Dockerfile file: Dockerfile
push: true push: true
tags: | platforms: linux/amd64,linux/arm64,linux/arm/v7
n1try/wakapi:latest tags: ${{ steps.meta.outputs.tags }}
n1try/wakapi:alpine
n1try/wakapi:${{ env.GIT_TAG }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:alpine
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
platforms: linux/amd64,linux/arm64
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max

View File

@ -1,55 +0,0 @@
name: Linux
on:
push:
branches:
pull_request:
release:
types:
- published
jobs:
build-and-release:
name: Linux - Build, Test & Release
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: go test ./... -run ./...
- name: API Tests
run: |
npm -g install newman
./testing/run_api_tests.sh
- name: Build
run: GO111MODULE=on go build -v .
- name: Zip executable and sample config
if: github.event_name == 'release'
run: |
cp config.default.yml config.yml
zip -9 release.zip wakapi config.yml
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_linux_amd64.zip
asset_content_type: application/gzip

77
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,77 @@
name: Release
on:
release:
types:
- published
jobs:
release:
name: 'Build, package and release to GitHub'
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-18.04
GOOS: linux
GOARCH: amd64
- platform: ubuntu-18.04
GOOS: linux
GOARCH: arm64
- platform: windows-latest
GOOS: windows
GOARCH: amd64
- platform: macos-latest
GOOS: darwin
GOARCH: amd64
- platform: macos-latest
GOOS: darwin
GOARCH: arm64
runs-on: ${{ matrix.platform }}
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Set version
shell: bash
run: |
(git describe --tags --exact-match \
|| git symbolic-ref -q --short HEAD \
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
- name: Prepare
run: |
mkdir -p dist/ && cd dist/
cp ../config.default.yml config.yml
- name: Build
working-directory: ./dist
shell: bash
run: |
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} CGO_ENABLED=0 \
go build -v -ldflags '-w -s' ../
- name: Compress working folder (Windows PowerShell)
working-directory: ./dist
if: "${{ matrix.GOOS == 'windows' }}"
run: |
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip
- name: Compress working folder
working-directory: ./dist
if: "${{ matrix.GOOS != 'windows' }}"
run: |
zip -9 wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip *
- name: Upload built executable to Release
uses: softprops/action-gh-release@v1
with:
files: ./dist/*.zip

View File

@ -1,51 +0,0 @@
name: Windows
on:
push:
branches:
pull_request:
release:
types:
- published
jobs:
build-and-release:
name: Windows - Build & Release
runs-on: windows-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get
- name: Enable Go 1.11 modules
run: cmd /c "set GO111MODULE=on"
- name: Build
run: go build -v .
- name: Compress working folder
if: github.event_name == 'release'
run: |
cp .\config.default.yml .\config.yml
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath release.zip
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_win_amd64.zip
asset_content_type: application/gzip

7
.gitignore vendored
View File

@ -5,11 +5,10 @@ wakapi
build build
*.exe *.exe
*.db *.db
*.zip
config*.yml config*.yml
!config.default.yml !config.default.yml
!testing/config.testing.yml !testing/config.*.yml
pkged.go pkged.go
package.json
yarn.lock
package-lock.json package-lock.json
node_modules node_modules

View File

@ -1,26 +1,22 @@
# Build Stage FROM golang:1.19-alpine AS build-env
FROM golang:1.16-alpine AS build-env
WORKDIR /src WORKDIR /src
# Required for go-sqlite3
RUN apk add gcc musl-dev
ADD ./go.mod .
RUN go mod download
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \ RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
chmod +x wait-for-it.sh chmod +x wait-for-it.sh
ADD ./go.mod ./go.sum ./
RUN go mod download
ADD . . ADD . .
RUN go build -o wakapi
WORKDIR /app RUN CGO_ENABLED=0 go build -ldflags "-s -w" -v -o wakapi main.go
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \ WORKDIR /staging
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \ RUN mkdir ./data ./app && \
cp /src/wait-for-it.sh . && \ cp /src/wakapi app/ && \
cp /src/entrypoint.sh . cp /src/config.default.yml app/config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' app/config.yml && \
cp /src/wait-for-it.sh app/ && \
cp /src/entrypoint.sh app/
# Run Stage # Run Stage
@ -31,22 +27,22 @@ RUN cp /src/wakapi . && \
FROM alpine:3 FROM alpine:3
WORKDIR /app WORKDIR /app
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk RUN apk add --no-cache bash ca-certificates tzdata
# See README.md and config.default.yml for all config options # See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod ENV ENVIRONMENT=prod \
ENV WAKAPI_DB_TYPE sqlite3 WAKAPI_DB_TYPE=sqlite3 \
ENV WAKAPI_DB_USER '' WAKAPI_DB_USER='' \
ENV WAKAPI_DB_PASSWORD '' WAKAPI_DB_PASSWORD='' \
ENV WAKAPI_DB_HOST '' WAKAPI_DB_HOST='' \
ENV WAKAPI_DB_NAME=/data/wakapi.db WAKAPI_DB_NAME=/data/wakapi.db \
ENV WAKAPI_PASSWORD_SALT '' WAKAPI_PASSWORD_SALT='' \
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0' WAKAPI_LISTEN_IPV4='0.0.0.0' \
ENV WAKAPI_INSECURE_COOKIES 'true' WAKAPI_INSECURE_COOKIES='true' \
ENV WAKAPI_ALLOW_SIGNUP 'true' WAKAPI_ALLOW_SIGNUP='true'
COPY --from=build-env /app . COPY --from=build-env /staging /
VOLUME /data EXPOSE 3000
ENTRYPOINT /app/entrypoint.sh ENTRYPOINT /app/entrypoint.sh

288
README.md
View File

@ -6,7 +6,7 @@
<img src="https://badges.fw-web.space/github/license/muety/wakapi"> <img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a> <a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a> <a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi"> <img src="https://wakapi.dev/api/badge/n1try/interval:any/project:wakapi?label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi"> <img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a> <a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a> <a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
@ -29,20 +29,18 @@
</div> </div>
<p align="center"> <p align="center">
<img src="static/assets/images/screenshot.png" width="500px"> <img src="static/assets/images/screenshot.webp" width="500px">
</p> </p>
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki). Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you want, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
## 🚀 Features ## 🚀 Features
* ✅ 100 % free and open-source
* ✅ Free and open-source
* ✅ Built by developers for developers * ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems * ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges * ✅ Badges
* ✅ Weekly E-Mail Reports * ✅ Weekly E-Mail reports
* ✅ REST API * ✅ REST API
* ✅ Partially compatible with WakaTime * ✅ Partially compatible with WakaTime
* ✅ WakaTime integration * ✅ WakaTime integration
@ -51,32 +49,43 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Self-hosted * ✅ Self-hosted
## 🚧 Roadmap ## 🚧 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), [#80](https://github.com/muety/wakapi/issues/80), [#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).
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
## ⌨️ How to use? ## ⌨️ How to use?
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev) ### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
If you want to try out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below).
However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕ If you want to try out a free, hosted cloud service, all you need to do is create an account and then set up your client-side tooling (see below).
### 📦 Option 2: Quick-run a release
### 📦 Option 2: Quick-run a Release
```bash ```bash
$ curl -L https://wakapi.dev/get | bash $ curl -L https://wakapi.dev/get | bash
``` ```
**Alternatively** using [eget](https://github.com/zyedidia/eget):
```bash
$ eget muety/wakapi
```
### 🐳 Option 3: Use Docker ### 🐳 Option 3: Use Docker
```bash ```bash
# Create a persistent volume # Create a persistent volume
$ docker volume create wakapi-data $ docker volume create wakapi-data
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
# Run the container # Run the container
$ docker run -d \ $ docker run -d \
-p 3000:3000 \ -p 3000:3000 \
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \ -e "WAKAPI_PASSWORD_SALT=$SALT" \
-v wakapi-data:/data \ -v wakapi-data:/data \
--name wakapi n1try/wakapi --name wakapi \
ghcr.io/muety/wakapi:latest
``` ```
**Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options. **Note:** By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
@ -84,39 +93,36 @@ $ docker run -d \
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment. If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
### 🧑‍💻 Option 4: Compile and run from source ### 🧑‍💻 Option 4: Compile and run from source
#### Prerequisites
* Go >= 1.16 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
* Fedora / RHEL: `dnf install @development-tools`
* Ubuntu / Debian: `apt install build-essential`
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
#### Compile & Run
```bash ```bash
# Build the executable # Build and install
$ go build -o wakapi # Alternatively: go build -o wakapi
$ go install github.com/muety/wakapi@latest
# Adapt config to your needs # Get default config and customize
$ cp config.default.yml config.yml $ curl -o wakapi.yml https://raw.githubusercontent.com/muety/wakapi/master/config.default.yml
$ vi config.yml $ vi wakapi.yml
# Run it # Run it
$ ./wakapi $ ./wakapi -config wakapi.yml
``` ```
**Note:** Check the comments `config.yml` for best practices regarding security configuration and more. **Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
### 💻 Client Setup 💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
### 💻 Client setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics for Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins) 1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. **Editing your local `~/.wakatime.cfg`** file as follows 2. **Edit your local `~/.wakatime.cfg`** file as follows.
```ini ```ini
[settings] [settings]
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server # Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api/heartbeat api_url = http://localhost:3000/api
# Your Wakapi API key (get it from the web interface after having created an account) # Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
@ -124,66 +130,91 @@ api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition. Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
## 🔧 Configuration Options ## 🔧 Configuration options
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key / Env. Variable | Default | Description | You can specify configuration options either via a config file (default: `config.yml`, customizable through the `-c` argument) or via environment variables. Here is an overview of all options.
|-----------------------------|--------------|---------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings | | YAML key / Env. variable | Default | Description |
| `app.custom_languages` | - | Map from file endings to language names | |------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) | | `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on | | `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) | | `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) | | `app.leaderboard_generation_time` /<br>`WAKAPI_LEADERBOARD_GENERATION_TIME` | `0 0 6 * * *,0 0 18 * * *` | One or multiple times of day at which to re-calculate the leaderboard |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) | | `app.data_cleanup_time` /<br>`WAKAPI_DATA_CLEANUP_TIME` | `0 0 6 * * 7` | When to perform data cleanup operations (see `app.data_retention_months`) |
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds | | `app.import_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) | | `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) | | `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | | `app.custom_languages` | - | Map from file endings to language names |
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing | | `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP | | `app.support_contact` /<br>`WAKAPI_SUPPORT_CONTACT` | `hostmaster@wakapi.dev` | E-Mail address to display as a support contact on the page |
| `security.cookie_max_age` /<br> `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies | | `app.data_retention_months` /<br>`WAKAPI_DATA_RETENTION_MONTHS` | `-1` | Maximum retention period in months for user data (heartbeats) (-1 for unlimited) |
| `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration | | `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` | | `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host | | `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port | | `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user | | `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password | | `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name | | `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) | | `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `db.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) | | `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | | `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | | `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up | | `security.cookie_max_age` /<br> `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | | `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) | | `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) | | `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
| `mail.smtp.*` /<br> `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details | | `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
| `mail.mailwhale.*` /<br> `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details | | `db.socket` /<br> `WAKAPI_DB_SOCKET` | - | Database UNIX socket (alternative to `host`) (for MySQL only) |
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) | | `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing | | `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry | | `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS`| `0.1` | Probability of tracing a heartbeats request in Sentry | | `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`) |
| `db.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `Wakapi <noreply@wakapi.dev>` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.host` /<br> `WAKAPI_MAIL_SMTP_HOST` | - | SMTP server address for sending mail (if using `smtp` mail provider) |
| `mail.smtp.port` /<br> `WAKAPI_MAIL_SMTP_PORT` | - | SMTP server port (usually 465) |
| `mail.smtp.username` /<br> `WAKAPI_MAIL_SMTP_USER` | - | SMTP server authentication username |
| `mail.smtp.password` /<br> `WAKAPI_MAIL_SMTP_PASS` | - | SMTP server authentication password |
| `mail.smtp.tls` /<br> `WAKAPI_MAIL_SMTP_TLS` | `false` | Whether the SMTP server requires TLS encryption (`false` for STARTTLS or no encryption) |
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider) |
| `mail.mailwhale.client_id` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_ID` | - | MailWhale API client ID |
| `mail.mailwhale.client_secret` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET` | - | MailWhale API client secret |
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeat request in Sentry |
| `quick_start` /<br> `WAKAPI_QUICK_START` | `false` | Whether to skip initial boot tasks. Use only for development purposes! |
### Supported databases ### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported. Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
* [SQLite](https://sqlite.org/) (_default, easy setup_) * [SQLite](https://sqlite.org/) (_default, easy setup_)
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_) * [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_) * [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_) * [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_) * [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
## 🔧 API Endpoints ## 🔧 API endpoints
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui). See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
### Generating Swagger docs ### Generating Swagger docs
```bash ```bash
$ go get -u github.com/swaggo/swag/cmd/swag $ go install github.com/swaggo/swag/cmd/swag@latest
$ swag init -o static/docs $ swag init -o static/docs
``` ```
## 🤝 Integrations ## 🤝 Integrations
### Prometheus Export
### Prometheus export
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how. You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
```bash ```bash
@ -198,6 +229,7 @@ $ echo "<YOUR_API_KEY>" | base64
``` ```
#### Scrape config example #### Scrape config example
```yml ```yml
# prometheus.yml # prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000) # (assuming your Wakapi instance listens at localhost, port 3000)
@ -211,15 +243,18 @@ scrape_configs:
- targets: ['localhost:3000'] - targets: ['localhost:3000']
``` ```
#### Grafana #### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter). There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image) ![](https://grafana.com/api/dashboards/12790/images/8741/image)
### WakaTime Integration ### WakaTime integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
### GitHub Readme Stats integrations
### 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.
![](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)
@ -227,20 +262,20 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
<details> <details>
<summary>Click to view code</summary> <summary>Click to view code</summary>
```md ```markdown
![](https://github-readme-stats.vercel.app/api/wakatime?username={yourusername}&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={yourusername}&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
``` ```
</details> </details>
<br> <br>
### Github Readme Metrics integration
### 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.
Preview: Preview:
![](https://raw.githubusercontent.com/lowlighter/lowlighter/master/metrics.plugin.wakatime.svg) ![](https://raw.githubusercontent.com/lowlighter/metrics/examples/metrics.plugin.wakatime.svg)
<details> <details>
<summary>Click to view code</summary> <summary>Click to view code</summary>
@ -254,7 +289,7 @@ Preview:
plugin_wakatime_days: 7 # Display last week stats plugin_wakatime_days: 7 # Display last week stats
plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs
plugin_wakatime_limit: 4 # Show 4 entries per graph plugin_wakatime_limit: 4 # Show 4 entries per graph
plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint
plugin_wakatime_user: .user.login # User plugin_wakatime_user: .user.login # User
``` ```
@ -262,20 +297,26 @@ Preview:
</details> </details>
<br> <br>
## 👍 Best Practices ## 👍 Best practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml` 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).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`.
## 🧪 Tests ## 🧪 Tests
### Unit Tests
### Unit tests
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify). Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
#### How to run #### How to run
```bash ```bash
$ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./... $ CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -json -coverprofile=coverage/coverage.out ./... -run ./...
``` ```
### API Tests ### API tests
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned. API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner. Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
@ -283,6 +324,7 @@ Our API (or end-to-end, in some way) tests are implemented as a [Postman](https:
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded. To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
#### Prerequisites (Linux only) #### Prerequisites (Linux only)
```bash ```bash
# 1. sqlite (cli) # 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite $ sudo apt install sqlite # Fedora: sudo dnf install sqlite
@ -291,58 +333,76 @@ $ sudo apt install sqlite # Fedora: sudo dnf install sqlite
$ npm install -g newman $ npm install -g newman
``` ```
#### How to run (Linux only) #### How to run (Linux only)
```bash ```bash
$ ./testing/run_api_tests.sh $ ./testing/run_api_tests.sh
``` ```
## 🤓 Developer Notes ## 🤓 Developer notes
### Building web assets ### Building web assets
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.
#### TailwindCSS To keep things minimal, all JS and CSS assets are included as static files and checked in to Git. [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) and [Iconify](https://iconify.design/docs/icon-bundles/) require an additional build step. To only require this at the time of development, the compiled assets are checked in to Git as well.
```bash
$ tailwindcss-cli build static/assets/vendor/tailwind.css -o static/assets/vendor/tailwind.dist.css
```
#### Iconify
```bash ```bash
$ yarn add -D @iconify/json-tools @iconify/json $ yarn
$ node scripts/bundle_icons.js $ yarn build # or: yarn watch
``` ```
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js). New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
#### Precompression
As explained in [#284](https://github.com/muety/wakapi/issues/284), precompressed (using Brotli) versions of some of the assets are delivered to save additional bandwidth. This was inspired by Caddy's [`precompressed`](https://caddyserver.com/docs/caddyfile/directives/file_server) directive. [`gzipped.FileServer`](https://github.com/muety/wakapi/blob/07a367ce0a97c7738ba8e255e9c72df273fd43a3/main.go#L249) checks for every static file's `.br` or `.gz` equivalents and, if present, delivers those instead of the actual file, alongside `Content-Encoding: br`. Currently, compressed assets are simply checked in to Git. Later we might want to have this be part of a new build step.
To pre-compress files, run this:
```bash
# Install brotli first
$ sudo apt install brotli # or: sudo dnf install brotli
# Watch, build and compress
$ yarn watch:compress
# Alternatively: build and compress only
$ yarn build:all:compress
# Alternatively: compress only
$ yarn compress
```
## ❔ FAQs ## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) largely apply to Wakapi as well. You might find answers there.
<details> <details>
<summary><b>What data is sent to Wakapi?</b></summary> <summary><b>What data are sent to Wakapi?</b></summary>
<ul> <ul>
<li>File names</li> <li>File names</li>
<li>Project names</li> <li>Project names</li>
<li>Editor names</li> <li>Editor names</li>
<li>You computer's host name</li> <li>Your computer's host name</li>
<li>Timestamps for every action you take in your editor</li> <li>Timestamps for every action you take in your editor</li>
<li>...</li> <li>...</li>
</ul> </ul>
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details. See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names. If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
</details> </details>
<details> <details>
<summary><b>What happens if I'm offline?</b></summary> <summary><b>What happens if I'm offline?</b></summary>
All data is cached locally on your machine and sent in batches once you're online again. All data are cached locally on your machine and sent in batches once you're online again.
</details> </details>
<details> <details>
<summary><b>How did Wakapi come about?</b></summary> <summary><b>How did Wakapi come about?</b></summary>
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing">$9 a month</a> back then. Luckily, most parts of WakaTime are open source! Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing">$9 a month</a> back then. Luckily, most parts of WakaTime are open source!
</details> </details>
<details> <details>
@ -355,27 +415,27 @@ Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li> <li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
<li>Personal Goals</li> <li>Personal Goals</li>
<li>Team- / Organization Support</li> <li>Team- / Organization Support</li>
<li>Integrations (with GitLab, etc.)</li> <li>Additional Integrations (with GitLab, etc.)</li>
<li>Richer API</li> <li>Richer API</li>
</ul> </ul>
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi. WakaTime is worth the price. However, if you only need basic statistics and like to keep sovereignty over your data, you might want to go with Wakapi.
</details> </details>
<details> <details>
<summary><b>How are durations calculated?</b></summary> <summary><b>How are durations calculated?</b></summary>
Inferring a measure for your coding time from heartbeats works a bit different than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat, that occurs after a longer pause, with 2 extra minutes. Inferring a measure for your coding time from heartbeats works a bit differently than in WakaTime. While WakaTime has <a href="https://wakatime.com/faq#timeout">timeout intervals</a>, Wakapi essentially just pads every heartbeat that occurs after a longer pause with 2 extra minutes.
Here is an example (circles are heartbeats): Here is an example (circles are heartbeats):
``` ```text
|---o---o--------------o---o---| |---o---o--------------o---o---|
| |10s| 3m |10s| | | |10s| 3m |10s| |
``` ```
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen find a solution, but not actually typing code. It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break, or were just no heartbeats being sent, e.g. because the developer was staring at the screen trying to find a solution, but not actually typing code?
<ul> <ul>
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec <li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
@ -387,17 +447,21 @@ Wakapi adds a "padding" of two minutes before the third heartbeat. This is why t
</details> </details>
## 🌳 Treeware ## 🌳 Treeware
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest youll be creating employment for local families and restoring wildlife habitats. This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest youll be creating employment for local families and restoring wildlife habitats.
## 👏 Support ## 👏 Support
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So if you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts my motivation to keep improving Wakapi! Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So if you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts my motivation to keep improving Wakapi!
## 🙏 Thanks ## 🙏 Thanks
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program. Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program.
![](static/assets/images/jetbrains-logo.png) ![](static/assets/images/jetbrains-logo.png)
## 📓 License ## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io) GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -1,4 +1,6 @@
env: production env: production
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
server: server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
@ -12,22 +14,31 @@ server:
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
app: app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>') leaderboard_generation_time: '0 0 6 * * *,0 0 18 * * *' # times at which to re-calculate the leaderboard
inactive_days: 7 # time of previous days within a user must have logged in to be considered active report_time_weekly: '0 0 18 * * 5' # time at which to fan out weekly reports (extended cron)
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction data_cleanup_time: '0 0 6 * * 7' # time at which to run old data cleanup (if enabled through data_retention_months)
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
data_retention_months: -1 # maximum retention period on months for user data (heartbeats) (-1 for infinity)
custom_languages: custom_languages:
vue: Vue vue: Vue
jsx: JSX jsx: JSX
tsx: TSX
cjs: JavaScript
ipynb: Python
svelte: Svelte svelte: Svelte
# url template for user avatar images (to be used with services like gravatar or dicebear) # url template for user avatar images (to be used with services like gravatar or dicebear)
# available variable placeholders are: username, username_hash, email, email_hash # available variable placeholders are: username, username_hash, email, email_hash
avatar_url_template: https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg # defaults to wakapi's internal avatar rendering powered by https://codeberg.org/Codeberg/avatars
avatar_url_template: api/avatar/{username_hash}.svg
db: db:
host: # leave blank when using sqlite3 host: # leave blank when using sqlite3
port: # leave blank when using sqlite3 port: # leave blank when using sqlite3
socket: # alternative to db.host (leave blank when using sqlite3)
user: # leave blank when using sqlite3 user: # leave blank when using sqlite3
password: # leave blank when using sqlite3 password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db) name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
@ -35,7 +46,7 @@ db:
charset: utf8mb4 # only used for mysql connections charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security: security:
password_salt: # change this password_salt: # change this
@ -51,6 +62,15 @@ sentry:
sample_rate: 0.75 # probability of tracing a request sample_rate: 0.75 # probability of tracing a request
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments
subscriptions:
enabled: false
expiry_notifications: true
stripe_api_key:
stripe_secret_key:
stripe_endpoint_secret:
standard_price_id:
mail: mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.) enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
@ -68,4 +88,4 @@ mail:
mailwhale: mailwhale:
url: url:
client_id: client_id:
client_secret: client_secret:

View File

@ -4,9 +4,11 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"github.com/robfig/cron/v3"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@ -14,8 +16,8 @@ import (
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/jinzhu/configor" "github.com/jinzhu/configor"
"github.com/muety/wakapi/data" "github.com/muety/wakapi/data"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/utils"
"gorm.io/gorm" uuid "github.com/satori/go.uuid"
) )
const ( const (
@ -25,9 +27,14 @@ const (
SQLDialectPostgres = "postgres" SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3" SQLDialectSqlite = "sqlite3"
KeyLatestTotalTime = "latest_total_time" KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users" KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import" KeyLastImportImport = "last_import"
KeyFirstHeartbeat = "first_heartbeat"
KeySubscriptionNotificationSent = "sub_reminder"
KeyNewsbox = "newsbox"
SessionKeyDefault = "default"
SimpleDateFormat = "2006-01-02" SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05" SimpleDateTimeFormat = "2006-01-02 15:04:05"
@ -62,15 +69,20 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string var env string
type appConfig struct { type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"` LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"0 0 6 * * *,0 0 18 * * *" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"` ReportTimeWeekly string `yaml:"report_time_weekly" default:"0 0 18 * * 5" env:"WAKAPI_REPORT_TIME_WEEKLY"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"` DataCleanupTime string `yaml:"data_cleanup_time" default:"0 0 6 * * 7" env:"WAKAPI_DATA_CLEANUP_TIME"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"` ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"` ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"` InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"` HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
Colors map[string]map[string]string `yaml:"-"` CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
DataRetentionMonths int `yaml:"data_retention_months" default:"-1" env:"WAKAPI_DATA_RETENTION_MONTHS"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
SupportContact string `yaml:"support_contact" default:"hostmaster@wakapi.dev" env:"WAKAPI_SUPPORT_CONTACT"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
} }
type securityConfig struct { type securityConfig struct {
@ -82,10 +94,12 @@ type securityConfig struct {
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"` InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"` CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"` SecureCookie *securecookie.SecureCookie `yaml:"-"`
SessionKey []byte `yaml:"-"`
} }
type dbConfig struct { type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"` Host string `env:"WAKAPI_DB_HOST"`
Socket string `env:"WAKAPI_DB_SOCKET"`
Port uint `env:"WAKAPI_DB_PORT"` Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"` User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"` Password string `env:"WAKAPI_DB_PASSWORD"`
@ -93,6 +107,7 @@ type dbConfig struct {
Dialect string `yaml:"-"` Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"` Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"` Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
DSN string `yaml:"DSN" default:"" env:"WAKAPI_DB_DSN"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"` MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"` Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"` AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
@ -110,6 +125,16 @@ type serverConfig struct {
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"` TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
} }
type subscriptionsConfig struct {
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
ExpiryNotifications bool `yaml:"expiry_notifications" default:"true" env:"WAKAPI_SUBSCRIPTIONS_EXPIRY_NOTIFICATIONS"`
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"`
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
StandardPrice string `yaml:"-"`
}
type sentryConfig struct { type sentryConfig struct {
Dsn string `env:"WAKAPI_SENTRY_DSN"` Dsn string `env:"WAKAPI_SENTRY_DSN"`
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"` EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
@ -140,22 +165,26 @@ type SMTPMailConfig struct {
} }
type Config struct { type Config struct {
Env string `default:"dev" env:"ENVIRONMENT"` Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"` Version string `yaml:"-"`
App appConfig QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
Security securityConfig SkipMigrations bool `yaml:"skip_migrations" env:"WAKAPI_SKIP_MIGRATIONS"`
Db dbConfig InstanceId string `yaml:"-"` // only temporary, changes between runs
Server serverConfig App appConfig
Sentry sentryConfig Security securityConfig
Mail mailConfig Db dbConfig
Server serverConfig
Subscriptions subscriptionsConfig
Sentry sentryConfig
Mail mailConfig
} }
func (c *Config) CreateCookie(name, value, path string) *http.Cookie { func (c *Config) CreateCookie(name, value string) *http.Cookie {
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec) return c.createCookie(name, value, c.Server.BasePath, c.Security.CookieMaxAgeSec)
} }
func (c *Config) GetClearCookie(name, path string) *http.Cookie { func (c *Config) GetClearCookie(name string) *http.Cookie {
return c.createCookie(name, "", path, -1) return c.createCookie(name, "", c.Server.BasePath, -1)
} }
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie { func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
@ -166,7 +195,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
MaxAge: maxAge, MaxAge: maxAge,
Secure: !c.Security.InsecureCookies, Secure: !c.Security.InsecureCookies,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteLaxMode,
} }
} }
@ -178,65 +207,102 @@ func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != "" return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
} }
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
return func(db *gorm.DB) error {
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
}
func (c *appConfig) GetCustomLanguages() map[string]string { func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false) return utils.CloneStringMap(c.CustomLanguages, false)
} }
func (c *appConfig) GetLanguageColors() map[string]string { func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.Colors["languages"], true) return utils.CloneStringMap(c.Colors["languages"], true)
} }
func (c *appConfig) GetEditorColors() map[string]string { func (c *appConfig) GetEditorColors() map[string]string {
return cloneStringMap(c.Colors["editors"], true) return utils.CloneStringMap(c.Colors["editors"], true)
} }
func (c *appConfig) GetOSColors() map[string]string { func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true) return utils.CloneStringMap(c.Colors["operating_systems"], true)
} }
func (c *appConfig) GetWeeklyReportDay() time.Weekday { func (c *appConfig) GetAggregationTimeCron() string {
s := strings.Split(c.ReportTimeWeekly, ",")[0] if strings.Contains(c.AggregationTime, ":") {
return parseWeekday(s) // old gocron format, e.g. "15:04"
timeParts := strings.Split(c.AggregationTime, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
return utils.CronPadToSecondly(c.AggregationTime)
} }
func (c *appConfig) GetWeeklyReportTime() string { func (c *appConfig) GetWeeklyReportCron() string {
return strings.Split(c.ReportTimeWeekly, ",")[1] if strings.Contains(c.ReportTimeWeekly, ",") {
// old gocron format, e.g. "fri,18:00"
split := strings.Split(c.ReportTimeWeekly, ",")
weekday := utils.ParseWeekday(split[0])
timeParts := strings.Split(split[1], ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
}
return utils.CronPadToSecondly(c.ReportTimeWeekly)
}
func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
crons := []string{}
var parse func(string) string
if strings.Contains(c.LeaderboardGenerationTime, ":") {
// old gocron format, e.g. "15:04"
parse = func(s string) string {
timeParts := strings.Split(s, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
} else {
parse = func(s string) string {
return utils.CronPadToSecondly(s)
}
}
for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") {
crons = append(crons, parse(strings.TrimSpace(s)))
}
return crons
}
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
d, _ := time.ParseDuration(c.HeartbeatMaxAge)
return d
} }
func (c *dbConfig) IsSQLite() bool { func (c *dbConfig) IsSQLite() bool {
@ -266,12 +332,12 @@ func IsDev(env string) bool {
func readColors() map[string]map[string]string { func readColors() map[string]map[string]string {
// Read language colors // Read language colors
// Source: // Source:
// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json // - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// https://wakatime.com/colors/operating_systems // - https://wakatime.com/colors/operating_systems
// - https://wakatime.com/colors/editors // - https://wakatime.com/colors/editors
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after. // Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
// $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue) // - $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
// $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue) // - $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
raw := data.ColorsFile raw := data.ColorsFile
if IsDev(env) { if IsDev(env) {
@ -286,13 +352,6 @@ func readColors() map[string]map[string]string {
return colors return colors
} }
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
}
return *cFlag
}
func resolveDbDialect(dbType string) string { func resolveDbDialect(dbType string) string {
if dbType == "cockroach" { if dbType == "cockroach" {
return "postgres" return "postgres"
@ -306,35 +365,6 @@ func resolveDbDialect(dbType string) string {
return dbType return dbType
} }
func findString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}
func parseWeekday(s string) time.Weekday {
switch strings.ToLower(s) {
case "mon", strings.ToLower(time.Monday.String()):
return time.Monday
case "tue", strings.ToLower(time.Tuesday.String()):
return time.Tuesday
case "wed", strings.ToLower(time.Wednesday.String()):
return time.Wednesday
case "thu", strings.ToLower(time.Thursday.String()):
return time.Thursday
case "fri", strings.ToLower(time.Friday.String()):
return time.Friday
case "sat", strings.ToLower(time.Saturday.String()):
return time.Saturday
case "sun", strings.ToLower(time.Sunday.String()):
return time.Sunday
}
return time.Monday
}
func Set(config *Config) { func Set(config *Config) {
cfg = config cfg = config
} }
@ -348,18 +378,26 @@ func Load(version string) *Config {
flag.Parse() flag.Parse()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil { if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
logbuch.Fatal("failed to read config: %v", err) logbuch.Fatal("failed to read config: %v", err)
} }
env = config.Env env = config.Env
config.Version = strings.TrimSpace(version) config.Version = strings.TrimSpace(version)
tagVersionMatch, _ := regexp.MatchString(`\d+\.\d+\.\d+`, version)
if tagVersionMatch {
config.Version = "v" + config.Version
}
config.InstanceId = uuid.NewV4().String()
config.App.Colors = readColors() config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type) config.Db.Dialect = resolveDbDialect(config.Db.Type)
config.Security.SecureCookie = securecookie.New( config.Security.SecureCookie = securecookie.New(
securecookie.GenerateRandomKey(64), securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32), securecookie.GenerateRandomKey(32),
) )
config.Security.SessionKey = securecookie.GenerateRandomKey(32)
if strings.HasSuffix(config.Server.BasePath, "/") { if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1] config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
@ -376,8 +414,18 @@ func Load(version string) *Config {
initSentry(config.Sentry, config.IsDev()) initSentry(config.Sentry, config.IsDev())
} }
if config.App.DataRetentionMonths <= 0 {
logbuch.Info("disabling data retention policy, keeping data forever")
} else {
dataRetentionWarning := fmt.Sprintf("⚠️ data retention policy will cause user data older than %d months to be deleted", config.App.DataRetentionMonths)
if config.Subscriptions.Enabled {
dataRetentionWarning += " (except for users with active subscriptions)"
}
logbuch.Warn(dataRetentionWarning)
}
// some validation checks // some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" { if config.Server.ListenIpV4 == "-" && config.Server.ListenIpV6 == "-" && config.Server.ListenSocket == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set") logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
} }
if config.Db.MaxConn <= 0 { if config.Db.MaxConn <= 0 {
@ -387,14 +435,36 @@ func Load(version string) *Config {
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
config.Db.MaxConn = 1 config.Db.MaxConn = 1
} }
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" { if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider) logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
} }
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil { if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
logbuch.Fatal("invalid interval set for report_time_weekly") logbuch.Fatal("invalid duration set for heartbeat_max_age")
} }
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time") cronParser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
if _, err := cronParser.Parse(config.App.GetWeeklyReportCron()); err != nil {
logbuch.Fatal("invalid cron expression for report_time_weekly")
}
if _, err := cronParser.Parse(config.App.GetAggregationTimeCron()); err != nil {
logbuch.Fatal("invalid cron expression for aggregation_time")
}
for _, c := range config.App.GetLeaderboardGenerationTimeCron() {
if _, err := cronParser.Parse(c); err != nil {
logbuch.Fatal("invalid cron expression for leaderboard_generation_time")
}
}
// deprecation notices
if strings.Contains(config.App.AggregationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'aggregation_time', please change it to a valid cron expression")
}
if strings.Contains(config.App.ReportTimeWeekly, ":") {
logbuch.Warn("you're using deprecated syntax for 'report_time_weekly', please change it to a valid cron expression")
}
if strings.Contains(config.App.LeaderboardGenerationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'leaderboard_generation_time', please change it to a semicolon-separated list if valid cron expressions")
} }
Set(config) Set(config)

View File

@ -2,8 +2,9 @@ package config
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/assert"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestConfig_IsDev(t *testing.T) { func TestConfig_IsDev(t *testing.T) {
@ -37,6 +38,28 @@ func Test_mysqlConnectionString(t *testing.T) {
), mysqlConnectionString(c)) ), mysqlConnectionString(c))
} }
func Test_mysqlConnectionStringSocket(t *testing.T) {
c := &dbConfig{
Socket: "/var/run/mysql.sock",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "mysql",
Charset: "utf8mb4",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@unix(%s)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Socket,
c.Name,
"Local",
), mysqlConnectionString(c))
}
func Test_postgresConnectionString(t *testing.T) { func Test_postgresConnectionString(t *testing.T) {
c := &dbConfig{ c := &dbConfig{
Host: "test_host", Host: "test_host",

View File

@ -2,9 +2,10 @@ package config
import ( import (
"fmt" "fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -54,11 +55,16 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
} }
func mysqlConnectionString(config *dbConfig) string { func mysqlConnectionString(config *dbConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES", host := fmt.Sprintf("tcp(%s:%d)", config.Host, config.Port)
if config.Socket != "" {
host = fmt.Sprintf("unix(%s)", config.Socket)
}
return fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User, config.User,
config.Password, config.Password,
config.Host, host,
config.Port,
config.Name, config.Name,
config.Charset, config.Charset,
"Local", "Local",
@ -66,6 +72,10 @@ func mysqlConnectionString(config *dbConfig) string {
} }
func postgresConnectionString(config *dbConfig) string { func postgresConnectionString(config *dbConfig) string {
if len(config.DSN) > 0 {
return config.DSN
}
sslmode := "disable" sslmode := "disable"
if config.Ssl { if config.Ssl {
sslmode = "require" sslmode = "require"

35
config/db_opts.go Normal file
View File

@ -0,0 +1,35 @@
package config
import (
"gorm.io/gorm"
)
type WakapiDBOpts struct {
dbConfig *dbConfig
}
func GetWakapiDBOpts(dbConfig *dbConfig) *WakapiDBOpts {
return &WakapiDBOpts{dbConfig: dbConfig}
}
func (opts WakapiDBOpts) Apply(config *gorm.Config) error {
return nil
}
func (opts WakapiDBOpts) AfterInitialize(db *gorm.DB) error {
// initial session variables
if opts.dbConfig.Type == "cockroach" {
// https://www.cockroachlabs.com/docs/stable/experimental-features.html#alter-column-types
if err := db.Exec("SET enable_experimental_alter_column_type_general = true;").Error; err != nil {
return err
}
}
if opts.dbConfig.IsSQLite() {
if err := db.Exec("PRAGMA foreign_keys = ON;").Error; err != nil {
return err
}
}
return nil
}

85
config/jobqueue.go Normal file
View File

@ -0,0 +1,85 @@
package config
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"math"
"runtime"
)
var jobQueues map[string]*artifex.Dispatcher
var jobCounts map[string]int
const (
QueueDefault = "wakapi.default"
QueueProcessing = "wakapi.processing"
QueueReports = "wakapi.reports"
QueueMails = "wakapi.mail"
QueueImports = "wakapi.imports"
QueueHousekeeping = "wakapi.housekeeping"
)
type JobQueueMetrics struct {
Queue string
EnqueuedJobs int
FinishedJobs int
}
func init() {
jobQueues = make(map[string]*artifex.Dispatcher)
InitQueue(QueueDefault, 1)
InitQueue(QueueProcessing, halfCPUs())
InitQueue(QueueReports, 1)
InitQueue(QueueMails, 1)
InitQueue(QueueImports, 1)
InitQueue(QueueHousekeeping, halfCPUs())
}
func InitQueue(name string, workers int) error {
if _, ok := jobQueues[name]; ok {
return fmt.Errorf("queue '%s' already existing", name)
}
logbuch.Info("creating job queue '%s' (%d workers)", name, workers)
jobQueues[name] = artifex.NewDispatcher(workers, 4096)
jobQueues[name].Start()
return nil
}
func GetDefaultQueue() *artifex.Dispatcher {
return GetQueue(QueueDefault)
}
func GetQueue(name string) *artifex.Dispatcher {
if _, ok := jobQueues[name]; !ok {
InitQueue(name, 1)
}
return jobQueues[name]
}
func GetQueueMetrics() []*JobQueueMetrics {
metrics := make([]*JobQueueMetrics, 0, len(jobQueues))
for name, queue := range jobQueues {
metrics = append(metrics, &JobQueueMetrics{
Queue: name,
EnqueuedJobs: queue.CountEnqueued(),
FinishedJobs: queue.CountDispatched(),
})
}
return metrics
}
func CloseQueues() {
for _, q := range jobQueues {
q.Stop()
}
}
func allCPUs() int {
return runtime.NumCPU()
}
func halfCPUs() int {
return int(math.Ceil(float64(runtime.NumCPU()) / 2.0))
}

View File

@ -3,7 +3,6 @@ package config
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/muety/wakapi/models"
"io" "io"
"net/http" "net/http"
"os" "os"
@ -89,8 +88,8 @@ func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
if h := l.req.Context().Value(sentry.HubContextKey); h != nil { if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
hub := h.(*sentry.Hub) hub := h.(*sentry.Hub)
hub.Scope().SetRequest(l.req) hub.Scope().SetRequest(l.req)
if u := getPrincipal(l.req); u != nil { if uid := getPrincipal(l.req); uid != "" {
hub.Scope().SetUser(sentry.User{ID: u.ID}) hub.Scope().SetUser(sentry.User{ID: uid})
} }
hub.CaptureEvent(event) hub.CaptureEvent(event)
return return
@ -109,8 +108,9 @@ var excludedRoutes = []string{
func initSentry(config sentryConfig, debug bool) { func initSentry(config sentryConfig, debug bool) {
if err := sentry.Init(sentry.ClientOptions{ if err := sentry.Init(sentry.ClientOptions{
Dsn: config.Dsn, Dsn: config.Dsn,
Debug: debug, Debug: debug,
AttachStacktrace: true,
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled { TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
if !config.EnableTracing { if !config.EnableTracing {
return sentry.SampledFalse return sentry.SampledFalse
@ -132,24 +132,26 @@ func initSentry(config sentryConfig, debug bool) {
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
if hint.Context != nil { if hint.Context != nil {
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok { if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
if u := getPrincipal(req); u != nil { if uid := getPrincipal(req); uid != "" {
event.User.ID = u.ID event.User.ID = uid
} }
} }
} }
return event return event
}, },
}); err != nil { }); err != nil {
logbuch.Fatal("failed to initialized sentry %v", err) logbuch.Fatal("failed to initialized sentry - %v", err)
} }
} }
func getPrincipal(r *http.Request) *models.User { // returns a user id
type principalGetter interface { func getPrincipal(r *http.Request) string {
GetPrincipal() *models.User type principalIdentityGetter interface {
GetPrincipalIdentity() string
} }
if p := r.Context().Value("principal"); p != nil { if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal() return p.(principalIdentityGetter).GetPrincipalIdentity()
} }
return nil return ""
} }

14
config/session.go Normal file
View File

@ -0,0 +1,14 @@
package config
import "github.com/gorilla/sessions"
// sessions are only used for displaying flash messages
var sessionStore *sessions.CookieStore
func GetSessionStore() *sessions.CookieStore {
if sessionStore == nil {
sessionStore = sessions.NewCookieStore(Get().Security.SessionKey)
}
return sessionStore
}

View File

@ -9,4 +9,5 @@ const (
ResetPasswordTemplate = "reset-password.tpl.html" ResetPasswordTemplate = "reset-password.tpl.html"
SettingsTemplate = "settings.tpl.html" SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html" SummaryTemplate = "summary.tpl.html"
LeaderboardTemplate = "leaderboard.tpl.html"
) )

View File

@ -1,14 +0,0 @@
package config
import "strings"
func cloneStringMap(m map[string]string, keysToLower bool) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
if keysToLower {
k = strings.ToLower(k)
}
m2[k] = v
}
return m2
}

File diff suppressed because it is too large Load Diff

View File

@ -2,291 +2,396 @@
"languages": { "languages": {
"1C Enterprise": "#814CCC", "1C Enterprise": "#814CCC",
"ABAP": "#E8274B", "ABAP": "#E8274B",
"ActionScript": "#882B0F",
"Ada": "#02f88c",
"Agda": "#315665",
"AGS Script": "#B9D9FF", "AGS Script": "#B9D9FF",
"Alloy": "#64C800", "AL": "#3AA2B5",
"AMPL": "#E6EFBB", "AMPL": "#E6EFBB",
"AngelScript": "#C7D7DC",
"ANTLR": "#9DC3FF", "ANTLR": "#9DC3FF",
"API Blueprint": "#2ACCA8", "API Blueprint": "#2ACCA8",
"APL": "#5A8164", "APL": "#8a0707",
"AppleScript": "#101F1F", "ASP.NET": "#9400ff",
"Arc": "#aa2afe",
"ASP": "#6a40fd",
"AspectJ": "#a957b0",
"Assembly": "#6E4C13",
"Asymptote": "#4a0c0c",
"ATS": "#1ac620", "ATS": "#1ac620",
"ActionScript": "#e3491a",
"Ada": "#02f88c",
"Agda": "#467C91",
"Alloy": "#cc5c24",
"AngelScript": "#C7D7DC",
"Apex": "#1797c0",
"Apollo Guidance Computer": "#0B3D91",
"AppleScript": "#101F1F",
"Arc": "#ca2afe",
"Arduino": "#bd79d1",
"AspectJ": "#1957b0",
"Assembly": "#6E4C13",
"Asymptote": "#ff0000",
"Augeas": "#62331f",
"AutoHotkey": "#6594b9", "AutoHotkey": "#6594b9",
"AutoIt": "#1C3552", "AutoIt": "#36699B",
"Ballerina": "#FF5000", "Ballerina": "#FF5000",
"Batchfile": "#C1F12E", "Batchfile": "#C1F12E",
"Beef": "#a52f4e",
"Bison": "#6A463F",
"Blade": "#f7523f",
"BlitzMax": "#cd6400", "BlitzMax": "#cd6400",
"Boo": "#d4bec1", "Boo": "#d4bec1",
"Boogie": "#c80fa0",
"Brainfuck": "#2F2530", "Brainfuck": "#2F2530",
"Browserslist": "#ffd539",
"C": "#555555", "C": "#555555",
"C#": "#178600", "C Sharp": "#178600",
"C#": "#5a25a2",
"C++": "#f34b7d", "C++": "#f34b7d",
"CSON": "#244776",
"CSS": "#563d7c",
"Ceylon": "#dfa535", "Ceylon": "#dfa535",
"Chapel": "#8dc63f", "Chapel": "#8dc63f",
"Cirru": "#ccccff", "Cirru": "#aaaaff",
"Clarion": "#db901e", "Clarion": "#db901e",
"Clean": "#3F85AF", "Classic ASP": "#6a40fd",
"Clean": "#3a81ad",
"Click": "#E4E6F3", "Click": "#E4E6F3",
"Clojure": "#db5855", "Clojure": "#db5855",
"Closure Templates": "#0d948f",
"CoffeeScript": "#244776", "CoffeeScript": "#244776",
"ColdFusion": "#ed2cd6", "ColdFusion": "#ed2cd6",
"ColdFusion CFC": "#ed2cd6",
"Common Lisp": "#3fb68b", "Common Lisp": "#3fb68b",
"Common Workflow Language": "#B5314C", "Common Workflow Language": "#B5314C",
"Component Pascal": "#B0CE4E", "Component Pascal": "#b0ce4e",
"Crystal": "#000100", "Crystal": "#000100",
"CSS": "#563d7c",
"Cuda": "#3A4E3A", "Cuda": "#3A4E3A",
"D": "#ba595e", "D": "#fcd46d",
"Dart": "#00B4AB", "DM": "#075ff1",
"Dafny": "#FFEC25",
"Dart": "#98BAD6",
"DataWeave": "#003a52", "DataWeave": "#003a52",
"DM": "#447265", "Denizen": "#faf094",
"Dhall": "#dfafff",
"Dockerfile": "#384d54", "Dockerfile": "#384d54",
"Docker": "#384d54",
"Dogescript": "#cca760", "Dogescript": "#cca760",
"Dylan": "#6c616e", "Dylan": "#3ebc27",
"E": "#ccce35", "E": "#ccce35",
"eC": "#913960",
"ECL": "#8a1267", "ECL": "#8a1267",
"EJS": "#a91e50",
"EQ": "#a78649",
"Eagle": "#3994bc",
"Eiffel": "#946d57", "Eiffel": "#946d57",
"Elixir": "#6e4a7e", "Elixir": "#6e4a7e",
"Elm": "#60B5CC", "Elm": "#60B5CC",
"Emacs Lisp": "#c065db", "Emacs Lisp": "#c065db",
"EmberScript": "#FFF4F3", "EmberScript": "#f64e3e",
"EQ": "#a78649", "Erlang": "#0faf8d",
"Erlang": "#B83998",
"F#": "#b845fc", "F#": "#b845fc",
"F*": "#572e30", "F*": "#572e30",
"FLUX": "#33CCFF",
"FORTRAN": "#4d41b1",
"Factor": "#636746", "Factor": "#636746",
"Fancy": "#7b9db4", "Fancy": "#7b9db4",
"Fantom": "#14253c", "Fantom": "#dbded5",
"FLUX": "#88ccff", "Faust": "#c37240",
"Forth": "#341708", "Forth": "#341708",
"Fortran": "#4d41b1", "Fortran": "#4d41b1",
"FreeMarker": "#0050b2", "FreeMarker": "#0050b2",
"Frege": "#00cafe", "Frege": "#00cafe",
"Game Maker Language": "#71b417", "Futhark": "#5f021f",
"G-code": "#D08CF2",
"GAML": "#FFC766",
"GDScript": "#355570", "GDScript": "#355570",
"Game Maker Language": "#8ad353",
"Genie": "#fb855d", "Genie": "#fb855d",
"Gherkin": "#5B2063", "Gherkin": "#5B2063",
"Glyph": "#c1ac7f", "Glyph": "#e4cc98",
"Gnuplot": "#f0a9f0", "Gnuplot": "#f0a9f0",
"Go": "#00ADD8", "Go": "#375eab",
"Golo": "#88562A", "Golo": "#f6a51f",
"Gosu": "#82937f", "Gosu": "#82937f",
"Grammatical Framework": "#79aa7a", "Grammatical Framework": "#ff0000",
"GraphQL": "#e10098",
"Groovy": "#e69f56", "Groovy": "#e69f56",
"HTML": "#e44b23",
"Hack": "#878787", "Hack": "#878787",
"Haml": "#ece2a9",
"Handlebars": "#f7931e",
"Harbour": "#0e60e3", "Harbour": "#0e60e3",
"Haskell": "#5e5086", "Haskell": "#29b544",
"Haxe": "#df7900", "Haxe": "#f7941e",
"HiveQL": "#dce200", "HiveQL": "#dce200",
"HTML": "#e34c26", "HolyC": "#ffefaf",
"Hy": "#7790B2", "Hy": "#7891b1",
"IDL": "#a3522f", "IDL": "#e3592c",
"IGOR Pro": "#0000cc",
"Idris": "#b30000", "Idris": "#b30000",
"ImageJ Macro": "#99AAFF",
"Io": "#a9188d", "Io": "#a9188d",
"Ioke": "#078193", "Ioke": "#078193",
"Isabelle": "#FEFE00", "Isabelle": "#fdcd00",
"J": "#9EEDFF", "J": "#9EEDFF",
"JFlex": "#DBCA00",
"JSONiq": "#40d47e",
"Java": "#b07219", "Java": "#b07219",
"JavaScript": "#f1e05a", "JavaScript": "#f1e05a",
"Jolie": "#843179", "Jolie": "#843179",
"JSONiq": "#40d47e",
"Jsonnet": "#0064bd", "Jsonnet": "#0064bd",
"Julia": "#a270ba", "Julia": "#a270ba",
"Jupyter Notebook": "#DA5B0B", "Jupyter Notebook": "#DA5B0B",
"KRL": "#f5c800",
"Kaitai Struct": "#773b37",
"Kotlin": "#F18E33", "Kotlin": "#F18E33",
"KRL": "#28430A", "LFE": "#004200",
"Lasso": "#999999",
"Lex": "#DBCA00",
"LFE": "#4C3023",
"LiveScript": "#499886",
"LLVM": "#185619", "LLVM": "#185619",
"LOLCODE": "#cc9900", "LOLCODE": "#cc9900",
"LookML": "#652B81",
"LSL": "#3d9970", "LSL": "#3d9970",
"Lua": "#000080", "Lark": "#0b130f",
"Makefile": "#427819", "Lasso": "#2584c3",
"Mask": "#f97732", "Latte": "#A8FF97",
"Less": "#1d365d",
"Lex": "#DBCA00",
"Liquid": "#67b8de",
"LiveScript": "#499886",
"LookML": "#652B81",
"Lua": "#fa1fa1",
"MATLAB": "#e16737", "MATLAB": "#e16737",
"Max": "#c4a79c",
"MAXScript": "#00a6a6", "MAXScript": "#00a6a6",
"mcfunction": "#E22837", "MLIR": "#5EC8DB",
"Mercury": "#ff2b2b", "MQL4": "#62A8D6",
"MQL5": "#4A76B8",
"MTML": "#0095d9",
"Macaulay2": "#d8ffff",
"Makefile": "#427819",
"Markdown": "#083fa1",
"Marko": "#42bff2",
"Mask": "#f97732",
"Matlab": "#bb92ac",
"Max": "#ce279c",
"Mercury": "#abcdef",
"Meson": "#007800", "Meson": "#007800",
"Metal": "#8f14e9", "Metal": "#8f14e9",
"Mirah": "#c7a938", "Mirah": "#c7a938",
"Modula-3": "#223388", "Modula-3": "#223388",
"MQL4": "#62A8D6", "Mustache": "#724b3b",
"MQL5": "#4A76B8",
"MTML": "#b7e1f4",
"NCL": "#28431f", "NCL": "#28431f",
"NWScript": "#111522",
"Nearley": "#990000", "Nearley": "#990000",
"Nemerle": "#3d3c6e", "Nemerle": "#0d3c6e",
"nesC": "#94B0C7",
"NetLinx": "#0aa0ff", "NetLinx": "#0aa0ff",
"NetLinx+ERB": "#747faa", "NetLinx+ERB": "#747faa",
"NetLogo": "#ff6375", "NetLogo": "#ff2b2b",
"NewLisp": "#87AED7", "NewLisp": "#eedd66",
"Nextflow": "#3ac486", "Nextflow": "#3ac486",
"Nim": "#37775b", "Nim": "#ffc200",
"Nit": "#009917", "Nimrod": "#37775b",
"Nix": "#7e7eff", "Nit": "#0d8921",
"Nix": "#7070ff",
"Nu": "#c9df40", "Nu": "#c9df40",
"Objective-C": "#438eff", "NumPy": "#9C8AF9",
"Objective-C++": "#6866fb", "Nunjucks": "#3d8137",
"Objective-J": "#ff0c5a",
"OCaml": "#3be133", "OCaml": "#3be133",
"ObjectScript": "#424893",
"Objective-C": "#438eff",
"Objective-C++": "#4886FC",
"Objective-J": "#ff0c5a",
"Odin": "#60AFFE",
"Omgrofl": "#cabbff", "Omgrofl": "#cabbff",
"ooc": "#b0b77e",
"Opal": "#f7ede0", "Opal": "#f7ede0",
"Oxygene": "#cdd0e3", "OpenQASM": "#AA70FF",
"Oz": "#fab738", "Org": "#77aa99",
"Oxygene": "#5a63a3",
"Oz": "#fcaf3e",
"P4": "#7055b5", "P4": "#7055b5",
"PAWN": "#dbb284",
"PHP": "#4F5D95",
"PLSQL": "#dad8d8",
"Pan": "#cc0000", "Pan": "#cc0000",
"Papyrus": "#6600cc", "Papyrus": "#6600cc",
"Parrot": "#f3ca0a", "Parrot": "#f3ca0a",
"Pascal": "#E3F171", "Pascal": "#b0ce4e",
"Pawn": "#dbb284", "Pawn": "#dbb284",
"Pep8": "#C76F5B", "Pep8": "#C76F5B",
"Perl": "#0298c3", "Perl": "#0298c3",
"Perl 6": "#0000fb", "Perl6": "#0298c3",
"PHP": "#4F5D95",
"PigLatin": "#fcd7de", "PigLatin": "#fcd7de",
"Pike": "#005390", "Pike": "#066ab2",
"PLSQL": "#dad8d8",
"PogoScript": "#d80074", "PogoScript": "#d80074",
"PostScript": "#da291c", "PostScript": "#da291c",
"PowerBuilder": "#8f0f8d", "PowerBuilder": "#8f0f8d",
"PowerShell": "#012456", "PowerShell": "#012456",
"Processing": "#0096D8", "Prisma": "#0c344b",
"Processing": "#2779ab",
"Prolog": "#74283c", "Prolog": "#74283c",
"Propeller Spin": "#7fa2a7", "Propeller Spin": "#2b446d",
"Puppet": "#302B6D", "Pug": "#a86454",
"Puppet": "#cc5555",
"Pure Data": "#91de79",
"PureBasic": "#5a6986", "PureBasic": "#5a6986",
"PureScript": "#1D222D", "PureScript": "#bcdc53",
"Python": "#3572A5", "Python": "#3581ba",
"q": "#0040cd", "Q#": "#fed659",
"QML": "#44a51c", "QML": "#44a51c",
"Qt Script": "#00b841",
"Quake": "#882233", "Quake": "#882233",
"R": "#198CE7", "R": "#198ce7",
"Racket": "#3c5caa",
"Ragel": "#9d5200",
"RAML": "#77d9fb", "RAML": "#77d9fb",
"RUNOFF": "#665a4e",
"Racket": "#ae17ff",
"Ragel": "#9d5200",
"Ragel in Ruby Host": "#ff9c2e",
"Raku": "#0000fb",
"Rascal": "#fffaa0", "Rascal": "#fffaa0",
"ReScript": "#ed5051",
"Reason": "#ff5847",
"Rebol": "#358a5b", "Rebol": "#358a5b",
"Red": "#f50000", "Record Jar": "#0673ba",
"Red": "#ee0000",
"Ren'Py": "#ff7f7f", "Ren'Py": "#ff7f7f",
"Ring": "#2D54CB", "Ring": "#2D54CB",
"Riot": "#A71E49",
"Roff": "#ecdebe", "Roff": "#ecdebe",
"Rouge": "#cc0088", "Rouge": "#cc0088",
"Ruby": "#701516", "Ruby": "#701516",
"RUNOFF": "#665a4e",
"Rust": "#dea584", "Rust": "#dea584",
"SAS": "#1E90FF",
"SCSS": "#c6538c",
"SQF": "#FFCB1F",
"SRecode Template": "#348a34",
"SVG": "#ff9900",
"SaltStack": "#646464", "SaltStack": "#646464",
"SAS": "#B34936", "Sass": "#a53b70",
"Scala": "#c22d40", "Scala": "#7dd3b0",
"Scaml": "#bd181a",
"Scheme": "#1e4aec", "Scheme": "#1e4aec",
"sed": "#64b970",
"Self": "#0579aa", "Self": "#0579aa",
"Shell": "#89e051", "Shell": "#5861ce",
"Shen": "#120F14", "Shen": "#120F14",
"Slash": "#007eff", "Slash": "#007eff",
"Slice": "#003fa2", "Slice": "#003fa2",
"Slim": "#ff8877",
"SmPL": "#c94949",
"Smalltalk": "#596706", "Smalltalk": "#596706",
"Solidity": "#AA6746", "Solidity": "#AA6746",
"SourcePawn": "#5c7611", "SourcePawn": "#f69e1d",
"SQF": "#3F3F3F",
"Squirrel": "#800000", "Squirrel": "#800000",
"SRecode Template": "#348a34",
"Stan": "#b2011d", "Stan": "#b2011d",
"Standard ML": "#dc566d", "Standard ML": "#dc566d",
"Starlark": "#76d275",
"Stylus": "#ff6347",
"SuperCollider": "#46390b", "SuperCollider": "#46390b",
"Svelte": "#ff3e00", "Svelte": "#ff3e00",
"Swift": "#ffac45", "Swift": "#ffac45",
"SystemVerilog": "#DAE1C2", "SystemVerilog": "#343761",
"Tcl": "#e4cc98",
"Terra": "#00004c",
"TeX": "#3D6117",
"TI Program": "#A0AA87", "TI Program": "#A0AA87",
"Turing": "#cf142b", "Tcl": "#e4cc98",
"TypeScript": "#2b7489", "TeX": "#3D6117",
"Terra": "#00004c",
"Turing": "#45f715",
"Twig": "#c1d026",
"TypeScript": "#31859c",
"Unified Parallel C": "#755223",
"Uno": "#9933cc",
"UnrealScript": "#a54c4d", "UnrealScript": "#a54c4d",
"Vala": "#fbe5cd", "V": "#4f87c4",
"VCL": "#148AA8", "VBA": "#867db1",
"Verilog": "#b2b7f8", "VBScript": "#15dcdc",
"VHDL": "#adb2cb", "VCL": "#0298c3",
"VHDL": "#543978",
"Vala": "#ee7d06",
"Verilog": "#848bf3",
"Vim script": "#199f4b", "Vim script": "#199f4b",
"VimL": "#199c4b",
"Visual Basic": "#945db7", "Visual Basic": "#945db7",
"Volt": "#1F1F1F", "Visual Basic .NET": "#945db7",
"Volt": "#0098db",
"Vue": "#2c3e50", "Vue": "#2c3e50",
"wdl": "#42f1f4", "Web Ontology Language": "#3994bc",
"WebAssembly": "#04133b", "WebAssembly": "#04133b",
"wisp": "#7582D1", "Wollok": "#a23738",
"X10": "#4B6BEF", "X10": "#4B6BEF",
"xBase": "#403a40",
"XC": "#99DA07", "XC": "#99DA07",
"XQuery": "#5232e7", "XQuery": "#2700e2",
"XSLT": "#EB8CEB", "XSLT": "#EB8CEB",
"Yacc": "#4B6C4B", "YAML": "#cb171e",
"YARA": "#220000", "YARA": "#220000",
"YASnippet": "#32AB90", "YASnippet": "#32AB90",
"Yacc": "#4B6C4B",
"ZAP": "#0d665e", "ZAP": "#0d665e",
"ZIL": "#dc75e5",
"ZenScript": "#00BCD1",
"Zephir": "#118f9e", "Zephir": "#118f9e",
"Zig": "#ec915c", "Zig": "#ec915c",
"ZIL": "#dc75e5" "cpp": "#f34b7d",
"eC": "#913960",
"edn": "#db5855",
"mIRC Script": "#3d57c3",
"mcfunction": "#E22837",
"nesC": "#ffce3b",
"ooc": "#b0b77e",
"q": "#0040cd",
"sed": "#64b970",
"wdl": "#42f1f4",
"wisp": "#7582D1",
"xBase": "#3a4040",
"Other": "#1f9aef"
}, },
"editors": { "editors": {
"Adobe XD": "#fd27bc",
"Android Studio": "#99cd00", "Android Studio": "#99cd00",
"AppCode": "#04dbde", "AppCode": "#04dbde",
"Aptana": "#ec8623", "Aptana": "#ec8623",
"Atom": "#49b77e", "Atom": "#49b77e",
"Azure Data Studio": "#0271c6", "Azure Data Studio": "#0271c6",
"Blender": "#fb8007", "Blender": "#fb8007",
"BlueJ": "#5d89af",
"Brackets": "#067dc3", "Brackets": "#067dc3",
"Chrome": "#fdd308", "Chrome": "#fdd308",
"CLion": "#14c9a5", "CLion": "#14c9a5",
"Cloud9": "#25a6d9", "Cloud9": "#25a6d9",
"Coda": "#3e8e1c", "Coda": "#3e8e1c",
"Code: :Blocks": "#d0ce71",
"Code::Blocks": "#d0ce71",
"CodeLite": "#1892e5",
"CodeTasty": "#7368a8", "CodeTasty": "#7368a8",
"DataGrip": "#907cf2", "DataGrip": "#907cf2",
"DBeaver": "#897363", "DBeaver": "#897363",
"Eclipse": "#443582", "Eclipse": "#443582",
"Emacs": "#8c76c3", "Emacs": "#8c76c3",
"Embarcadero Delphi": "#d9242a",
"EmEditor": "#ed3103",
"Eric": "#423f13", "Eric": "#423f13",
"Excel": "#0f753c", "Excel": "#0f753c",
"Figma": "#c7b9ff",
"Firefox": "#d96527",
"Flash Builder": "#aca3a4", "Flash Builder": "#aca3a4",
"Geany": "#fbec75",
"Gedit": "#872114", "Gedit": "#872114",
"GoLand": "#bd4ffc", "GoLand": "#bd4ffc",
"HBuilder X": "#1ba334", "HBuilder X": "#1ba334",
"IntelliJ IDEA": "#237ce2", "IntelliJ IDEA": "#2876e1",
"IntelliJ": "#237ce2", "IntelliJ": "#2876e1",
"Kakoune": "#dd5f4a", "Kakoune": "#dd5f4a",
"Kate": "#3f4040", "Kate": "#3f4040",
"KDevelop": "#22a273",
"Komodo": "#fcb414", "Komodo": "#fcb414",
"Light Table": "#007ac1",
"MacRabbit Espresso": "#e6593f",
"Micro": "#2c3494", "Micro": "#2c3494",
"MonoDevelop": "#6185b3", "MonoDevelop": "#6185b3",
"MySQL Workbench": "#245279",
"Neovim": "#068304",
"NetBeans": "#f1f6e2", "NetBeans": "#f1f6e2",
"Notepad++": "#9ecf54", "Notepad++": "#9ecf54",
"Nova": "#ff054a", "Nova": "#ff054a",
"Onivim": "#ee848e", "Onivim": "#ee848e",
"Photoshop": "#0a0054",
"PhpStorm": "#d93ac1", "PhpStorm": "#d93ac1",
"PowerPoint": "#c6421f", "PowerPoint": "#c6421f",
"Processing": "#6a7152", "Processing": "#6a7152",
"PyCharm": "#d2ee5c", "PyCharm": "#d2ee5c",
"Pymakr": "#323d4f", "Pymakr": "#323d4f",
"QtCreator": "#7fc342",
"Rider": "#f7a415", "Rider": "#f7a415",
"RStudio": "#2369c7",
"RubyMine": "#ff6336", "RubyMine": "#ff6336",
"Sketch": "#fdad00", "Sketch": "#fdad00",
"SlickEdit": "#57ca57", "SlickEdit": "#57ca57",
"Spyder": "#ee181e",
"SQL Server Management Studio": "#ffb901", "SQL Server Management Studio": "#ffb901",
"Sublime Text": "#ff9800", "Sublime Text": "#ff9800",
"Terminal": "#133f1c", "Terminal": "#133f1c",
@ -297,29 +402,17 @@
"Visual Studio": "#9460cd", "Visual Studio": "#9460cd",
"VS Code": "#027acd", "VS Code": "#027acd",
"VSCode": "#027acd", "VSCode": "#027acd",
"WebMatrix": "#aeaeae",
"WebStorm": "#00c6d7", "WebStorm": "#00c6d7",
"Wing": "#b3b3b3",
"Word": "#0f4091", "Word": "#0f4091",
"WPS Office": "#fc6143", "WPS Office": "#fc6143",
"Xamarin": "#3598db", "Xamarin": "#3598db",
"Xcode": "#3fa7e4", "Xcode": "#3fa7e4"
"Adobe XD": "#fd27bc",
"Code::Blocks": "#d0ce71",
"Embarcadero Delphi": "#d9242a",
"EmEditor": "#ed3103",
"Figma": "#c7b9ff",
"Firefox": "#d96527",
"Geany": "#fbec75",
"Light Table": "#007ac1",
"MacRabbit Espresso": "#e6593f",
"MySQL Workbench": "#245279",
"Photoshop": "#0a0054",
"QtCreator": "#7fc342",
"RStudio": "#2369c7",
"WebMatrix": "#aeaeae"
}, },
"operating_systems": { "operating_systems": {
"Linux": "#f0b912", "Linux": "#f0b912",
"Windows": "#00b7ee", "Windows": "#00b7ee",
"Mac": "#4d66cb" "Mac": "#4d66cb"
} }
} }

View File

@ -22,3 +22,8 @@ services:
POSTGRES_USER: "wakapi" POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "choose-a-password" POSTGRES_PASSWORD: "choose-a-password"
POSTGRES_DB: "wakapi" POSTGRES_DB: "wakapi"
volumes:
- wakapi-db-data:/var/lib/postgresql/data
volumes:
wakapi-db-data: {}

View File

@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
./wakapi exec ./wakapi
else else
echo "Waiting for database to come up" echo "Waiting for database to come up"
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi exec ./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
fi fi

26
etc/Caddyfile Normal file
View File

@ -0,0 +1,26 @@
wakapi.yourdomain.tld {
encode zstd gzip
header {
Strict-Transport-Security "max-age=2592000; includeSubDomains"
}
log {
output file /var/log/caddy/wakapi.dev.access.log
format single_field common_log
}
reverse_proxy http://[::1]:3000
@api path_regexp "^/api.*"
@notapi not path_regexp "^/api.*"
push @notapi /assets/vendor/source-sans-3.css
push @notapi /assets/css/app.dist.css
push @notapi /assets/vendor/petite-vue.min.js
push @notapi /assets/vendor/chart.min.js
push @notapi /assets/vendor/iconify.basic.min.js
push @notapi /assets/js/icons.dist.js
push @notapi /assets/js/base.js
push @notapi /assets/images/logo.svg
}

53
etc/wakapi.service Normal file
View File

@ -0,0 +1,53 @@
[Unit]
Description=Wakapi
StartLimitIntervalSec=400
StartLimitBurst=3
# Optional, in case you're running MySQL / Postgres with Systemd, too
Requires=mysql.service
After=mysql.service
[Service]
Type=simple
# Assuming Wakapi executable is under /opt/wakapi and config file at /etc
# Feel free to change this
WorkingDirectory=/opt/wakapi
ExecStart=/opt/wakapi/wakapi -config /etc/wakapi.yml
# Environment variables, see README for more
Environment=WAKAPI_DB_HOST=localhost
Environment=WAKAPI_DB_USER=wakapi
Environment=WAKAPI_DB_NAME=wakapi
Environment=WAKAPI_DB_PASSWORD=secretpassword
Environment=WAKAPI_PASSWORD_SALT=somerandomstring
# TODO: Use Systemd's credentials management (https://systemd.io/CREDENTIALS/) introduced in v247 (%d syntax in v250) once more established
# sudo groupadd wakapi
# sudo useradd -g wakapi wakapi
User=wakapi
Group=wakapi
Restart=on-failure
RestartSec=90
# Security hardening
PrivateTmp=true
PrivateUsers=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
PrivateDevices=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ProtectClock=true
RestrictSUIDSGID=true
ProtectHostname=true
ProtectProc=invisible
[Install]
WantedBy=multi-user.target

92
go.mod
View File

@ -1,40 +1,84 @@
module github.com/muety/wakapi module github.com/muety/wakapi
go 1.16 go 1.19
require ( require (
github.com/BurntSushi/toml v0.4.1 // indirect codeberg.org/Codeberg/avatars v1.0.0
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/duke-git/lancet/v2 v2.1.10
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0 github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.2.0 github.com/emvi/logbuch v1.2.0
github.com/felixge/httpsnoop v1.0.2 // indirect github.com/getsentry/sentry-go v0.15.0
github.com/getsentry/sentry-go v0.11.0 github.com/glebarez/sqlite v1.5.0
github.com/go-co-op/gocron v1.11.0
github.com/go-kit/kit v0.10.0 // indirect
github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.5.1 github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0 github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/jackc/pgx/v4 v4.14.1 // indirect github.com/hashicorp/golang-lru v0.5.4
github.com/jinzhu/configor v1.2.1 github.com/jinzhu/configor v1.2.1
github.com/jinzhu/now v1.1.4 // indirect
github.com/leandro-lugaresi/hub v1.1.1 github.com/leandro-lugaresi/hub v1.1.1
github.com/mailru/easyjson v0.7.7 // indirect github.com/lpar/gzipped/v2 v2.1.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.8.0
github.com/swaggo/swag v1.7.0 github.com/swaggo/http-swagger v1.3.3
go.uber.org/atomic v1.9.0 github.com/swaggo/swag v1.8.8
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b go.uber.org/atomic v1.10.0
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/crypto v0.3.0
golang.org/x/tools v0.1.0 // indirect golang.org/x/sync v0.1.0
gorm.io/driver/mysql v1.2.1 gorm.io/driver/mysql v1.4.4
gorm.io/driver/postgres v1.2.3 gorm.io/driver/postgres v1.4.5
gorm.io/driver/sqlite v1.2.6 gorm.io/driver/sqlite v1.4.3
gorm.io/gorm v1.22.4 gorm.io/gorm v1.24.2
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/glebarez/go-sqlite v1.19.5 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.7 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.13.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/pgx/v4 v4.17.2 // 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.16 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/stripe/stripe-go/v74 v74.3.0 // indirect
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/net v0.2.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/tools v0.3.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.20.0 // indirect
) )

697
go.sum
View File

@ -1,220 +1,85 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/duke-git/lancet/v2 v2.1.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs= github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM= github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc= github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/glebarez/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8= github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo= github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v1.6.2 h1:x5g1tWnWcXIZesdosJJcbziRi4XG6tKB92yKLUpoBkU=
github.com/go-co-op/gocron v1.6.2/go.mod h1:DbJm9kdgr1sEvWpHCA7dFFs/PGHPMil9/97EXCRPr4k=
github.com/go-co-op/gocron v1.11.0 h1:ujOMubCpGcTxnnR/9vJIPIEpgwuAjbueAYqJRNr+nHg=
github.com/go-co-op/gocron v1.11.0/go.mod h1:qtlsoMpHlSdIZ3E/xuZzrrAbeX3u5JtPvWf2TcdutU0=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA= github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@ -222,17 +87,11 @@ github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgO
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.0 h1:4EYhlDVEMsJ30nNj0mmgwIUXoq7e9sMJrVC2ED6QlCU= github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.10.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgconn v1.10.1 h1:DzdIHIjG1AxGwoEEqS+mGsURyjt4enSmqzACXvVzOT8=
github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@ -241,298 +100,140 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns= github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.8.1 h1:9k0IXtdJXHJbyAWQgbWr1lU+MEhPXZz6RIXxfR5oxXs= github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.8.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.9.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.9.1 h1:MJc2s0MFS8C3ok1wQTdQxWuXQcB6+HwAm5x1CzW7mf0=
github.com/jackc/pgtype v1.9.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.13.0 h1:JCjhT5vmhMAf/YwBHLvrBn4OGdIQBiFG6ym8Zmdx570= github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
github.com/jackc/pgx/v4 v4.13.0/go.mod h1:9P4X524sErlaxj0XSGZk7s+LD0eOyu1ZDUrrpznYDF0= github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8=
github.com/jackc/pgx/v4 v4.14.1 h1:71oo1KAGI6mXhLiTMn6iDFcp3e7+zon/capWjl2OEFU=
github.com/jackc/pgx/v4 v4.14.1/go.mod h1:RgDuE4Z34o7XE92RpLsvFiOEfrAUT0Xt2KxvX73W06M=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8= github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8=
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo= github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lpar/gzipped/v2 v2.1.0 h1:87/ug239roEqXLVOnXZg6NjDfFvMwmkGTKnFWJPUA9U=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lpar/gzipped/v2 v2.1.0/go.mod h1:G3UlFoFYzjCx6NV4zDmD1BIWMNBaJuKoUvxrEWJuZ3Y=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 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 h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/stripe/stripe-go/v74 v74.3.0 h1:8ymGwZvMnpWMCRNomc9dVGcJ5j8L/ubwhQvpIpcmcOA=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/stripe/stripe-go/v74 v74.3.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/swaggo/swag v1.8.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
@ -540,215 +241,161 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/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 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.1.1 h1:yr1bpyqiwuSPJ4aGGUX9nu46RHXlF8RASQVb1QQNcvo= gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
gorm.io/driver/mysql v1.1.1/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU= gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
gorm.io/driver/mysql v1.2.1 h1:h+3f1l9Ng2C072Y2tIiLgPpWN78r1KXL7bHJ0nTjlhU= gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/mysql v1.2.1/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo= gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/driver/postgres v1.1.0 h1:afBljg7PtJ5lA6YUWluV2+xovIPhS+YiInuL3kUjrbk= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/postgres v1.1.0/go.mod h1:hXQIwafeRjJvUm+OMxcFWyswJ/vevcpPLlGocwAwuqw= gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4= gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.9/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.21.12 h1:3fQM0Eiz7jcJEhPggHEpoYnsGZqynMzverL77DV40RM=
gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM=
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
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.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
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.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
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 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
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.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.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.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

View File

@ -1,9 +1,8 @@
package utils package helpers
import ( import (
"errors" "fmt"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"regexp"
"time" "time"
) )
@ -41,15 +40,10 @@ func FormatDateHuman(date time.Time) string {
return date.Format("Mon, 02 Jan 2006") return date.Format("Mon, 02 Jan 2006")
} }
func Add(i, j int) int { func FmtWakatimeDuration(d time.Duration) string {
return i + j d = d.Round(time.Minute)
} h := d / time.Hour
d -= h * time.Hour
func ParseUserAgent(ua string) (string, string, error) { m := d / time.Minute
re := regexp.MustCompile(`(?iU)^wakatime\/v?[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`) return fmt.Sprintf("%d hrs %d mins", h, m)
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
} }

4
helpers/helpers.go Normal file
View File

@ -0,0 +1,4 @@
package helpers
// helpers are different from utils in that they contain wakapi-specific utility functions
// also, helpers may depend on the config package, while utils must be entirely static

30
helpers/http.go Normal file
View File

@ -0,0 +1,30 @@
package helpers
import (
"encoding/json"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"net/http"
)
func ExtractCookieAuth(r *http.Request, config *config.Config) (username *string, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &username); err != nil {
return nil, errors.New("cookie is invalid")
}
return username, nil
}
func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(object); err != nil {
config.Log().Request(r).Error("error while writing json response: %v", err)
}
}

View File

@ -1,12 +1,85 @@
package utils package helpers
import ( import (
"errors" "errors"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"time" "time"
) )
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := extractUser(r)
params := r.URL.Query()
var err error
var from, to time.Time
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
} else {
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
if err != nil {
return nil, errors.New("missing or invalid 'from' parameter")
}
to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
if err != nil {
return nil, errors.New("missing or invalid 'to' parameter")
}
}
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
filters := ParseSummaryFilters(r)
return &models.SummaryParams{
From: from,
To: to,
User: user,
Recompute: recompute,
Filters: filters,
}, nil
}
func ParseSummaryFilters(r *http.Request) *models.Filters {
filters := &models.Filters{}
if q := r.URL.Query().Get("project"); q != "" {
filters.With(models.SummaryProject, q)
}
if q := r.URL.Query().Get("language"); q != "" {
filters.With(models.SummaryLanguage, q)
}
if q := r.URL.Query().Get("editor"); q != "" {
filters.With(models.SummaryEditor, q)
}
if q := r.URL.Query().Get("machine"); q != "" {
filters.With(models.SummaryMachine, q)
}
if q := r.URL.Query().Get("operating_system"); q != "" {
filters.With(models.SummaryOS, q)
}
if q := r.URL.Query().Get("label"); q != "" {
filters.With(models.SummaryLabel, q)
}
if q := r.URL.Query().Get("branch"); q != "" {
filters.With(models.SummaryBranch, q)
}
return filters
}
func extractUser(r *http.Request) *models.User {
type principalGetter interface {
GetPrincipal() *models.User
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal()
}
return nil
}
func ParseInterval(interval string) (*models.IntervalKey, error) { func ParseInterval(interval string) (*models.IntervalKey, error) {
for _, i := range models.AllIntervals { for _, i := range models.AllIntervals {
if i.HasAlias(interval) { if i.HasAlias(interval) {
@ -35,31 +108,33 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
switch interval { switch interval {
case models.IntervalToday: case models.IntervalToday:
from = StartOfToday(tz) from = utils.BeginOfToday(tz)
case models.IntervalYesterday: case models.IntervalYesterday:
from = StartOfToday(tz).Add(-24 * time.Hour) from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
to = StartOfToday(tz) to = utils.BeginOfToday(tz)
case models.IntervalThisWeek: case models.IntervalThisWeek:
from = StartOfThisWeek(tz) from = utils.BeginOfThisWeek(tz)
case models.IntervalLastWeek: case models.IntervalLastWeek:
from = StartOfThisWeek(tz).AddDate(0, 0, -7) from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = StartOfThisWeek(tz) to = utils.BeginOfThisWeek(tz)
case models.IntervalThisMonth: case models.IntervalThisMonth:
from = StartOfThisMonth(tz) from = utils.BeginOfThisMonth(tz)
case models.IntervalLastMonth: case models.IntervalLastMonth:
from = StartOfThisMonth(tz).AddDate(0, -1, 0) from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
to = StartOfThisMonth(tz) to = utils.BeginOfThisMonth(tz)
case models.IntervalThisYear: case models.IntervalThisYear:
from = StartOfThisYear(tz) from = utils.BeginOfThisYear(tz)
case models.IntervalPast7Days: case models.IntervalPast7Days:
from = now.AddDate(0, 0, -7) from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday: case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7) from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1) to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days: case models.IntervalPast14Days:
from = now.AddDate(0, 0, -14) from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days: case models.IntervalPast30Days:
from = now.AddDate(0, 0, -30) from = now.AddDate(0, 0, -30)
case models.IntervalPast6Months:
from = now.AddDate(0, -6, 0)
case models.IntervalPast12Months: case models.IntervalPast12Months:
from = now.AddDate(0, -12, 0) from = now.AddDate(0, -12, 0)
case models.IntervalAny: case models.IntervalAny:
@ -70,46 +145,3 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
return err, from, to return err, from, to
} }
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := extractUser(r)
params := r.URL.Query()
var err error
var from, to time.Time
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
} else {
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
if err != nil {
return nil, errors.New("missing or invalid 'from' parameter")
}
to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
if err != nil {
return nil, errors.New("missing or invalid 'to' parameter")
}
}
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
return &models.SummaryParams{
From: from,
To: to,
User: user,
Recompute: recompute,
}, nil
}
func extractUser(r *http.Request) *models.User {
type principalGetter interface {
GetPrincipal() *models.User
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal()
}
return nil
}

121
main.go
View File

@ -2,8 +2,6 @@ package main
import ( import (
"embed" "embed"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"io/fs" "io/fs"
"log" "log"
"net" "net"
@ -12,33 +10,43 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/muety/wakapi/static/docs"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/lpar/gzipped/v2"
httpSwagger "github.com/swaggo/http-swagger"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/migrations" "github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/services/mail"
"github.com/muety/wakapi/utils"
"gorm.io/gorm/logger"
"github.com/gorilla/mux"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
"github.com/muety/wakapi/routes/api"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1" shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/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"
"github.com/muety/wakapi/services/mail"
fsutils "github.com/muety/wakapi/utils/fs"
_ "gorm.io/driver/mysql" _ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres" _ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite" _ "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
_ "github.com/muety/wakapi/static/docs"
) )
// Embed version.txt // Embed version.txt
//
//go:embed version.txt //go:embed version.txt
var version string var version string
// Embed static files // Embed static files
//
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
@ -54,8 +62,10 @@ var (
languageMappingRepository repositories.ILanguageMappingRepository languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository summaryRepository repositories.ISummaryRepository
leaderboardRepository *repositories.LeaderboardRepository
keyValueRepository repositories.IKeyValueRepository keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
) )
var ( var (
@ -64,12 +74,15 @@ var (
userService services.IUserService userService services.IUserService
languageMappingService services.ILanguageMappingService languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService projectLabelService services.IProjectLabelService
durationService services.IDurationService
summaryService services.ISummaryService summaryService services.ISummaryService
leaderboardService services.ILeaderboardService
aggregationService services.IAggregationService aggregationService services.IAggregationService
mailService services.IMailService mailService services.IMailService
keyValueService services.IKeyValueService keyValueService services.IKeyValueService
reportService services.IReportService reportService services.IReportService
diagnosticsService services.IDiagnosticsService diagnosticsService services.IDiagnosticsService
housekeepingService services.IHousekeepingService
miscService services.IMiscService miscService services.IMiscService
) )
@ -94,10 +107,12 @@ var (
// @in header // @in header
// @name Authorization // @name Authorization
// @BasePath /api
func main() { func main() {
config = conf.Load(version) config = conf.Load(version)
// Configure Swagger docs
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
// Set log level // Set log level
if config.IsDev() { if config.IsDev() {
logbuch.SetLevel(logbuch.LevelDebug) logbuch.SetLevel(logbuch.LevelDebug)
@ -117,9 +132,11 @@ func main() {
// Connect to database // Connect to database
var err error var err error
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}) logbuch.Info("starting with %s database", config.Db.Dialect)
if config.Db.IsSQLite() { db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}, conf.GetWakapiDBOpts(&config.Db))
db.Exec("PRAGMA foreign_keys = ON;") if err != nil {
logbuch.Error(err.Error())
logbuch.Fatal("could not open database")
} }
if config.IsDev() { if config.IsDev() {
@ -135,7 +152,9 @@ func main() {
defer sqlDb.Close() defer sqlDb.Close()
// Migrate database schema // Migrate database schema
migrations.Run(db, config) if !config.SkipMigrations {
migrations.Run(db, config)
}
// Repositories // Repositories
aliasRepository = repositories.NewAliasRepository(db) aliasRepository = repositories.NewAliasRepository(db)
@ -144,8 +163,10 @@ func main() {
languageMappingRepository = repositories.NewLanguageMappingRepository(db) languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db) projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db) summaryRepository = repositories.NewSummaryRepository(db)
leaderboardRepository = repositories.NewLeaderboardRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db) keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db) diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
// Services // Services
mailService = mail.NewMailService() mailService = mail.NewMailService()
@ -154,17 +175,22 @@ func main() {
languageMappingService = services.NewLanguageMappingService(languageMappingRepository) languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository) projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService) durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
leaderboardService = services.NewLeaderboardService(leaderboardRepository, summaryService, userService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository) keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService) reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository) diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService) housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
// Schedule background tasks // Schedule background tasks
go aggregationService.Schedule() go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime() go leaderboardService.Schedule()
go reportService.Schedule() go reportService.Schedule()
go housekeepingService.Schedule()
go miscService.Schedule()
routes.Init() routes.Init()
@ -172,8 +198,10 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db) healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService) metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService) diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
avatarHandler := api.NewAvatarHandler()
badgeHandler := api.NewBadgeHandler(userService, summaryService)
// Compat Handlers // Compat Handlers
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService) wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
@ -182,11 +210,14 @@ func main() {
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService) wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService) wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService) wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers // MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService) summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
homeHandler := routes.NewHomeHandler(keyValueService) homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService) loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService) imprintHandler := routes.NewImprintHandler(keyValueService)
@ -221,7 +252,9 @@ func main() {
loginHandler.RegisterRoutes(rootRouter) loginHandler.RegisterRoutes(rootRouter)
imprintHandler.RegisterRoutes(rootRouter) imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter) summaryHandler.RegisterRoutes(rootRouter)
leaderboardHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter) settingsHandler.RegisterRoutes(rootRouter)
subscriptionHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter) relayHandler.RegisterRoutes(rootRouter)
// API route registrations // API route registrations
@ -230,37 +263,33 @@ func main() {
heartbeatApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter) metricsHandler.RegisterRoutes(apiRouter)
diagnosticsHandler.RegisterRoutes(apiRouter) diagnosticsHandler.RegisterRoutes(apiRouter)
avatarHandler.RegisterRoutes(apiRouter)
badgeHandler.RegisterRoutes(apiRouter)
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter) wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1UsersHandler.RegisterRoutes(apiRouter) wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter) wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter) shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes // Static Routes
// https://github.com/golang/go/issues/43431 // https://github.com/golang/go/issues/43431
embeddedStatic, _ := fs.Sub(staticFiles, "static") embeddedStatic, _ := fs.Sub(staticFiles, "static")
static := conf.ChooseFS("static", embeddedStatic) static := conf.ChooseFS("static", embeddedStatic)
fileServer := http.FileServer(utils.NeuteredFileSystem{Fs: http.FS(static)})
router.PathPrefix("/contribute.json").Handler(fileServer)
router.PathPrefix("/assets").Handler(fileServer)
router.PathPrefix("/swagger-ui").Handler(fileServer)
router.PathPrefix("/docs").Handler(
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
)
// Miscellaneous assetsFileServer := gzipped.FileServer(fsutils.NewExistsHttpFS(
// Pre-warm projects cache fsutils.NewExistsFS(static).WithCache(!config.IsDev()),
if !config.IsDev() { ))
allUsers, err := userService.GetAll() staticFileServer := http.FileServer(http.FS(
if err == nil { fsutils.NeuteredFileSystem{FS: static},
logbuch.Info("pre-warming user project cache") ))
for _, u := range allUsers {
go heartbeatService.GetEntitySetByUser(models.SummaryProject, u) router.PathPrefix("/contribute.json").Handler(staticFileServer)
} router.PathPrefix("/assets").Handler(assetsFileServer)
} router.Path("/swagger-ui").Handler(http.RedirectHandler("swagger-ui/", http.StatusMovedPermanently)) // https://github.com/swaggo/http-swagger/issues/44
} router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
// Listen HTTP // Listen HTTP
listen(router) listen(router)
@ -270,7 +299,7 @@ func listen(handler http.Handler) {
var s4, s6, sSocket *http.Server var s4, s6, sSocket *http.Server
// IPv4 // IPv4
if config.Server.ListenIpV4 != "" { if config.Server.ListenIpV4 != "-" && config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port) bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{ s4 = &http.Server{
Handler: handler, Handler: handler,
@ -281,7 +310,7 @@ func listen(handler http.Handler) {
} }
// IPv6 // IPv6
if config.Server.ListenIpV6 != "" { if config.Server.ListenIpV6 != "-" && config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port) bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{ s6 = &http.Server{
Handler: handler, Handler: handler,
@ -292,10 +321,10 @@ func listen(handler http.Handler) {
} }
// UNIX domain socket // UNIX domain socket
if config.Server.ListenSocket != "" { if config.Server.ListenSocket != "-" && config.Server.ListenSocket != "" {
// Remove if exists // Remove if exists
if _, err := os.Stat(config.Server.ListenSocket); err == nil { if _, err := os.Stat(config.Server.ListenSocket); err == nil {
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket) logbuch.Info("👉 Removing unix socket %s", config.Server.ListenSocket)
if err := os.Remove(config.Server.ListenSocket); err != nil { if err := os.Remove(config.Server.ListenSocket); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
} }
@ -309,7 +338,7 @@ func listen(handler http.Handler) {
if config.UseTLS() { if config.UseTLS() {
if s4 != nil { if s4 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr) logbuch.Info("👉 Listening for HTTPS on %s... ✅", s4.Addr)
go func() { go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil { if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
@ -317,7 +346,7 @@ func listen(handler http.Handler) {
}() }()
} }
if s6 != nil { if s6 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr) logbuch.Info("👉 Listening for HTTPS on %s... ✅", s6.Addr)
go func() { go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil { if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
@ -325,7 +354,7 @@ func listen(handler http.Handler) {
}() }()
} }
if sSocket != nil { if sSocket != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket) logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
go func() { go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket) unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil { if err != nil {
@ -338,7 +367,7 @@ func listen(handler http.Handler) {
} }
} else { } else {
if s4 != nil { if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr) logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
go func() { go func() {
if err := s4.ListenAndServe(); err != nil { if err := s4.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
@ -346,7 +375,7 @@ func listen(handler http.Handler) {
}() }()
} }
if s6 != nil { if s6 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr) logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
go func() { go func() {
if err := s6.ListenAndServe(); err != nil { if err := s6.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
@ -354,7 +383,7 @@ func listen(handler http.Handler) {
}() }()
} }
if sSocket != nil { if sSocket != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket) logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
go func() { go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket) unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil { if err != nil {

View File

@ -2,6 +2,7 @@ package middlewares
import ( import (
"fmt" "fmt"
"github.com/muety/wakapi/helpers"
"net/http" "net/http"
"strings" "strings"
@ -21,10 +22,11 @@ var (
) )
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
optionalForPaths []string optionalForPaths []string
redirectTarget string // optional redirectTarget string // optional
redirectErrorMessage string // optional
} }
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware { func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
@ -45,6 +47,11 @@ func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMi
return m return m
} }
func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *AuthenticateMiddleware {
m.redirectErrorMessage = message
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler { func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP) m.ServeHTTP(w, r, h.ServeHTTP)
@ -72,7 +79,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized)) w.Write([]byte(conf.ErrUnauthorized))
} else { } else {
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/")) if m.redirectErrorMessage != "" {
session, _ := conf.GetSessionStore().Get(r, conf.SessionKeyDefault)
session.AddFlash(m.redirectErrorMessage, "error")
session.Save(r, w)
}
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey))
http.Redirect(w, r, m.redirectTarget, http.StatusFound) http.Redirect(w, r, m.redirectTarget, http.StatusFound)
} }
return return
@ -121,7 +133,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
} }
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) { func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
username, err := utils.ExtractCookieAuth(r, m.config) username, err := helpers.ExtractCookieAuth(r, m.config)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -3,12 +3,15 @@ package relay
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors"
"fmt" "fmt"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub" "github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"io" "io"
"io/ioutil" "io/ioutil"
@ -18,9 +21,10 @@ import (
const maxFailuresPerDay = 100 const maxFailuresPerDay = 100
/* Middleware to conditionally relay heartbeats to Wakatime */ // WakatimeRelayMiddleware is a middleware to conditionally relay heartbeats to Wakatime (and other compatible services)
type WakatimeRelayMiddleware struct { type WakatimeRelayMiddleware struct {
httpClient *http.Client httpClient *http.Client
hashCache *cache.Cache
failureCache *cache.Cache failureCache *cache.Cache
eventBus *hub.Hub eventBus *hub.Hub
} }
@ -30,6 +34,7 @@ func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
hashCache: cache.New(10*time.Minute, 10*time.Minute),
failureCache: cache.New(24*time.Hour, 1*time.Hour), failureCache: cache.New(24*time.Hour, 1*time.Hour),
eventBus: config.EventBus(), eventBus: config.EventBus(),
} }
@ -44,7 +49,10 @@ func (m *WakatimeRelayMiddleware) Handler(h http.Handler) http.Handler {
func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
defer next(w, r) defer next(w, r)
if r.Method != http.MethodPost { ownInstanceId := config.Get().InstanceId
originInstanceId := r.Header.Get("X-Origin-Instance")
if r.Method != http.MethodPost || originInstanceId == ownInstanceId {
return return
} }
@ -53,10 +61,22 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
return return
} }
err := m.filterByCache(r)
if err != nil {
logbuch.Warn("%v", err)
return
}
body, _ := ioutil.ReadAll(r.Body) body, _ := ioutil.ReadAll(r.Body)
r.Body.Close() r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
// prevent cycles
downstreamInstanceId := ownInstanceId
if originInstanceId != "" {
downstreamInstanceId = originInstanceId
}
headers := http.Header{ headers := http.Header{
"X-Machine-Name": r.Header.Values("X-Machine-Name"), "X-Machine-Name": r.Header.Values("X-Machine-Name"),
"Content-Type": r.Header.Values("Content-Type"), "Content-Type": r.Header.Values("Content-Type"),
@ -65,14 +85,17 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
"X-Origin": []string{ "X-Origin": []string{
fmt.Sprintf("wakapi v%s", config.Get().Version), fmt.Sprintf("wakapi v%s", config.Get().Version),
}, },
"X-Origin-Instance": []string{downstreamInstanceId},
"Authorization": []string{ "Authorization": []string{
fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))), fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(user.WakatimeApiKey))),
}, },
} }
url := user.WakaTimeURL(config.WakatimeApiUrl) + config.WakatimeApiHeartbeatsBulkUrl
go m.send( go m.send(
http.MethodPost, http.MethodPost,
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl, url,
bytes.NewReader(body), bytes.NewReader(body),
headers, headers,
user, user,
@ -82,7 +105,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) { func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
request, err := http.NewRequest(method, url, body) request, err := http.NewRequest(method, url, body)
if err != nil { if err != nil {
logbuch.Warn("error constructing relayed request %v", err) logbuch.Warn("error constructing relayed request - %v", err)
return return
} }
@ -94,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
response, err := m.httpClient.Do(request) response, err := m.httpClient.Do(request)
if err != nil { if err != nil {
logbuch.Warn("error executing relayed request %v", err) logbuch.Warn("error executing relayed request - %v", err)
return return
} }
@ -115,3 +138,59 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
} }
} }
} }
// filterByCache takes an HTTP request, tries to parse the body contents as heartbeats, checks against a local cache for whether a heartbeat has already been relayed before according to its hash and in-place filters these from the request's raw json body.
// This method operates on the raw body data (interface{}), because serialization of models.Heartbeat is not necessarily identical to what the CLI has actually sent.
// Purpose of this mechanism is mainly to prevent cyclic relays / loops.
// Caution: this method does in-place changes to the request.
func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
heartbeats, err := routeutils.ParseHeartbeats(r)
if err != nil {
return err
}
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
var rawData interface{}
if err := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))).Decode(&rawData); err != nil {
return err
}
newData := make([]interface{}, 0, len(heartbeats))
process := func(heartbeat *models.Heartbeat, rawData interface{}) {
heartbeat = heartbeat.Hashed()
// we didn't see this particular heartbeat before
if _, found := m.hashCache.Get(heartbeat.Hash); !found {
m.hashCache.SetDefault(heartbeat.Hash, true)
newData = append(newData, rawData)
}
}
if _, isList := rawData.([]interface{}); isList {
for i, hb := range heartbeats {
process(hb, rawData.([]interface{})[i])
}
} else if len(heartbeats) > 0 {
process(heartbeats[0], rawData.(interface{}))
}
if len(newData) == 0 {
return errors.New("no new heartbeats to relay")
}
if len(newData) != len(heartbeats) {
user := middlewares.GetPrincipal(r)
logbuch.Warn("only relaying %d of %d heartbeats for user %s", len(newData), len(heartbeats), user.ID)
}
buf := bytes.Buffer{}
if err := json.NewEncoder(&buf).Encode(newData); err != nil {
return err
}
r.Body = ioutil.NopCloser(&buf)
return nil
}

View File

@ -20,6 +20,10 @@ func (c *PrincipalContainer) GetPrincipal() *models.User {
return c.principal return c.principal
} }
func (c *PrincipalContainer) GetPrincipalIdentity() string {
return c.principal.Identity()
}
// This middleware is a bit of a dirty workaround to the fact that a http.Request's context // This middleware is a bit of a dirty workaround to the fact that a http.Request's context
// does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a // does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()), // request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),

View File

@ -6,7 +6,7 @@ import (
var securityHeaders = map[string]string{ var securityHeaders = map[string]string{
"Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Opener-Policy": "same-origin",
"Content-Security-Policy": "default-src 'self' 'unsafe-inline'; img-src 'self' https: data:; form-action 'self'; block-all-mixed-content;", "Content-Security-Policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data:; form-action 'self'; block-all-mixed-content;",
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
} }

View File

@ -15,6 +15,8 @@ func init() {
return nil return nil
} }
logbuch.Info("this may take a while!")
if cfg.Db.IsMySQL() { if cfg.Db.IsMySQL() {
tx := db.Begin() tx := db.Begin()
if err := tx.Exec("ALTER TABLE heartbeats MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil { if err := tx.Exec("ALTER TABLE heartbeats MODIFY COLUMN id BIGINT UNSIGNED AUTO_INCREMENT").Error; err != nil {

View File

@ -0,0 +1,48 @@
package migrations
import (
"database/sql"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20212212-total_summary_heartbeats"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
logbuch.Info("this may take a while!")
// this turns out to actually be way faster than using joins and instead has the benefit of being cross-dialect compatible
var summaries []*models.Summary
if err := db.Model(&models.Summary{}).
Select("id, from_time, to_time, user_id").
Scan(&summaries).Error; err != nil {
return err
}
tx := db.Begin()
for _, s := range summaries {
query := "UPDATE summaries SET num_heartbeats = (SELECT count(id) AS num_heartbeats FROM heartbeats WHERE user_id = @user AND time BETWEEN @from AND @to) WHERE id = @id"
tx.Exec(query, sql.Named("from", s.FromTime), sql.Named("to", s.ToTime), sql.Named("id", s.ID), sql.Named("user", s.UserID))
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
logbuch.Error("failed to retroactively determine total summary heartbeats")
return err
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,56 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20220317-align_num_heartbeats"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
logbuch.Info("this may take a while!")
// find all summaries whose num_heartbeats is zero even though they have items
var faultyIds []uint
if err := db.Model(&models.Summary{}).
Distinct("summaries.id").
Joins("INNER JOIN summary_items ON summaries.num_heartbeats = 0 AND summaries.id = summary_items.summary_id").
Scan(&faultyIds).Error; err != nil {
return err
}
// update their heartbeats counter
result := db.
Table("summaries AS s1").
Where("s1.id IN ?", faultyIds).
Update(
"num_heartbeats",
db.
Model(&models.Heartbeat{}).
Select("COUNT(*)").
Where("user_id = ?", gorm.Expr("s1.user_id")).
Where("time BETWEEN ? AND ?", gorm.Expr("s1.from_time"), gorm.Expr("s1.to_time")),
)
if err := result.Error; err != nil {
return err
}
logbuch.Info("corrected heartbeats counter of %d summaries", result.RowsAffected)
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20220318-mysql_timestamp_precision"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if cfg.Db.IsMySQL() {
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
db.Exec("SET foreign_key_checks=0;")
db.Exec("SET unique_checks=0;")
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `time` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `created_at` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
db.Exec("SET foreign_key_checks=1;")
db.Exec("SET unique_checks=1;")
logbuch.Info("migrated timestamp columns to millisecond precision")
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,39 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "202203191-drop_diagnostics_user"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
migrator := db.Migrator()
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
logbuch.Info("running migration '%s'", name)
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
}
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,40 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
// migration to fix https://github.com/muety/wakapi/issues/346
// caused by https://github.com/muety/wakapi/blob/2.3.2/migrations/20220319_add_user_project_idx.go in combination with
// the wrongly defined index at https://github.com/muety/wakapi/blob/5aae18e2415d9e620f383f98cd8cbdf39cd99f27/models/heartbeat.go#L18
// and https://github.com/go-gorm/sqlite/issues/87
// -> drop index and let it be auto-created again with properly formatted ddl
func init() {
const name = "20220403-drop_user_project_idx"
const idxName = "idx_user_project"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if !db.Migrator().HasTable(&models.KeyStringValue{}) || hasRun(name, db) {
return nil
}
if cfg.Db.IsSQLite() && db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
logbuch.Info("running migration '%s'", name)
if err := db.Migrator().DropIndex(&models.Heartbeat{}, idxName); err != nil {
logbuch.Warn("failed to drop %s", idxName)
}
}
setHasRun(name, db)
return nil
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,24 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20220930-drop_heartbeats_entity_idx"
const idxName = "idx_entity"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if !db.Migrator().HasTable(&models.Heartbeat{}) || !db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
return nil
}
return db.Migrator().DropIndex(&models.Heartbeat{}, idxName)
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,88 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"strings"
)
// fix for https://github.com/muety/wakapi/issues/416
func init() {
const name = "20221002-fix_summary_id_types"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if cfg.Db.Dialect != config.SQLDialectMysql {
return nil
}
if !db.Migrator().HasTable(&models.Summary{}) || !db.Migrator().HasTable(&models.SummaryItem{}) {
return nil
}
var currentType string
if err := db.
Table("information_schema.columns").
Select("data_type").
Where("table_name = ?", "summary_items").
Where("column_name = ?", "summary_id").
Limit(1).
Row().Scan(&currentType); err != nil {
return err
}
if strings.ToLower(currentType) != "int" {
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_editors") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_editors"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_languages") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_languages"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_machines") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_machines"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_projects") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_projects"); err != nil {
return err
}
}
// https://github.com/muety/wakapi/issues/416#issuecomment-1271674792
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summary_items_summary") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summary_items_summary"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_labels") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_labels"); err != nil {
return err
}
}
if err := db.Migrator().AlterColumn(&models.Summary{}, "id"); err != nil {
return err
}
if err := db.Migrator().AlterColumn(&models.SummaryItem{}, "summary_id"); err != nil {
return err
}
}
return nil
},
}
registerPreMigration(f)
}

View File

@ -0,0 +1,35 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20221016-drop_rank_column"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
migrator := db.Migrator()
if migrator.HasTable(&models.LeaderboardItem{}) && migrator.HasColumn(&models.LeaderboardItem{}, "rank") {
logbuch.Info("running migration '%s'", name)
if err := migrator.DropColumn(&models.LeaderboardItem{}, "rank"); err != nil {
logbuch.Warn("failed to drop 'rank' column (%v)", err)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,71 @@
package migrations
import (
"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
// if that's the case in the current state of the database, drop the index and let it be recreated by auto migration afterwards
func init() {
const name = "20221028-fix_heartbeats_time_user_idx"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasTable(&models.Heartbeat{}) {
return nil
}
var drop bool
if cfg.Db.IsSQLite() {
// sqlite migrator doesn't support GetIndexes() currently
var ddl string
if err := db.
Table("sqlite_schema").
Select("sql").
Where("type = 'index'").
Where("tbl_name = 'heartbeats'").
Where("name = 'idx_time_user'").
Scan(&ddl).Error; err != nil {
return err
}
matches := regexp.MustCompile("(?i)\\((.+)\\)$").FindStringSubmatch(ddl)
if len(matches) > 0 && !strings.Contains(matches[0], "`user_id`") || !strings.Contains(matches[0], "`time`") {
drop = true
}
} else {
indexes, err := migrator.GetIndexes(&models.Heartbeat{})
if err != nil {
return err
}
for _, idx := range indexes {
if idx.Table() == "heartbeats" && idx.Name() == "idx_time_user" && len(idx.Columns()) == 1 {
drop = true
break
}
}
}
if !drop {
return nil
}
if err := migrator.DropIndex(&models.Heartbeat{}, "idx_time_user"); err != nil {
return err
}
logbuch.Info("index 'idx_time_user' needs to be recreated, this may take a while")
return nil
},
}
registerPreMigration(f)
}

View File

@ -3,11 +3,14 @@ package migrations
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"sort" "sort"
"strings" "strings"
) )
type gormMigrationFunc func(db *gorm.DB) error
type migrationFunc struct { type migrationFunc struct {
f func(db *gorm.DB, cfg *config.Config) error f func(db *gorm.DB, cfg *config.Config) error
name string name string
@ -20,6 +23,45 @@ var (
postMigrations migrationFuncs postMigrations migrationFuncs
) )
func GetMigrationFunc(cfg *config.Config) gormMigrationFunc {
switch cfg.Db.Dialect {
default:
return func(db *gorm.DB) error {
if err := db.AutoMigrate(&models.User{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Alias{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Summary{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
}
func registerPreMigration(f migrationFunc) { func registerPreMigration(f migrationFunc) {
preMigrations = append(preMigrations, f) preMigrations = append(preMigrations, f)
} }
@ -35,7 +77,7 @@ func Run(db *gorm.DB, cfg *config.Config) {
} }
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) { func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
if err := cfg.GetMigrationFunc(cfg.Db.Dialect)(db); err != nil { if err := GetMigrationFunc(cfg)(db); err != nil {
logbuch.Fatal(err.Error()) logbuch.Fatal(err.Error())
} }
} }
@ -46,7 +88,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range preMigrations { for _, m := range preMigrations {
logbuch.Info("potentially running migration '%s'", m.name) logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err) logbuch.Fatal("migration '%s' failed - %v", m.name, err)
} }
} }
} }
@ -57,7 +99,7 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range postMigrations { for _, m := range postMigrations {
logbuch.Info("potentially running migration '%s'", m.name) logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err) logbuch.Fatal("migration '%s' failed - %v", m.name, err)
} }
} }
} }

View File

@ -29,6 +29,11 @@ func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
return args.Get(0).([]*models.Alias), args.Error(1) return args.Get(0).([]*models.Alias), args.Error(1)
} }
func (m *AliasServiceMock) GetByUserAndType(s string, u uint8) ([]*models.Alias, error) {
args := m.Called(s, u)
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) { func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
args := m.Called(s, s2, u) args := m.Called(s, s2, u)
return args.Get(0).([]*models.Alias), args.Error(1) return args.Get(0).([]*models.Alias), args.Error(1)

16
mocks/duration_service.go Normal file
View File

@ -0,0 +1,16 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type DurationServiceMock struct {
mock.Mock
}
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User, f *models.Filters) (models.Durations, error) {
args := m.Called(time, time2, user, f)
return args.Get(0).(models.Durations), args.Error(1)
}

View File

@ -10,18 +10,18 @@ type HeartbeatServiceMock struct {
mock.Mock mock.Mock
} }
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error { func (m *HeartbeatServiceMock) Insert(h *models.Heartbeat) error {
args := m.Called(heartbeat) args := m.Called(h)
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error { func (m *HeartbeatServiceMock) InsertBatch(h []*models.Heartbeat) error {
args := m.Called(heartbeats) args := m.Called(h)
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) Count() (int64, error) { func (m *HeartbeatServiceMock) Count(a bool) (int64, error) {
args := m.Called() args := m.Called(a)
return int64(args.Int(0)), args.Error(1) return int64(args.Int(0)), args.Error(1)
} }
@ -40,6 +40,11 @@ func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, use
return args.Get(0).([]*models.Heartbeat), args.Error(1) return args.Get(0).([]*models.Heartbeat), args.Error(1)
} }
func (m *HeartbeatServiceMock) GetAllWithinByFilters(time time.Time, time2 time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user, filters)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) { func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called() args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1) return args.Get(0).([]*models.TimeByUser), args.Error(1)
@ -64,3 +69,13 @@ func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time) args := m.Called(time)
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
args := m.Called(u)
return args.Error(0)
}
func (m *HeartbeatServiceMock) DeleteByUserBefore(u *models.User, t time.Time) error {
args := m.Called(u, t)
return args.Error(0)
}

View File

@ -10,8 +10,8 @@ type SummaryRepositoryMock struct {
mock.Mock mock.Mock
} }
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error { func (m *SummaryRepositoryMock) Insert(s *models.Summary) error {
args := m.Called(summary) args := m.Called(s)
return args.Error(0) return args.Error(0)
} }
@ -20,8 +20,8 @@ func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
return args.Get(0).([]*models.Summary), args.Error(1) return args.Get(0).([]*models.Summary), args.Error(1)
} }
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) { func (m *SummaryRepositoryMock) GetByUserWithin(u *models.User, t1 time.Time, t2 time.Time) ([]*models.Summary, error) {
args := m.Called(user, time, time2) args := m.Called(u, t1, t2)
return args.Get(0).([]*models.Summary), args.Error(1) return args.Get(0).([]*models.Summary), args.Error(1)
} }
@ -34,3 +34,8 @@ func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
args := m.Called(s) args := m.Called(s)
return args.Error(0) return args.Error(0)
} }
func (m *SummaryRepositoryMock) DeleteByUserBefore(s string, t time.Time) error {
args := m.Called(s, t)
return args.Error(0)
}

View File

@ -34,11 +34,31 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)
} }
func (m *UserServiceMock) GetMany(s []string) ([]*models.User, error) {
args := m.Called(s)
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetManyMapped(s []string) (map[string]*models.User, error) {
args := m.Called()
return args.Get(0).(map[string]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAllByLeaderboard(b bool) ([]*models.User, error) {
//TODO implement me
panic("implement me")
}
func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) { func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
args := m.Called(b) args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)
} }
func (m *UserServiceMock) GetUserByStripeCustomerId(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) { func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
args := m.Called(b) args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)
@ -74,8 +94,8 @@ func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error)
return args.Get(0).(*models.User), args.Error(1) return args.Get(0).(*models.User), args.Error(1)
} }
func (m *UserServiceMock) SetWakatimeApiKey(user *models.User, s string) (*models.User, error) { func (m *UserServiceMock) SetWakatimeApiCredentials(user *models.User, s1, s2 string) (*models.User, error) {
args := m.Called(user, s) args := m.Called(user, s1, s2)
return args.Get(0).(*models.User), args.Error(1) return args.Get(0).(*models.User), args.Error(1)
} }
@ -92,3 +112,7 @@ func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, e
func (m *UserServiceMock) FlushCache() { func (m *UserServiceMock) FlushCache() {
m.Called() m.Called()
} }
func (m *UserServiceMock) FlushUserCache(s string) {
m.Called(s)
}

View File

@ -1,5 +1,11 @@
package models package models
// AliasResolver returns the alias of an entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value
type AliasResolver func(t uint8, k string) string
// AliasReverseResolver returns all original names, which have the given alias as mapping target. I.e., it returns a list of Alias.Value, given an Alias.Key
type AliasReverseResolver func(t uint8, k string) []string
type Alias struct { type Alias struct {
ID uint `gorm:"primary_key"` ID uint `gorm:"primary_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key"` Type uint8 `gorm:"not null; index:idx_alias_type_key"`

View File

@ -1,16 +1,15 @@
package v1 package v1
import ( import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
) )
// https://shields.io/endpoint // https://shields.io/endpoint
const ( const (
defaultLabel = "coding time" defaultLabel = "wakapi.dev"
defaultColor = "#2D3748" // not working defaultColor = "2F855A"
) )
type BadgeData struct { type BadgeData struct {
@ -20,18 +19,11 @@ type BadgeData struct {
Color string `json:"color"` Color string `json:"color"`
} }
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData { func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
var total time.Duration
if hasFilter, _, _ := filters.One(); hasFilter {
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}
return &BadgeData{ return &BadgeData{
SchemaVersion: 1, SchemaVersion: 1,
Label: defaultLabel, Label: defaultLabel,
Message: utils.FmtWakatimeDuration(total), Message: helpers.FmtWakatimeDuration(summary.TotalTime()),
Color: defaultColor, Color: defaultColor,
} }
} }

View File

@ -1,8 +1,8 @@
package v1 package v1
import ( import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time" "time"
) )
@ -27,19 +27,21 @@ type AllTimeRange struct {
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
} }
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel { func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
var total time.Duration total := summary.TotalTime()
if key := filters.Project; key != "" { tzName, _ := summary.FromTime.T().Zone()
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}
return &AllTimeViewModel{ return &AllTimeViewModel{
Data: &AllTimeData{ Data: &AllTimeData{
TotalSeconds: float32(total.Seconds()), TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total), Text: helpers.FmtWakatimeDuration(total),
IsUpToDate: true, IsUpToDate: true,
Range: &AllTimeRange{
End: summary.ToTime.T().Format(time.RFC3339),
EndDate: helpers.FormatDate(summary.ToTime.T()),
Start: summary.FromTime.T().Format(time.RFC3339),
StartDate: helpers.FormatDate(summary.FromTime.T()),
Timezone: tzName,
},
}, },
} }
} }

View File

@ -1,6 +1,9 @@
package v1 package v1
import ( import (
"strconv"
"time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
@ -12,18 +15,40 @@ type HeartbeatsViewModel struct {
// that is actually required for the import // that is actually required for the import
type HeartbeatEntry struct { type HeartbeatEntry struct {
Id string `json:"id"` Id string `json:"id"`
Branch string `json:"branch"` Branch string `json:"branch"`
Category string `json:"category"` Category string `json:"category"`
Entity string `json:"entity"` Entity string `json:"entity"`
IsWrite bool `json:"is_write"` IsWrite bool `json:"is_write"`
Language string `json:"language"` Language string `json:"language"`
Project string `json:"project"` Project string `json:"project"`
Time models.CustomTime `json:"time"` Time float64 `json:"time"`
Type string `json:"type"` Type string `json:"type"`
UserId string `json:"user_id"` UserId string `json:"user_id"`
MachineNameId string `json:"machine_name_id"` MachineNameId string `json:"machine_name_id"`
UserAgentId string `json:"user_agent_id"` UserAgentId string `json:"user_agent_id"`
CreatedAt models.CustomTime `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ModifiedAt models.CustomTime `json:"created_at"` }
func HeartbeatsToCompat(entries []*models.Heartbeat) []*HeartbeatEntry {
out := make([]*HeartbeatEntry, len(entries))
for i := 0; i < len(entries); i++ {
entry := entries[i]
out[i] = &HeartbeatEntry{
Id: strconv.FormatUint(entry.ID, 10),
Branch: entry.Branch,
Category: entry.Category,
Entity: entry.Entity,
IsWrite: entry.IsWrite,
Language: entry.Language,
Project: entry.Project,
Time: float64(entry.Time.T().Unix()),
Type: entry.Type,
UserId: entry.UserID,
MachineNameId: entry.Machine,
UserAgentId: entry.UserAgent,
CreatedAt: entry.CreatedAt.T(),
}
}
return out
} }

View File

@ -3,7 +3,8 @@ package v1
// https://wakatime.com/api/v1/users/current/machine_names // https://wakatime.com/api/v1/users/current/machine_names
type MachineViewModel struct { type MachineViewModel struct {
Data []*MachineEntry `json:"data"` Data []*MachineEntry `json:"data"`
TotalPages int `json:"total_pages"`
} }
type MachineEntry struct { type MachineEntry struct {

View File

@ -26,6 +26,7 @@ type StatsData struct {
Machines []*SummariesEntry `json:"machines"` Machines []*SummariesEntry `json:"machines"`
Projects []*SummariesEntry `json:"projects"` Projects []*SummariesEntry `json:"projects"`
OperatingSystems []*SummariesEntry `json:"operating_systems"` OperatingSystems []*SummariesEntry `json:"operating_systems"`
Branches []*SummariesEntry `json:"branches,omitempty"`
} }
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel { func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
@ -71,11 +72,21 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS)) oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
} }
branches := make([]*SummariesEntry, len(summary.Branches))
for i, e := range summary.Branches {
branches[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryBranch))
}
data.Editors = editors data.Editors = editors
data.Languages = languages data.Languages = languages
data.Machines = machines data.Machines = machines
data.Projects = projects data.Projects = projects
data.OperatingSystems = oss data.OperatingSystems = oss
data.Branches = branches
if summary.Branches == nil {
data.Branches = nil
}
return &StatsViewModel{ return &StatsViewModel{
Data: data, Data: data,

View File

@ -2,8 +2,8 @@ package v1
import ( import (
"fmt" "fmt"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math" "math"
"sync" "sync"
"time" "time"
@ -13,9 +13,17 @@ import (
// https://pastr.de/v/736450 // https://pastr.de/v/736450
type SummariesViewModel struct { type SummariesViewModel struct {
Data []*SummariesData `json:"data"` Data []*SummariesData `json:"data"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
CumulativeTotal *SummariesCumulativeTotal `json:"cummulative_total"` // typo is intended
}
type SummariesCumulativeTotal struct {
Decimal string `json:"decimal"`
Digital string `json:"digital"`
Seconds float64 `json:"seconds"`
Text string `json:"text"`
} }
type SummariesData struct { type SummariesData struct {
@ -26,6 +34,7 @@ type SummariesData struct {
Machines []*SummariesEntry `json:"machines"` Machines []*SummariesEntry `json:"machines"`
OperatingSystems []*SummariesEntry `json:"operating_systems"` OperatingSystems []*SummariesEntry `json:"operating_systems"`
Projects []*SummariesEntry `json:"projects"` Projects []*SummariesEntry `json:"projects"`
Branches []*SummariesEntry `json:"branches,omitempty"`
GrandTotal *SummariesGrandTotal `json:"grand_total"` GrandTotal *SummariesGrandTotal `json:"grand_total"`
Range *SummariesRange `json:"range"` Range *SummariesRange `json:"range"`
} }
@ -57,8 +66,7 @@ type SummariesRange struct {
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
} }
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
// TODO: implement filtering (https://github.com/muety/wakapi/issues/58)
data := make([]*SummariesData, len(summaries)) data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
@ -73,10 +81,23 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
} }
} }
var totalTime time.Duration
for _, s := range summaries {
totalTime += s.TotalTime()
}
totalHrs, totalMins, totalSecs := totalTime.Hours(), (totalTime - time.Duration(totalTime.Hours())*time.Hour).Minutes(), totalTime.Seconds()
return &SummariesViewModel{ return &SummariesViewModel{
Data: data, Data: data,
End: maxDate, End: maxDate,
Start: minDate, Start: minDate,
CumulativeTotal: &SummariesCumulativeTotal{
Decimal: fmt.Sprintf("%.2f", totalHrs),
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
Seconds: totalSecs,
Text: helpers.FmtWakatimeDuration(totalTime),
},
} }
} }
@ -93,11 +114,12 @@ func newDataFrom(s *models.Summary) *SummariesData {
Machines: make([]*SummariesEntry, len(s.Machines)), Machines: make([]*SummariesEntry, len(s.Machines)),
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)), OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
Projects: make([]*SummariesEntry, len(s.Projects)), Projects: make([]*SummariesEntry, len(s.Projects)),
Branches: make([]*SummariesEntry, len(s.Branches)),
GrandTotal: &SummariesGrandTotal{ GrandTotal: &SummariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs, Hours: totalHrs,
Minutes: totalMins, Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total), Text: helpers.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(), TotalSeconds: total.Seconds(),
}, },
Range: &SummariesRange{ Range: &SummariesRange{
@ -110,7 +132,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
} }
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(5) wg.Add(6)
go func(data *SummariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
@ -147,6 +169,17 @@ func newDataFrom(s *models.Summary) *SummariesData {
} }
}(data) }(data)
go func(data *SummariesData) {
defer wg.Done()
for i, e := range s.Branches {
data.Branches[i] = convertEntry(e, s.TotalTimeBy(models.SummaryBranch))
}
}(data)
if s.Branches == nil {
data.Branches = nil
}
wg.Wait() wg.Wait()
return data return data
} }
@ -168,7 +201,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEn
Name: e.Key, Name: e.Key,
Percent: percentage, Percent: percentage,
Seconds: secs, Seconds: secs,
Text: utils.FmtWakatimeDuration(total), Text: helpers.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(), TotalSeconds: total.Seconds(),
} }
} }

View File

@ -1,7 +1,8 @@
package v1 package v1
type UserAgentsViewModel struct { type UserAgentsViewModel struct {
Data []*UserAgentEntry `json:"data"` Data []*UserAgentEntry `json:"data"`
TotalPages int `json:"total_pages"`
} }
type UserAgentEntry struct { type UserAgentEntry struct {

View File

@ -2,8 +2,6 @@ package models
type Diagnostics struct { type Diagnostics struct {
ID uint `gorm:"primary_key"` ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_diagnostics_user"`
Platform string `json:"platform"` Platform string `json:"platform"`
Architecture string `json:"architecture"` Architecture string `json:"architecture"`
Plugin string `json:"plugin"` Plugin string `json:"plugin"`

70
models/duration.go Normal file
View File

@ -0,0 +1,70 @@
package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"time"
)
type Duration struct {
UserID string `json:"user_id"`
Time CustomTime `json:"time" hash:"ignore"`
Duration time.Duration `json:"duration" hash:"ignore"`
Project string `json:"project"`
Language string `json:"language"`
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Branch string `json:"branch"`
NumHeartbeats int `json:"-" hash:"ignore"`
GroupHash string `json:"-" hash:"ignore"`
}
func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
d := &Duration{
UserID: h.UserID,
Time: h.Time,
Duration: 0,
Project: h.Project,
Language: h.Language,
Editor: h.Editor,
OperatingSystem: h.OperatingSystem,
Machine: h.Machine,
Branch: h.Branch,
NumHeartbeats: 1,
}
return d.Hashed()
}
func (d *Duration) Hashed() *Duration {
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
}
d.GroupHash = fmt.Sprintf("%x", hash)
return d
}
func (d *Duration) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = d.Project
case SummaryEditor:
key = d.Editor
case SummaryLanguage:
key = d.Language
case SummaryOS:
key = d.OperatingSystem
case SummaryMachine:
key = d.Machine
case SummaryBranch:
key = d.Branch
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

46
models/durations.go Normal file
View File

@ -0,0 +1,46 @@
package models
import "sort"
type Durations []*Duration
func (d Durations) Len() int {
return len(d)
}
func (d Durations) Less(i, j int) bool {
return d[i].Time.T().Before(d[j].Time.T())
}
func (d Durations) Swap(i, j int) {
d[i], d[j] = d[j], d[i]
}
func (d Durations) TotalNumHeartbeats() int {
var total int
for _, e := range d {
total += e.NumHeartbeats
}
return total
}
func (d Durations) Sorted() Durations {
sort.Sort(d)
return d
}
func (d *Durations) First() *Duration {
// assumes slice to be sorted
if d.Len() == 0 {
return nil
}
return (*d)[0]
}
func (d *Durations) Last() *Duration {
// assumes slice to be sorted
if d.Len() == 0 {
return nil
}
return (*d)[d.Len()-1]
}

View File

@ -1,50 +1,223 @@
package models package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
)
type Filters struct { type Filters struct {
Project string Project OrFilter
OS string OS OrFilter
Language string Language OrFilter
Editor string Editor OrFilter
Machine string Machine OrFilter
Label string Label OrFilter
Branch OrFilter
}
type OrFilter []string
func (f OrFilter) Exists() bool {
return len(f) > 0 && f[0] != ""
}
func (f OrFilter) MatchAny(search string) bool {
for _, s := range f {
if s == search {
return true
}
}
return false
} }
type FilterElement struct { type FilterElement struct {
Type uint8 entity uint8
Key string filter OrFilter
} }
func NewFiltersWith(entity uint8, key string) *Filters { func NewFiltersWith(entity uint8, key string) *Filters {
switch entity { return NewFilterWithMultiple(entity, []string{key})
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{OS: key}
case SummaryLanguage:
return &Filters{Language: key}
case SummaryEditor:
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
}
return &Filters{}
} }
func (f *Filters) One() (bool, uint8, string) { func NewFilterWithMultiple(entity uint8, keys []string) *Filters {
if f.Project != "" { filters := &Filters{}
return true, SummaryProject, f.Project return filters.WithMultiple(entity, keys)
} else if f.OS != "" { }
return true, SummaryOS, f.OS
} else if f.Language != "" { func (f *Filters) With(entity uint8, key string) *Filters {
return true, SummaryLanguage, f.Language return f.WithMultiple(entity, []string{key})
} else if f.Editor != "" { }
return true, SummaryEditor, f.Editor
} else if f.Machine != "" { func (f *Filters) WithMultiple(entity uint8, keys []string) *Filters {
return true, SummaryMachine, f.Machine switch entity {
} else if f.Label != "" { case SummaryProject:
return true, SummaryLabel, f.Label f.Project = append(f.Project, keys...)
} case SummaryOS:
return false, 0, "" f.OS = append(f.OS, keys...)
case SummaryLanguage:
f.Language = append(f.Language, keys...)
case SummaryEditor:
f.Editor = append(f.Editor, keys...)
case SummaryMachine:
f.Machine = append(f.Machine, keys...)
case SummaryLabel:
f.Label = append(f.Label, keys...)
case SummaryBranch:
f.Branch = append(f.Branch, keys...)
}
return f
}
func (f *Filters) One() (bool, uint8, OrFilter) {
if f.Project != nil && f.Project.Exists() {
return true, SummaryProject, f.Project
} else if f.OS != nil && f.OS.Exists() {
return true, SummaryOS, f.OS
} else if f.Language != nil && f.Language.Exists() {
return true, SummaryLanguage, f.Language
} else if f.Editor != nil && f.Editor.Exists() {
return true, SummaryEditor, f.Editor
} else if f.Machine != nil && f.Machine.Exists() {
return true, SummaryMachine, f.Machine
} else if f.Label != nil && f.Label.Exists() {
return true, SummaryLabel, f.Label
} else if f.Branch != nil && f.Branch.Exists() {
return true, SummaryBranch, f.Branch
}
return false, 0, OrFilter{}
}
func (f *Filters) OneOrEmpty() FilterElement {
if ok, t, of := f.One(); ok {
return FilterElement{entity: t, filter: of}
}
return FilterElement{entity: SummaryUnknown, filter: []string{}}
}
func (f *Filters) IsEmpty() bool {
nonEmpty, _, _ := f.One()
return !nonEmpty
}
func (f *Filters) Count() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
count += f.CountByEntity(i)
}
return count
}
func (f *Filters) CountByEntity(entity uint8) int {
return len(*f.ResolveEntity(entity))
}
func (f *Filters) EntityCount() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
if c := f.CountByEntity(i); c > 0 {
count++
}
}
return count
}
func (f *Filters) ResolveEntity(entityId uint8) *OrFilter {
switch entityId {
case SummaryProject:
return &f.Project
case SummaryLanguage:
return &f.Language
case SummaryEditor:
return &f.Editor
case SummaryOS:
return &f.OS
case SummaryMachine:
return &f.Machine
case SummaryLabel:
return &f.Label
case SummaryBranch:
return &f.Branch
default:
return &OrFilter{}
}
}
func (f *Filters) Hash() string {
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
}
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
}
func (f *Filters) Match(h *Heartbeat) bool {
return (f.Project == nil || f.Project.MatchAny(h.Project)) &&
(f.OS == nil || f.OS.MatchAny(h.OperatingSystem)) &&
(f.Language == nil || f.Language.MatchAny(h.Language)) &&
(f.Editor == nil || f.Editor.MatchAny(h.Editor)) &&
(f.Machine == nil || f.Machine.MatchAny(h.Machine))
}
// WithAliases adds OR-conditions for every alias of a filter key as additional filter keys
func (f *Filters) WithAliases(resolve AliasReverseResolver) *Filters {
if f.Project != nil {
updated := OrFilter(make([]string, 0, len(f.Project)))
for _, e := range f.Project {
updated = append(updated, e)
updated = append(updated, resolve(SummaryProject, e)...)
}
f.Project = updated
}
if f.OS != nil {
updated := OrFilter(make([]string, 0, len(f.OS)))
for _, e := range f.OS {
updated = append(updated, e)
updated = append(updated, resolve(SummaryOS, e)...)
}
f.OS = updated
}
if f.Language != nil {
updated := OrFilter(make([]string, 0, len(f.Language)))
for _, e := range f.Language {
updated = append(updated, e)
updated = append(updated, resolve(SummaryLanguage, e)...)
}
f.Language = updated
}
if f.Editor != nil {
updated := OrFilter(make([]string, 0, len(f.Editor)))
for _, e := range f.Editor {
updated = append(updated, e)
updated = append(updated, resolve(SummaryEditor, e)...)
}
f.Editor = updated
}
if f.Machine != nil {
updated := OrFilter(make([]string, 0, len(f.Machine)))
for _, e := range f.Machine {
updated = append(updated, e)
updated = append(updated, resolve(SummaryMachine, e)...)
}
f.Machine = updated
}
if f.Branch != nil {
updated := OrFilter(make([]string, 0, len(f.Branch)))
for _, e := range f.Branch {
updated = append(updated, e)
updated = append(updated, resolve(SummaryBranch, e)...)
}
f.Branch = updated
}
return f
}
func (f *Filters) WithProjectLabels(resolve ProjectLabelReverseResolver) *Filters {
if f.Label == nil || !f.Label.Exists() {
return f
}
for _, l := range f.Label {
f.WithMultiple(SummaryProject, resolve(l))
}
return f
} }

160
models/filters_test.go Normal file
View File

@ -0,0 +1,160 @@
package models
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"testing"
)
type FiltersTestSuite struct {
suite.Suite
TestAliases []*Alias
TestProjectLabels []*ProjectLabel
GetAliasReverseResolver func(indices []int) AliasReverseResolver
GetProjectLabelReverseResolver func(indices []int) ProjectLabelReverseResolver
}
func (suite *FiltersTestSuite) SetupSuite() {
suite.TestAliases = []*Alias{
{
Type: SummaryProject,
Key: "wakapi",
Value: "wakapi-mobile",
},
{
Type: SummaryProject,
Key: "wakapi",
Value: "wakapi-desktop",
},
{
Type: SummaryLanguage,
Key: "Python",
Value: "Python 3",
},
}
suite.TestProjectLabels = []*ProjectLabel{
{
ProjectKey: "wakapi",
Label: "oss",
},
{
ProjectKey: "anchr",
Label: "oss",
},
{
ProjectKey: "business-application",
Label: "work",
},
}
suite.GetAliasReverseResolver = func(indices []int) AliasReverseResolver {
return func(t uint8, k string) []string {
aliases := make([]string, 0, len(indices))
for _, j := range indices {
if a := suite.TestAliases[j]; a.Type == t && a.Key == k {
aliases = append(aliases, a.Value)
}
}
return aliases
}
}
suite.GetProjectLabelReverseResolver = func(indices []int) ProjectLabelReverseResolver {
return func(k string) []string {
labels := make([]string, 0, len(indices))
for _, j := range indices {
if l := suite.TestProjectLabels[j]; l.Label == k {
labels = append(labels, l.ProjectKey)
}
}
return labels
}
}
}
func TestFiltersTestSuite(t *testing.T) {
suite.Run(t, new(FiltersTestSuite))
}
func (suite *FiltersTestSuite) TestFilters_IsEmpty() {
assert.False(suite.T(), NewFiltersWith(SummaryProject, "wakapi").IsEmpty())
assert.True(suite.T(), (&Filters{}).IsEmpty())
}
func (suite *FiltersTestSuite) TestFilters_Match() {
heartbeats := []*Heartbeat{
{Project: "wakapi", Language: "Go"},
{Project: "anchr", Language: "Javascript"},
}
sut1 := NewFiltersWith(SummaryProject, "wakapi")
assert.True(suite.T(), sut1.Match(heartbeats[0]))
assert.False(suite.T(), sut1.Match(heartbeats[1]))
sut2 := NewFiltersWith(SummaryProject, "Go").With(SummaryLanguage, "JavaScript")
assert.False(suite.T(), sut2.Match(heartbeats[0]))
assert.False(suite.T(), sut2.Match(heartbeats[1]))
sut3 := NewFilterWithMultiple(SummaryProject, []string{"wakapi", "anchr"})
assert.True(suite.T(), sut3.Match(heartbeats[0]))
assert.True(suite.T(), sut3.Match(heartbeats[1]))
sut4 := &Filters{}
assert.True(suite.T(), sut4.Match(heartbeats[0]))
assert.True(suite.T(), sut4.Match(heartbeats[1]))
}
func (suite *FiltersTestSuite) TestFilters_One() {
sut1 := NewFiltersWith(SummaryLanguage, "Java")
ok1, type1, filters1 := sut1.One()
assert.True(suite.T(), ok1)
assert.Equal(suite.T(), SummaryLanguage, type1)
assert.Equal(suite.T(), "Java", filters1[0])
sut2 := &Filters{}
ok2, type2, filters2 := sut2.One()
assert.False(suite.T(), ok2)
assert.Zero(suite.T(), type2)
assert.Empty(suite.T(), filters2)
}
func (suite *FiltersTestSuite) TestFilters_WithAliases() {
sut1 := NewFiltersWith(SummaryProject, "wakapi")
sut1 = sut1.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
assert.Len(suite.T(), sut1.Project, 3)
assert.Len(suite.T(), sut1.Language, 0)
assert.Contains(suite.T(), sut1.Project, "wakapi")
assert.Contains(suite.T(), sut1.Project, "wakapi-desktop")
assert.Contains(suite.T(), sut1.Project, "wakapi-mobile")
sut2 := NewFiltersWith(SummaryProject, "wakapi").With(SummaryLanguage, "Python")
sut2 = sut2.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
assert.Len(suite.T(), sut2.Project, 3)
assert.Len(suite.T(), sut2.Language, 2)
assert.Contains(suite.T(), sut2.Language, "Python")
assert.Contains(suite.T(), sut2.Language, "Python 3")
sut3 := NewFiltersWith(SummaryProject, "foo")
sut3 = sut3.WithAliases(suite.GetAliasReverseResolver([]int{0, 1, 2}))
assert.Len(suite.T(), sut3.Project, 1)
assert.Len(suite.T(), sut3.Language, 0)
assert.Contains(suite.T(), sut3.Project, "foo")
}
func (suite *FiltersTestSuite) TestFilters_WithProjectLabels() {
sut1 := NewFiltersWith(SummaryProject, "mailwhale").With(SummaryLabel, "oss")
sut1 = sut1.WithProjectLabels(suite.GetProjectLabelReverseResolver([]int{0, 1, 2}))
assert.Len(suite.T(), sut1.Project, 3)
assert.Contains(suite.T(), sut1.Project, "wakapi")
assert.Contains(suite.T(), sut1.Project, "anchr")
assert.Contains(suite.T(), sut1.Project, "mailwhale")
assert.Contains(suite.T(), sut1.Label, "oss")
sut2 := NewFiltersWith(SummaryLabel, "oss")
sut2 = sut2.WithProjectLabels(suite.GetProjectLabelReverseResolver([]int{0, 1, 2}))
assert.Len(suite.T(), sut2.Project, 2)
assert.Contains(suite.T(), sut2.Project, "wakapi")
assert.Contains(suite.T(), sut2.Project, "anchr")
assert.Contains(suite.T(), sut2.Label, "oss")
}

View File

@ -11,29 +11,34 @@ import (
type Heartbeat struct { type Heartbeat struct {
ID uint64 `gorm:"primary_key" hash:"ignore"` ID uint64 `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"` UserID string `json:"-" gorm:"not null; index:idx_time_user; index:idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
Entity string `json:"entity" gorm:"not null; index:idx_entity"` Entity string `json:"entity" gorm:"not null"`
Type string `json:"type"` Type string `json:"type" gorm:"size:255"`
Category string `json:"category"` Category string `json:"category" gorm:"size:255"`
Project string `json:"project"` Project string `json:"project" gorm:"index:idx_project; index:idx_user_project"`
Branch string `json:"branch"` Branch string `json:"branch" gorm:"index:idx_branch"`
Language string `json:"language" gorm:"index:idx_language"` Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"` IsWrite bool `json:"is_write"`
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime Editor string `json:"editor" gorm:"index:idx_editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore"` UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"` Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"` Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"` Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore"` OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp(3)" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
} }
func (h *Heartbeat) Valid() bool { func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{}) return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
} }
func (h *Heartbeat) Timely(maxAge time.Duration) bool {
now := time.Now()
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
}
func (h *Heartbeat) Augment(languageMappings map[string]string) { func (h *Heartbeat) Augment(languageMappings map[string]string) {
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
for ending, value := range languageMappings { for ending, value := range languageMappings {
@ -56,6 +61,8 @@ func (h *Heartbeat) GetKey(t uint8) (key string) {
key = h.OperatingSystem key = h.OperatingSystem
case SummaryMachine: case SummaryMachine:
key = h.Machine key = h.Machine
case SummaryBranch:
key = h.Branch
} }
if key == "" { if key == "" {
@ -92,8 +99,20 @@ func (h *Heartbeat) String() string {
func (h *Heartbeat) Hashed() *Heartbeat { func (h *Heartbeat) Hashed() *Heartbeat {
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil) hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
if err != nil { if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err) logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
} }
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported" h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
return h return h
} }
func GetEntityColumn(t uint8) string {
return []string{
"project",
"language",
"editor",
"operating_system",
"machine",
"label",
"branch",
}[t]
}

18
models/init_test.go Normal file
View File

@ -0,0 +1,18 @@
package models
import (
"os"
"path"
"runtime"
)
func init() {
// move to project root as working directory (e.g. so data/* can be resolved when loading config)
// taken from https://intellij-support.jetbrains.com/hc/en-us/community/posts/360009685279-Go-test-working-directory-keeps-changing-to-dir-of-the-test-file-instead-of-value-in-template
_, filename, _, _ := runtime.Caller(0)
dir := path.Join(path.Dir(filename), "..")
err := os.Chdir(dir)
if err != nil {
panic(err)
}
}

View File

@ -14,8 +14,9 @@ var (
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"} IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
IntervalPast14Days = &IntervalKey{"Last 14 Days"} IntervalPast14Days = &IntervalKey{"Last 14 Days"}
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"} IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"} IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
IntervalAny = &IntervalKey{"any"} IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
IntervalAny = &IntervalKey{"any", "all_time"}
) )
var AllIntervals = []*IntervalKey{ var AllIntervals = []*IntervalKey{
@ -30,6 +31,7 @@ var AllIntervals = []*IntervalKey{
IntervalPast7DaysYesterday, IntervalPast7DaysYesterday,
IntervalPast14Days, IntervalPast14Days,
IntervalPast30Days, IntervalPast30Days,
IntervalPast6Months,
IntervalPast12Months, IntervalPast12Months,
IntervalAny, IntervalAny,
} }

109
models/leaderboard.go Normal file
View File

@ -0,0 +1,109 @@
package models
import (
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/slice"
"strings"
"time"
)
type LeaderboardItem struct {
ID uint `json:"-" gorm:"primary_key; size:32"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
Key *string `json:"key" gorm:"size:255"` // pointer because nullable
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
// https://github.com/go-gorm/gorm/issues/5789
// https://github.com/go-gorm/gorm/issues/5284#issuecomment-1107775806
type LeaderboardItemRanked struct {
LeaderboardItem
Rank uint
}
func (l1 *LeaderboardItemRanked) Equals(l2 *LeaderboardItemRanked) bool {
return l1.ID == l2.ID
}
type Leaderboard []*LeaderboardItemRanked
func (l *Leaderboard) Add(item *LeaderboardItemRanked) {
if _, found := slice.Find[*LeaderboardItemRanked](*l, func(i int, item2 *LeaderboardItemRanked) bool {
return item.Equals(item2)
}); !found {
*l = append(*l, item)
}
}
func (l *Leaderboard) AddMany(items []*LeaderboardItemRanked) {
for _, item := range items {
l.Add(item)
}
}
func (l Leaderboard) UserIDs() []string {
return slice.Unique[string](slice.Map[*LeaderboardItemRanked, string](l, func(i int, item *LeaderboardItemRanked) string {
return item.UserID
}))
}
func (l Leaderboard) HasUser(userId string) bool {
return slice.Contain(l.UserIDs(), userId)
}
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
return slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
})
}
func (l Leaderboard) TopKeys(by uint8) []string {
type keyTotal struct {
Key string
Total time.Duration
}
totalsMapped := make(map[string]*keyTotal, len(l))
for _, item := range l {
if item.Key == nil || item.By == nil || *item.By != by {
continue
}
key := strings.ToLower(*item.Key)
if _, ok := totalsMapped[key]; !ok {
totalsMapped[key] = &keyTotal{Key: *item.Key, Total: 0}
}
totalsMapped[key].Total += item.Total
}
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
return *item
})
if err := slice.SortByField(totals, "Total", "desc"); err != nil {
return []string{} // TODO
}
return slice.Map[keyTotal, string](totals, func(i int, item keyTotal) string {
return item.Key
})
}
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
return Leaderboard(slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.UserID == userId
})).TopKeys(by)
}
func (l Leaderboard) LastUpdate() time.Time {
lastUpdate := time.Time{}
for _, item := range l {
if item.CreatedAt.T().After(lastUpdate) {
lastUpdate = item.CreatedAt.T()
}
}
return lastUpdate
}

View File

@ -4,7 +4,7 @@ import "fmt"
type CounterMetric struct { type CounterMetric struct {
Name string Name string
Value int Value int64
Desc string Desc string
Labels Labels Labels Labels
} }

View File

@ -0,0 +1,22 @@
package metrics
import "fmt"
type GaugeMetric struct {
Name string
Value int64
Desc string
Labels Labels
}
func (c GaugeMetric) Key() string {
return c.Name
}
func (c GaugeMetric) Print() string {
return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value)
}
func (c GaugeMetric) Header() string {
return fmt.Sprintf("# HELP %s %s\n# TYPE %s gauge", c.Name, c.Desc, c.Name)
}

View File

@ -1,5 +1,8 @@
package models package models
// ProjectLabelReverseResolver returns all projects for a given label
type ProjectLabelReverseResolver func(l string) []string
type ProjectLabel struct { type ProjectLabel struct {
ID uint `json:"id" gorm:"primary_key"` ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`

View File

@ -5,20 +5,18 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
const ( const (
UserKey = "user" UserKey = "user"
ImprintKey = "imprint" ImprintKey = "imprint"
AuthCookieKey = "wakapi_auth" AuthCookieKey = "wakapi_auth"
PersistentIntervalKey = "wakapi_summary_interval"
) )
type MigrationFunc func(db *gorm.DB) error
type KeyStringValue struct { type KeyStringValue struct {
Key string `gorm:"primary_key"` Key string `gorm:"primary_key"`
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
@ -29,6 +27,11 @@ type Interval struct {
End time.Time End time.Time
} }
type KeyedInterval struct {
Interval
Key *IntervalKey
}
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468) // CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time type CustomTime time.Time

View File

@ -8,19 +8,21 @@ import (
const ( const (
NSummaryTypes uint8 = 99 NSummaryTypes uint8 = 99
SummaryUnknown uint8 = 98
SummaryProject uint8 = 0 SummaryProject uint8 = 0
SummaryLanguage uint8 = 1 SummaryLanguage uint8 = 1
SummaryEditor uint8 = 2 SummaryEditor uint8 = 2
SummaryOS uint8 = 3 SummaryOS uint8 = 3
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
SummaryLabel uint8 = 5 SummaryLabel uint8 = 5
SummaryBranch uint8 = 6
) )
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default" const DefaultProjectLabel = "default"
type Summary struct { type Summary struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key; size:32"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"` UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
@ -30,7 +32,9 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 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 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
NumHeartbeats int `json:"-"`
} }
type SummaryItems []*SummaryItem type SummaryItems []*SummaryItem
@ -38,9 +42,9 @@ type SummaryItems []*SummaryItem
type SummaryItem struct { type SummaryItem struct {
ID uint64 `json:"-" gorm:"primary_key"` ID uint64 `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-"` SummaryID uint `json:"-" gorm:"size:32"`
Type uint8 `json:"-" gorm:"index:idx_type"` Type uint8 `json:"-" gorm:"index:idx_type"`
Key string `json:"key"` Key string `json:"key" gorm:"size:255"`
Total time.Duration `json:"total" swaggertype:"primitive,integer"` Total time.Duration `json:"total" swaggertype:"primitive,integer"`
} }
@ -49,34 +53,23 @@ type SummaryItemContainer struct {
Items []*SummaryItem Items []*SummaryItem
} }
type SummaryViewModel struct {
*Summary
*SummaryParams
User *User
AvatarURL string
LanguageColors map[string]string
EditorColors map[string]string
OSColors map[string]string
Error string
Success string
ApiKey string
RawQuery string
}
type SummaryParams struct { type SummaryParams struct {
From time.Time From time.Time
To time.Time To time.Time
User *User User *User
Filters *Filters
Recompute bool Recompute bool
} }
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 { func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel} return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel, SummaryBranch}
} }
func NativeSummaryTypes() []uint8 { func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryBranch}
}
func PersistedSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
} }
@ -87,6 +80,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Languages)) sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors)) sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels)) sort.Sort(sort.Reverse(s.Labels))
sort.Sort(sort.Reverse(s.Branches))
return s return s
} }
@ -102,14 +96,47 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryOS: &s.OperatingSystems, SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines, SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels, SummaryLabel: &s.Labels,
SummaryBranch: &s.Branches,
} }
} }
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems { func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType] switch summaryType {
case SummaryProject:
return &s.Projects
case SummaryLanguage:
return &s.Languages
case SummaryEditor:
return &s.Editors
case SummaryOS:
return &s.OperatingSystems
case SummaryMachine:
return &s.Machines
case SummaryLabel:
return &s.Labels
case SummaryBranch:
return &s.Branches
}
return nil
} }
/* Augments the summary in a way that at least one item is present for every type. func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {
if len(types) == 0 {
return s
}
for _, t := range SummaryTypes() {
if keep, ok := types[t]; !keep || !ok {
*s.ItemsByType(t) = []*SummaryItem{}
}
}
return s
}
/*
Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types, If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown" the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
for the missing type. for the missing type.
@ -217,16 +244,41 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur
return timeSum return timeSum
} }
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration { func (s *Summary) TotalTimeByFilter(filter FilterElement) time.Duration {
do, typeId, key := filters.One() var total time.Duration
if do { for _, f := range filter.filter {
return s.TotalTimeByKey(typeId, key) total += s.TotalTimeByKey(filter.entity, f)
} }
return 0 return total
}
func (s *Summary) MaxBy(entityType uint8) *SummaryItem {
var max *SummaryItem
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if max == nil || item.Total > max.Total {
max = item
}
}
}
return max
}
func (s *Summary) MaxByToString(entityType uint8) string {
max := s.MaxBy(entityType)
if max == nil {
return "-"
}
return max.Key
} }
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary { func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem { processAliases := func(origin []*SummaryItem) []*SummaryItem {
if origin == nil {
return nil
}
target := make([]*SummaryItem, 0) target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem { findItem := func(key string) *SummaryItem {
@ -272,6 +324,7 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.OperatingSystems = processAliases(s.OperatingSystems) s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines) s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels) s.Labels = processAliases(s.Labels)
s.Branches = processAliases(s.Branches)
return s return s
} }
@ -285,6 +338,26 @@ func (s *Summary) findFirstPresentType() (uint8, error) {
return 127, errors.New("no type present") return 127, errors.New("no type present")
} }
func (s *SummaryParams) HasFilters() bool {
return s.Filters != nil && !s.Filters.IsEmpty()
}
func (s *SummaryParams) IsProjectDetails() bool {
if !s.HasFilters() {
return false
}
_, entity, filters := s.Filters.One()
return entity == SummaryProject && len(filters) == 1 // exactly one
}
func (s *SummaryParams) GetProjectFilter() string {
if !s.IsProjectDetails() {
return ""
}
_, _, filters := s.Filters.One()
return filters[0]
}
func (s *SummaryItem) TotalFixed() time.Duration { func (s *SummaryItem) TotalFixed() time.Duration {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items // TODO: fix some day, while migrating persisted summary items

View File

@ -98,20 +98,13 @@ func TestSummary_TotalTimeByFilters(t *testing.T) {
}, },
} }
// Specifying filters about multiple entites is not supported at the moment filters1 := NewFiltersWith(SummaryProject, "wakapi").OneOrEmpty()
// as the current, very rudimentary, time calculation logic wouldn't make sense then. filters2 := NewFiltersWith(SummaryLanguage, "Go").OneOrEmpty()
// Evaluating a filter like (project="wakapi", language="go") can only be realized filters3 := FilterElement{}
// before computing the summary in the first place, because afterwards we can't know
// what time coded in "Go" was in the "Wakapi" project
// See https://github.com/muety/wakapi/issues/108
filters1 := &Filters{Project: "wakapi"} assert.Equal(t, testDuration1, sut.TotalTimeByFilter(filters1))
filters2 := &Filters{Language: "Go"} assert.Equal(t, testDuration3, sut.TotalTimeByFilter(filters2))
filters3 := &Filters{} assert.Zero(t, sut.TotalTimeByFilter(filters3))
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
} }
func TestSummary_WithResolvedAliases(t *testing.T) { func TestSummary_WithResolvedAliases(t *testing.T) {
@ -175,6 +168,66 @@ func TestSummary_WithResolvedAliases(t *testing.T) {
assert.Empty(t, sut.Machines) assert.Empty(t, sut.Machines)
} }
func TestSummary_KeepOnly(t *testing.T) {
newSummary := func() *Summary {
return &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: 10 * time.Minute / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: 10 * time.Minute / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: 10 * time.Minute / time.Second,
},
},
Editors: []*SummaryItem{
{
Type: SummaryEditor,
Key: "VSCode",
Total: 10 * time.Minute / time.Second,
},
},
}
}
var sut *Summary
sut = newSummary().KeepOnly(map[uint8]bool{}) // keep all
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 20*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.Zero(t, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 20*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryEditor: true, SummaryLanguage: true})
assert.Zero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 10*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
sut.FillMissing()
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
}
func TestSummaryItems_Sorted(t *testing.T) { func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute

View File

@ -3,6 +3,8 @@ package models
import ( import (
"crypto/md5" "crypto/md5"
"fmt" "fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -13,25 +15,29 @@ func init() {
} }
type User struct { type User struct {
ID string `json:"id" gorm:"primary_key"` ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"` Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"` Location string `json:"location"`
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
ShareDataMaxDays int `json:"-" gorm:"default:0"` ShareDataMaxDays int `json:"-"`
ShareEditors bool `json:"-" gorm:"default:false; type:bool"` ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"` ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
ShareProjects bool `json:"-" gorm:"default:false; type:bool"` ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"` ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"` ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels bool `json:"-" gorm:"default:false; type:bool"` ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"` IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"` HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"` WakatimeApiKey string `json:"-"` // for relay middleware and imports
ResetToken string `json:"-"` WakatimeApiUrl string `json:"-"` // for relay middleware and imports
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"` ResetToken string `json:"-"`
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
StripeCustomerId string `json:"-"`
} }
type Login struct { type Login struct {
@ -64,9 +70,10 @@ type CredentialsReset struct {
} }
type UserDataUpdate struct { type UserDataUpdate struct {
Email string `schema:"email"` Email string `schema:"email"`
Location string `schema:"location"` Location string `schema:"location"`
ReportsWeekly bool `schema:"reports_weekly"` ReportsWeekly bool `schema:"reports_weekly"`
PublicLeaderboard bool `schema:"public_leaderboard"`
} }
type TimeByUser struct { type TimeByUser struct {
@ -79,6 +86,10 @@ type CountByUser struct {
Count int64 Count int64
} }
func (u *User) Identity() string {
return u.ID
}
func (u *User) TZ() *time.Location { func (u *User) TZ() *time.Location {
if u.Location == "" { if u.Location == "" {
u.Location = "Local" u.Location = "Local"
@ -90,6 +101,8 @@ func (u *User) TZ() *time.Location {
return tz return tz
} }
// TZOffset returns the time difference between the user's current time zone and UTC
// TODO: is this actually working??
func (u *User) TZOffset() time.Duration { func (u *User) TZOffset() time.Duration {
_, offset := time.Now().In(u.TZ()).Zone() _, offset := time.Now().In(u.TZ()).Zone()
return time.Duration(offset * int(time.Second)) return time.Duration(offset * int(time.Second))
@ -107,6 +120,48 @@ func (u *User) AvatarURL(urlTemplate string) string {
return urlTemplate return urlTemplate
} }
// WakaTimeURL returns the user's effective WakaTime URL, i.e. a custom one (which could also point to another Wakapi instance) or fallback if not specified otherwise.
func (u *User) WakaTimeURL(fallback string) string {
if u.WakatimeApiUrl != "" {
return strings.TrimSuffix(u.WakatimeApiUrl, "/")
}
return fallback
}
// HasActiveSubscription returns true if subscriptions are enabled on the server and the user has got one
func (u *User) HasActiveSubscription() bool {
return conf.Get().Subscriptions.Enabled && u.HasActiveSubscriptionStrict()
}
func (u *User) HasActiveSubscriptionStrict() bool {
return u.SubscribedUntil != nil && u.SubscribedUntil.T().After(time.Now())
}
// SubscriptionExpiredSince returns if a user's subscription has expiration and the duration since when that happened.
// Returns (false, <negative duration>), if subscription hasn't expired, yet.
// Returns (false, 0), if subscriptions are not enabled in the first place.
// Returns (true, <very long duration>), if the user has never had a subscription.
func (u *User) SubscriptionExpiredSince() (bool, time.Duration) {
cfg := conf.Get()
if !cfg.Subscriptions.Enabled {
return false, 0
}
if u.SubscribedUntil == nil {
return true, 99 * 365 * 24 * time.Hour
}
diff := time.Now().Sub(u.SubscribedUntil.T())
return diff >= 0, diff
}
func (u *User) MinDataAge() time.Time {
retentionMonths := conf.Get().App.DataRetentionMonths
if retentionMonths <= 0 || u.HasActiveSubscription() {
return time.Time{}
}
// this is not exactly precise, because of summer / winter time, etc.
return time.Now().AddDate(0, -retentionMonths, 0)
}
func (c *CredentialsReset) IsValid() bool { func (c *CredentialsReset) IsValid() bool {
return ValidatePassword(c.PasswordNew) && return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat c.PasswordNew == c.PasswordRepeat
@ -136,8 +191,9 @@ func ValidatePassword(password string) bool {
return len(password) >= 6 return len(password) >= 6
} }
// ValidateEmail checks that, if an email address is given, it has proper syntax and (if not in dev mode) an MX record exists for the domain
func ValidateEmail(email string) bool { func ValidateEmail(email string) bool {
return email == "" || mailRegex.Match([]byte(email)) return email == "" || (mailRegex.Match([]byte(email)) && (conf.Get().IsDev() || utils.CheckEmailMX(email)))
} }
func ValidateTimezone(tz string) bool { func ValidateTimezone(tz string) bool {

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
conf "github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
"time" "time"
@ -18,3 +19,41 @@ func TestUser_TZ(t *testing.T) {
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second)) assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second)) assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
} }
func TestUser_MinDataAge(t *testing.T) {
c := conf.Load("")
var sut *User
// test with unlimited retention time / clean-up disabled
c.App.DataRetentionMonths = -1
c.Subscriptions.Enabled = false
sut = &User{}
assert.Zero(t, sut.MinDataAge())
// test with limited retention time / clean-up enabled, and subscriptions disabled
c.App.DataRetentionMonths = 1
c.Subscriptions.Enabled = false
sut = &User{}
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
// test with limited retention time, subscriptions enabled, and user hasn't got one
c.App.DataRetentionMonths = 1
c.Subscriptions.Enabled = true
sut = &User{}
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
// test with limited retention time, subscriptions disabled, but user still got (an expired) one
c.App.DataRetentionMonths = 1
c.Subscriptions.Enabled = false
until2 := CustomTime(time.Now().AddDate(0, 0, -1))
sut = &User{SubscribedUntil: &until2}
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
// test with limited retention time, subscriptions enabled, and user has got one
c.App.DataRetentionMonths = 1
c.Subscriptions.Enabled = true
until1 := CustomTime(time.Now().AddDate(0, 1, 0))
sut = &User{SubscribedUntil: &until1}
assert.Zero(t, sut.MinDataAge())
}

19
models/view/common.go Normal file
View File

@ -0,0 +1,19 @@
package view
type BasicViewModel interface {
SetError(string)
SetSuccess(string)
}
type Messages struct {
Success string
Error string
}
func (m *Messages) SetError(message string) {
m.Error = message
}
func (m *Messages) SetSuccess(message string) {
m.Success = message
}

View File

@ -1,18 +1,23 @@
package view package view
type Newsbox struct {
Type string `json:"type"`
Text string `json:"text"`
}
type HomeViewModel struct { type HomeViewModel struct {
Success string Messages
Error string
TotalHours int TotalHours int
TotalUsers int TotalUsers int
Newsbox *Newsbox
} }
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel { func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
s.Success = m s.SetSuccess(m)
return s return s
} }
func (s *HomeViewModel) WithError(m string) *HomeViewModel { func (s *HomeViewModel) WithError(m string) *HomeViewModel {
s.Error = m s.SetError(m)
return s return s
} }

View File

@ -1,18 +1,17 @@
package view package view
type ImprintViewModel struct { type ImprintViewModel struct {
Messages
HtmlText string HtmlText string
Success string
Error string
} }
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel { func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
s.Success = m s.SetSuccess(m)
return s return s
} }
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel { func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
s.Error = m s.SetError(m)
return s return s
} }

View File

@ -0,0 +1,83 @@
package view
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"strings"
"time"
)
type LeaderboardViewModel struct {
Messages
User *models.User
By string
Key string
Items []*models.LeaderboardItemRanked
TopKeys []string
UserLanguages map[string][]string
ApiKey string
PageParams *utils.PageParams
}
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
s.SetSuccess(m)
return s
}
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
s.SetError(m)
return s
}
func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItemRanked, principal *models.User) string {
if principal != nil && item.UserID == principal.ID {
return "self"
}
if item.Rank == 1 {
return "gold"
}
if item.Rank == 2 {
return "silver"
}
if item.Rank == 3 {
return "bronze"
}
return "default"
}
func (s *LeaderboardViewModel) LangIcon(lang string) string {
// https://icon-sets.iconify.design/mdi/
langs := map[string]string{
"c++": "language-cpp",
"cpp": "language-cpp",
"go": "language-go",
"haskell": "language-haskell",
"html": "language-html5",
"java": "language-java",
"javascript": "language-javascript",
"jsx": "language-javascript",
"kotlin": "language-kotlin",
"lua": "language-lua",
"php": "language-php",
"python": "language-python",
"r": "language-r",
"ruby": "language-ruby",
"rust": "language-rust",
"swift": "language-swift",
"typescript": "language-typescript",
"tsx": "language-typescript",
"markdown": "language-markdown",
"vue": "vuejs",
"react": "react",
"bash": "bash",
"json": "code-json",
}
if match, ok := langs[strings.ToLower(lang)]; ok {
return "mdi:" + match
}
return ""
}
func (s *LeaderboardViewModel) LastUpdate() time.Time {
return models.Leaderboard(s.Items).LastUpdate()
}

View File

@ -1,9 +1,9 @@
package view package view
type LoginViewModel struct { type LoginViewModel struct {
Success string Messages
Error string TotalUsers int
TotalUsers int AllowSignup bool
} }
type SetPasswordViewModel struct { type SetPasswordViewModel struct {
@ -12,11 +12,11 @@ type SetPasswordViewModel struct {
} }
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel { func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
s.Success = m s.SetSuccess(m)
return s return s
} }
func (s *LoginViewModel) WithError(m string) *LoginViewModel { func (s *LoginViewModel) WithError(m string) *LoginViewModel {
s.Error = m s.SetError(m)
return s return s
} }

View File

@ -1,15 +1,22 @@
package view package view
import "github.com/muety/wakapi/models" import (
"github.com/muety/wakapi/models"
"time"
)
type SettingsViewModel struct { type SettingsViewModel struct {
User *models.User Messages
LanguageMappings []*models.LanguageMapping User *models.User
Aliases []*SettingsVMCombinedAlias LanguageMappings []*models.LanguageMapping
Labels []*SettingsVMCombinedLabel Aliases []*SettingsVMCombinedAlias
Projects []string Labels []*SettingsVMCombinedLabel
Success string Projects []string
Error string SubscriptionPrice string
DataRetentionMonths int
UserFirstData time.Time
SupportContact string
ApiKey string
} }
type SettingsVMCombinedAlias struct { type SettingsVMCombinedAlias struct {
@ -23,12 +30,16 @@ type SettingsVMCombinedLabel struct {
Values []string Values []string
} }
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
return s.SubscriptionPrice != ""
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel { func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m s.SetSuccess(m)
return s return s
} }
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel { func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
s.Error = m s.SetError(m)
return s return s
} }

View File

@ -1,16 +1,26 @@
package view package view
import "github.com/muety/wakapi/models"
type SummaryViewModel struct { type SummaryViewModel struct {
Success string Messages
Error string *models.Summary
*models.SummaryParams
User *models.User
AvatarURL string
EditorColors map[string]string
LanguageColors map[string]string
OSColors map[string]string
ApiKey string
RawQuery string
} }
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel { func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
s.Success = m s.SetSuccess(m)
return s return s
} }
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel { func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
s.Error = m s.SetError(m)
return s return s
} }

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"scripts": {
"build": "npm run build:icons && npm run build:tailwind",
"build:tailwind": "tailwindcss build -i static/assets/css/app.css -o static/assets/css/app.dist.css --minify",
"build:icons": "node scripts/bundle_icons.js",
"build:all:compress": "npm run build && npm run compress",
"watch": "chokidar \"./views/**/*.html\" \"./static/assets/js/**/*.js\" \"./static/assets/css/**/*.css\" -i \"**/vendor/*\" -i \"**/*.dist.*\" -c \"npm run build\"",
"watch:compress": "chokidar \"./views/**/*.html\" \"./static/assets/js/**/*.js\" \"./static/assets/css/**/*.css\" -i \"**/vendor/*\" -i \"**/*.dist.*\" -c \"npm run build:all:compress\"",
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
},
"devDependencies": {
"@iconify/json": "^2.1.136",
"@iconify/json-tools": "^1.0.10",
"chokidar-cli": "^3.0.0",
"tailwindcss": "^3.1.8"
},
"dependencies": {}
}

View File

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "728a2979-6cb3-4b46-9be9-3273f3d20a3d", "_postman_id": "1043ce31-dc5c-4477-a74a-a29a0e1168b0",
"name": "Wakapi", "name": "Wakapi",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
@ -245,6 +245,41 @@
}, },
"response": [] "response": []
}, },
{
"name": "Get heartbeats",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
}
],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2021-02-10",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"heartbeats"
],
"query": [
{
"key": "date",
"value": "2021-02-10"
}
]
}
},
"response": []
},
{ {
"name": "Get stats", "name": "Get stats",
"request": { "request": {

View File

@ -1,7 +1,7 @@
package repositories package repositories
import ( import (
"errors" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@ -9,11 +9,12 @@ import (
) )
type HeartbeatRepository struct { type HeartbeatRepository struct {
db *gorm.DB db *gorm.DB
config *conf.Config
} }
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository { func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db} return &HeartbeatRepository{config: conf.Get(), db: db}
} }
// Use with caution!! // Use with caution!!
@ -77,6 +78,26 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
return heartbeats, nil return heartbeats, nil
} }
func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *models.User, filterMap map[string][]string) ([]*models.Heartbeat, error) {
// https://stackoverflow.com/a/20765152/3112139
var heartbeats []*models.Heartbeat
q := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from.Local()).
Where("time < ?", to.Local()).
Order("time asc")
for col, vals := range filterMap {
q = q.Where(col+" in ?", vals)
}
if err := q.Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) { func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser var result []*models.TimeByUser
r.db.Model(&models.User{}). r.db.Model(&models.User{}).
@ -97,12 +118,19 @@ func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
return result, nil return result, nil
} }
func (r *HeartbeatRepository) Count() (int64, error) { func (r *HeartbeatRepository) Count(approximate bool) (count int64, err error) {
var count int64 if r.config.Db.IsMySQL() && approximate {
if err := r.db. err = r.db.Table("information_schema.tables").
Model(&models.Heartbeat{}). Select("table_rows").
Count(&count).Error; err != nil { Where("table_schema = ?", r.config.Db.Name).
return 0, err Where("table_name = 'heartbeats'").
Scan(&count).Error
}
if count == 0 {
err = r.db.
Model(&models.Heartbeat{}).
Count(&count).Error
} }
return count, nil return count, nil
} }
@ -126,6 +154,10 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
userIds[i] = u.ID userIds[i] = u.ID
} }
if len(userIds) == 0 {
return counts, nil
}
if err := r.db. if err := r.db.
Model(&models.Heartbeat{}). Model(&models.Heartbeat{}).
Select("user_id as user, count(id) as count"). Select("user_id as user, count(id) as count").
@ -134,20 +166,15 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
Find(&counts).Error; err != nil { Find(&counts).Error; err != nil {
return counts, err return counts, err
} }
return counts, nil return counts, nil
} }
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) { func (r *HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
columns := []string{"project", "language", "editor", "operating_system", "machine"}
if int(entityType) >= len(columns) {
// invalid entity type
return nil, errors.New("invalid entity type")
}
var results []string var results []string
if err := r.db. if err := r.db.
Model(&models.Heartbeat{}). Model(&models.Heartbeat{}).
Distinct(columns[entityType]). Distinct(models.GetEntityColumn(entityType)).
Where(&models.Heartbeat{UserID: user.ID}). Where(&models.Heartbeat{UserID: user.ID}).
Find(&results).Error; err != nil { Find(&results).Error; err != nil {
return nil, err return nil, err
@ -163,3 +190,22 @@ func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
} }
return nil return nil
} }
func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
if err := r.db.
Where("user_id = ?", user.ID).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}
func (r *HeartbeatRepository) DeleteByUserBefore(user *models.User, t time.Time) error {
if err := r.db.
Where("user_id = ?", user.ID).
Where("time <= ?", t.Local()).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

View File

@ -34,6 +34,17 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
return kv, nil return kv, nil
} }
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
var keyValues []*models.KeyStringValue
if err := r.db.Table("key_string_values").
Where("`key` like ?", like).
Find(&keyValues).
Error; err != nil {
return nil, err
}
return keyValues, nil
}
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error { func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
result := r.db. result := r.db.
Clauses(clause.OnConflict{ Clauses(clause.OnConflict{

108
repositories/leaderboard.go Normal file
View File

@ -0,0 +1,108 @@
package repositories
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type LeaderboardRepository struct {
db *gorm.DB
}
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
return &LeaderboardRepository{db: db}
}
func (r *LeaderboardRepository) InsertBatch(items []*models.LeaderboardItem) error {
if err := r.db.
Clauses(clause.OnConflict{DoNothing: true}).
Create(&items).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Where("user_id = ?", userId).
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) CountUsers() (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Distinct("user_id").
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
// TODO: distinct by (user, key) to filter out potential duplicates ?
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq).Where("user_id = ?", userId)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *LeaderboardRepository) DeleteByUser(userId string) error {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.LeaderboardItem{}).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *models.IntervalKey) error {
if err := r.db.
Where("user_id = ?", userId).
Where("\"interval\" in ?", *key).
Delete(models.LeaderboardItem{}).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) withPaging(q *gorm.DB, limit, skip int) *gorm.DB {
if limit > 0 {
q = q.Where("\"rank\" <= ?", skip+limit)
}
if skip > 0 {
q = q.Where("\"rank\" > ?", skip)
}
return q
}

43
repositories/metrics.go Normal file
View File

@ -0,0 +1,43 @@
package repositories
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
type MetricsRepository struct {
config *config.Config
db *gorm.DB
}
const sizeTplMysql = `
SELECT SUM(data_length + index_length)
FROM information_schema.tables
WHERE table_schema = ?
GROUP BY table_schema`
const sizeTplPostgres = `SELECT pg_database_size('%s');`
const sizeTplSqlite = `
SELECT page_count * page_size as size
FROM pragma_page_count(), pragma_page_size();`
func NewMetricsRepository(db *gorm.DB) *MetricsRepository {
return &MetricsRepository{config: config.Get(), db: db}
}
func (srv *MetricsRepository) GetDatabaseSize() (size int64, err error) {
cfg := srv.config.Db
query := srv.db.Raw("SELECT 0")
if cfg.IsMySQL() {
query = srv.db.Raw(sizeTplMysql, cfg.Name)
} else if cfg.IsPostgres() {
query = srv.db.Raw(sizeTplPostgres, cfg.Name)
} else if cfg.IsSQLite() {
query = srv.db.Raw(sizeTplSqlite)
}
err = query.Scan(&size).Error
return size, err
}

View File

@ -20,15 +20,18 @@ type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
GetAll() ([]*models.Heartbeat, error) GetAll() ([]*models.Heartbeat, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetAllWithinByFilters(time.Time, time.Time, *models.User, map[string][]string) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error) GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error) GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error) Count(bool) (int64, error)
CountByUser(*models.User) (int64, error) CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error) CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error) GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
DeleteByUserBefore(*models.User, time.Time) error
} }
type IDiagnosticsRepository interface { type IDiagnosticsRepository interface {
@ -40,6 +43,7 @@ type IKeyValueRepository interface {
GetString(string) (*models.KeyStringValue, error) GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error PutString(*models.KeyStringValue) error
DeleteString(string) error DeleteString(string) error
Search(string) ([]*models.KeyStringValue, error)
} }
type ILanguageMappingRepository interface { type ILanguageMappingRepository interface {
@ -64,16 +68,16 @@ type ISummaryRepository interface {
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error) GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error) GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error DeleteByUser(string) error
DeleteByUserBefore(string, time.Time) error
} }
type IUserRepository interface { type IUserRepository interface {
GetById(string) (*models.User, error) FindOne(user models.User) (*models.User, error)
GetByIds([]string) ([]*models.User, error) GetByIds([]string) ([]*models.User, error)
GetByApiKey(string) (*models.User, error)
GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error) GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error) GetAllByReports(bool) ([]*models.User, error)
GetAllByLeaderboard(bool) ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error) GetByLoggedInAfter(time.Time) ([]*models.User, error)
GetByLastActiveAfter(time.Time) ([]*models.User, error) GetByLastActiveAfter(time.Time) ([]*models.User, error)
Count() (int64, error) Count() (int64, error)
@ -82,3 +86,13 @@ type IUserRepository interface {
UpdateField(*models.User, string, interface{}) (*models.User, error) UpdateField(*models.User, string, interface{}) (*models.User, error)
Delete(*models.User) error Delete(*models.User) error
} }
type ILeaderboardRepository interface {
InsertBatch([]*models.LeaderboardItem) error
CountAllByUser(string) (int64, error)
CountUsers() (int64, error)
DeleteByUser(string) error
DeleteByUserAndInterval(string, *models.IntervalKey) error
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
}

View File

@ -1,8 +1,10 @@
package repositories package repositories
import ( import (
"github.com/duke-git/lancet/v2/slice"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"time" "time"
) )
@ -18,14 +20,15 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
var summaries []*models.Summary var summaries []*models.Summary
if err := r.db. if err := r.db.
Order("from_time asc"). Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject). // branch summaries are currently not persisted, as only relevant in combination with project filter
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil { Find(&summaries).Error; err != nil {
return nil, err return nil, err
} }
if err := r.populateItems(summaries, []clause.Interface{}); err != nil {
return nil, err
}
return summaries, nil return summaries, nil
} }
@ -38,19 +41,29 @@ func (r *SummaryRepository) Insert(summary *models.Summary) error {
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) { func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}). queryConditions := []clause.Interface{
Where("from_time >= ?", from.Local()). clause.Where{Exprs: r.db.Statement.BuildCondition("user_id = ?", user.ID)},
Where("to_time <= ?", to.Local()). clause.Where{Exprs: r.db.Statement.BuildCondition("from_time >= ?", from.Local())},
Order("from_time asc"). clause.Where{Exprs: r.db.Statement.BuildCondition("to_time <= ?", to.Local())},
Preload("Projects", "type = ?", models.SummaryProject). }
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor). q := r.db.Model(&models.Summary{}).
Preload("OperatingSystems", "type = ?", models.SummaryOS). Order("from_time asc")
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil { for _, c := range queryConditions {
q.Statement.AddClause(c)
}
// branch summaries are currently not persisted, as only relevant in combination with project filter
if err := q.Find(&summaries).Error; err != nil {
return nil, err return nil, err
} }
if err := r.populateItems(summaries, queryConditions); err != nil {
return nil, err
}
return summaries, nil return summaries, nil
} }
@ -72,3 +85,46 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
} }
return nil return nil
} }
func (r *SummaryRepository) DeleteByUserBefore(userId string, t time.Time) error {
if err := r.db.
Where("user_id = ?", userId).
Where("to_time <= ?", t.Local()).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}
// inplace
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
var items []*models.SummaryItem
summaryMap := slice.GroupWith[*models.Summary, uint](summaries, func(s *models.Summary) uint {
return s.ID
})
q := r.db.Model(&models.SummaryItem{}).
Select("summary_items.*").
Joins("cross join summaries").
Where("summary_items.summary_id = summaries.id").
Where("num_heartbeats > ?", 0)
for _, c := range conditions {
q.Statement.AddClause(c)
}
if err := q.Find(&items).Error; err != nil {
return err
}
for _, item := range items {
if _, ok := summaryMap[item.SummaryID]; !ok {
continue
}
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)
*l = append(*l, item)
}
return nil
}

View File

@ -15,9 +15,9 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db} return &UserRepository{db: db}
} }
func (r *UserRepository) GetById(userId string) (*models.User, error) { func (r *UserRepository) FindOne(attributes models.User) (*models.User, error) {
u := &models.User{} u := &models.User{}
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil { if err := r.db.Where(&attributes).First(u).Error; err != nil {
return u, err return u, err
} }
return u, nil return u, nil
@ -34,39 +34,6 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
return users, nil return users, nil
} }
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
if key == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
if resetToken == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
if email == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetAll() ([]*models.User, error) { func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := r.db. if err := r.db.
@ -77,6 +44,17 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
return users, nil return users, nil
} }
func (r *UserRepository) GetMany(ids []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Where("id in ?", ids).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) { func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil { if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil {
@ -85,6 +63,14 @@ func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, e
return users, nil return users, nil
} }
func (r *UserRepository) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
var users []*models.User
if err := r.db.Where(&models.User{PublicLeaderboard: leaderboardEnabled}).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) { func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := r.db. if err := r.db.
@ -98,10 +84,9 @@ func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error)
// Returns a list of user ids, whose last heartbeat is not older than t // Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated // NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) { func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}). subQuery1 := r.db.Model(&models.Heartbeat{}).
Select("users.id as user, max(time) as time"). Select("user_id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id"). Group("user_id")
Group("user")
var userIds []string var userIds []string
if err := r.db. if err := r.db.
@ -126,7 +111,7 @@ func (r *UserRepository) Count() (int64, error) {
} }
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) { func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
if u, err := r.GetById(user.ID); err == nil && u != nil && u.ID != "" { if u, err := r.FindOne(models.User{ID: user.ID}); err == nil && u != nil && u.ID != "" {
return u, false, nil return u, false, nil
} }
@ -152,10 +137,14 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_machines": user.ShareMachines, "share_machines": user.ShareMachines,
"share_labels": user.ShareLabels, "share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey, "wakatime_api_key": user.WakatimeApiKey,
"wakatime_api_url": user.WakatimeApiUrl,
"has_data": user.HasData, "has_data": user.HasData,
"reset_token": user.ResetToken, "reset_token": user.ResetToken,
"location": user.Location, "location": user.Location,
"reports_weekly": user.ReportsWeekly, "reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
"subscribed_until": user.SubscribedUntil,
"stripe_customer_id": user.StripeCustomerId,
} }
result := r.db.Model(user).Updates(updateMap) result := r.db.Model(user).Updates(updateMap)

51
routes/api/avatar.go Normal file
View File

@ -0,0 +1,51 @@
package api
import (
"codeberg.org/Codeberg/avatars"
"github.com/gorilla/mux"
lru "github.com/hashicorp/golang-lru"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type AvatarHandler struct {
config *conf.Config
cache *lru.Cache
}
func NewAvatarHandler() *AvatarHandler {
cache, err := lru.New(1 * 1000 * 64) // assuming an avatar is 1 kb, allocate up to 64 mb of memory for avatars cache
if err != nil {
panic(err)
}
return &AvatarHandler{
config: conf.Get(),
cache: cache,
}
}
func (h *AvatarHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/avatar/{hash}.svg").Subrouter()
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
if utils.IsNoCache(r, 1*time.Hour) {
h.cache.Remove(hash)
}
if !h.cache.Contains(hash) {
h.cache.Add(hash, avatars.MakeAvatar(hash))
}
data, _ := h.cache.Get(hash)
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "max-age=2592000")
w.WriteHeader(http.StatusOK)
w.Write([]byte(data.(string)))
}

98
routes/api/badge.go Normal file
View File

@ -0,0 +1,98 @@
package api
import (
"fmt"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/slice"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/narqo/go-badge"
"github.com/patrickmn/go-cache"
"net/http"
"time"
)
type BadgeHandler struct {
config *conf.Config
cache *cache.Cache
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewBadgeHandler(userService services.IUserService, summaryService services.ISummaryService) *BadgeHandler {
return &BadgeHandler{
config: conf.Get(),
cache: cache.New(time.Hour, time.Hour),
userSrvc: userService,
summarySrvc: summaryService,
}
}
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/badge/{user}").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
interval, filters, err := routeutils.GetBadgeParams(r, user)
if err != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(err.Error()))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
noCache := utils.IsNoCache(r, 1*time.Hour)
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
respondSvg(w, cacheResult.([]byte))
return
}
params := &models.SummaryParams{
From: interval.Start,
To: interval.End,
User: user,
Filters: filters,
}
summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
badgeData := v1.NewBadgeDataFrom(summary)
if customLabel := r.URL.Query().Get("label"); customLabel != "" {
badgeData.Label = customLabel
}
if customColor := r.URL.Query().Get("color"); customColor != "" {
badgeData.Color = customColor
}
if badgeData.Color[0:1] != "#" && !slice.Contain(maputil.Keys(badge.ColorScheme), badgeData.Color) {
badgeData.Color = "#" + badgeData.Color
}
badgeSvg, err := badge.RenderBytes(badgeData.Label, badgeData.Message, badge.Color(badgeData.Color))
h.cache.SetDefault(cacheKey, badgeSvg)
respondSvg(w, badgeSvg)
}
func respondSvg(w http.ResponseWriter, data []byte) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(data)
}

View File

@ -2,14 +2,13 @@ package api
import ( import (
"encoding/json" "encoding/json"
"github.com/gorilla/mux" "github.com/muety/wakapi/helpers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
) )
type DiagnosticsApiHandler struct { type DiagnosticsApiHandler struct {
@ -28,9 +27,6 @@ func NewDiagnosticsApiHandler(userService services.IUserService, diagnosticsServ
func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) { func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/plugins/errors").Subrouter() r := router.PathPrefix("/plugins/errors").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post) r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post)
} }
@ -39,26 +35,17 @@ func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
// @Tags diagnostics // @Tags diagnostics
// @Accept json // @Accept json
// @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI" // @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI"
// @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/plugins/errors [post] // @Router /plugins/errors [post]
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) { func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var diagnostics models.Diagnostics var diagnostics models.Diagnostics
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil { if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(conf.ErrBadRequest)) w.Write([]byte(conf.ErrBadRequest))
conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err) conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err)
return return
} }
diagnostics.UserID = user.ID
if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil { if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -67,5 +54,5 @@ func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondJSON(w, r, http.StatusCreated, struct{}{}) helpers.RespondJSON(w, r, http.StatusCreated, struct{}{})
} }

View File

@ -2,9 +2,10 @@ package api
import ( import (
"fmt" "fmt"
"net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"gorm.io/gorm" "gorm.io/gorm"
"net/http"
) )
type HealthApiHandler struct { type HealthApiHandler struct {
@ -25,7 +26,7 @@ func (h *HealthApiHandler) RegisterRoutes(router *mux.Router) {
// @Tags misc // @Tags misc
// @Produce plain // @Produce plain
// @Success 200 {string} string // @Success 200 {string} string
// @Router /api/health [get] // @Router /health [get]
func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *HealthApiHandler) Get(w http.ResponseWriter, r *http.Request) {
var dbStatus int var dbStatus int
if sqlDb, err := h.db.DB(); err == nil { if sqlDb, err := h.db.DB(); err == nil {

View File

@ -1,8 +1,9 @@
package api package api
import ( import (
"bytes" "github.com/muety/wakapi/helpers"
"encoding/json" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
@ -10,8 +11,6 @@ import (
routeutils "github.com/muety/wakapi/routes/utils" routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"io/ioutil"
"net/http"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
@ -60,7 +59,7 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
// @Param heartbeat body models.Heartbeat true "A single heartbeat" // @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/heartbeat [post] // @Router /heartbeat [post]
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) { func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current") user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil { if err != nil {
@ -68,15 +67,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
} }
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
heartbeats, err = h.tryParseBulk(r) heartbeats, err = routeutils.ParseHeartbeats(r)
if err != nil { if err != nil {
heartbeats, err = h.tryParseSingle(r) conf.Log().Request(r).Error(err.Error())
if err != nil { w.WriteHeader(http.StatusBadRequest)
conf.Log().Request(r).Error(err.Error()) w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusBadRequest) return
w.Write([]byte(err.Error()))
return
}
} }
userAgent := r.Header.Get("User-Agent") userAgent := r.Header.Get("User-Agent")
@ -84,6 +80,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
machineName := r.Header.Get("X-Machine-Name") machineName := r.Header.Get("X-Machine-Name")
for _, hb := range heartbeats { for _, hb := range heartbeats {
if hb == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object"))
return
}
hb.OperatingSystem = opSys hb.OperatingSystem = opSys
hb.Editor = editor hb.Editor = editor
hb.Machine = machineName hb.Machine = machineName
@ -91,7 +93,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
hb.UserID = user.ID hb.UserID = user.ID
hb.UserAgent = userAgent hb.UserAgent = userAgent
if !hb.Valid() { if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object")) w.Write([]byte("invalid heartbeat object"))
return return
@ -103,7 +105,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil { if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to batch-insert heartbeats %v", err) conf.Log().Request(r).Error("failed to batch-insert heartbeats - %v", err)
return return
} }
@ -112,44 +114,14 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to update user %v", err) conf.Log().Request(r).Error("failed to update user - %v", err)
return return
} }
} }
defer func() {}() defer func() {}()
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats))) helpers.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
}
func (h *HeartbeatApiHandler) tryParseBulk(r *http.Request) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
if err := dec.Decode(&heartbeats); err != nil {
return nil, err
}
return heartbeats, nil
}
func (h *HeartbeatApiHandler) tryParseSingle(r *http.Request) ([]*models.Heartbeat, error) {
var heartbeat models.Heartbeat
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
if err := dec.Decode(&heartbeat); err != nil {
return nil, err
}
return []*models.Heartbeat{&heartbeat}, nil
} }
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288) // construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
@ -180,9 +152,10 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat" // @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/v1/users/{user}/heartbeats [post] // @Router /v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias1() {} func (h *HeartbeatApiHandler) postAlias1() {}
// @Summary Push a new heartbeat // @Summary Push a new heartbeat
@ -190,9 +163,10 @@ func (h *HeartbeatApiHandler) postAlias1() {}
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat" // @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/compat/wakatime/v1/users/{user}/heartbeats [post] // @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias2() {} func (h *HeartbeatApiHandler) postAlias2() {}
// @Summary Push a new heartbeat // @Summary Push a new heartbeat
@ -200,9 +174,10 @@ func (h *HeartbeatApiHandler) postAlias2() {}
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat" // @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/users/{user}/heartbeats [post] // @Router /users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias3() {} func (h *HeartbeatApiHandler) postAlias3() {}
// @Summary Push new heartbeats // @Summary Push new heartbeats
@ -212,7 +187,7 @@ func (h *HeartbeatApiHandler) postAlias3() {}
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" // @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/heartbeats [post] // @Router /heartbeats [post]
func (h *HeartbeatApiHandler) postAlias4() {} func (h *HeartbeatApiHandler) postAlias4() {}
// @Summary Push new heartbeats // @Summary Push new heartbeats
@ -220,9 +195,10 @@ func (h *HeartbeatApiHandler) postAlias4() {}
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" // @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/v1/users/{user}/heartbeats.bulk [post] // @Router /v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias5() {} func (h *HeartbeatApiHandler) postAlias5() {}
// @Summary Push new heartbeats // @Summary Push new heartbeats
@ -230,9 +206,10 @@ func (h *HeartbeatApiHandler) postAlias5() {}
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" // @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/compat/wakatime/v1/users/{user}/heartbeats.bulk [post] // @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias6() {} func (h *HeartbeatApiHandler) postAlias6() {}
// @Summary Push new heartbeats // @Summary Push new heartbeats
@ -240,7 +217,8 @@ func (h *HeartbeatApiHandler) postAlias6() {}
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" // @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /api/users/{user}/heartbeats.bulk [post] // @Router /users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias7() {} func (h *HeartbeatApiHandler) postAlias7() {}

View File

@ -5,12 +5,13 @@ import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics" mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"runtime" "runtime"
"sort" "sort"
@ -36,9 +37,13 @@ const (
DescAdminTotalUsers = "Total number of registered users." DescAdminTotalUsers = "Total number of registered users."
DescAdminActiveUsers = "Number of active users." DescAdminActiveUsers = "Number of active users."
DescJobQueueEnqueued = "Number of jobs currently enqueued"
DescJobQueueTotalFinished = "Total number of processed jobs"
DescMemAllocTotal = "Total number of bytes allocated for heap" DescMemAllocTotal = "Total number of bytes allocated for heap"
DescMemSysTotal = "Total number of bytes obtained from the OS" DescMemSysTotal = "Total number of bytes obtained from the OS"
DescGoroutines = "Total number of running goroutines" DescGoroutines = "Total number of running goroutines"
DescDatabaseSize = "Total database size in bytes"
) )
type MetricsHandler struct { type MetricsHandler struct {
@ -47,14 +52,16 @@ type MetricsHandler struct {
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService keyValueSrvc services.IKeyValueService
metricsRepo *repositories.MetricsRepository
} }
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler { func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService, metricsRepo *repositories.MetricsRepository) *MetricsHandler {
return &MetricsHandler{ return &MetricsHandler{
userSrvc: userService, userSrvc: userService,
summarySrvc: summaryService, summarySrvc: summaryService,
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,
metricsRepo: metricsRepo,
config: conf.Get(), config: conf.Get(),
} }
} }
@ -116,15 +123,15 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) { func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics var metrics mm.Metrics
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, false) summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, nil, false)
if err != nil { if err != nil {
logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID) logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID)
return nil, err return nil, err
} }
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ()) from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false) summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
if err != nil { if err != nil {
logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID) logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID)
return nil, err return nil, err
@ -138,77 +145,77 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
// User Metrics // User Metrics
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_cumulative_seconds_total", Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime, Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds), Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_seconds_total", Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal, Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()), Value: int64(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_heartbeats_total", Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats, Desc: DescHeartbeats,
Value: int(heartbeatCount), Value: int64(heartbeatCount),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
for _, p := range summaryToday.Projects { for _, p := range summaryToday.Projects {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_project_seconds_total", Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects, Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}}, Labels: []mm.Label{{Key: "name", Value: p.Key}},
}) })
} }
for _, l := range summaryToday.Languages { for _, l := range summaryToday.Languages {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_language_seconds_total", Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages, Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}}, Labels: []mm.Label{{Key: "name", Value: l.Key}},
}) })
} }
for _, e := range summaryToday.Editors { for _, e := range summaryToday.Editors {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_editor_seconds_total", Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors, Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}}, Labels: []mm.Label{{Key: "name", Value: e.Key}},
}) })
} }
for _, o := range summaryToday.OperatingSystems { for _, o := range summaryToday.OperatingSystems {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_operating_system_seconds_total", Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems, Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}}, Labels: []mm.Label{{Key: "name", Value: o.Key}},
}) })
} }
for _, m := range summaryToday.Machines { for _, m := range summaryToday.Machines {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_machine_seconds_total", Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines, Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}}, Labels: []mm.Label{{Key: "name", Value: m.Key}},
}) })
} }
for _, m := range summaryToday.Labels { for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_label_seconds_total", Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels, Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}}, Labels: []mm.Label{{Key: "name", Value: m.Key}},
}) })
} }
@ -217,27 +224,57 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
var memStats runtime.MemStats var memStats runtime.MemStats
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_goroutines_total", Name: MetricsPrefix + "_goroutines_total",
Desc: DescGoroutines, Desc: DescGoroutines,
Value: runtime.NumGoroutine(), Value: int64(runtime.NumGoroutine()),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_mem_alloc_total", Name: MetricsPrefix + "_mem_alloc_total",
Desc: DescMemAllocTotal, Desc: DescMemAllocTotal,
Value: int(memStats.Alloc), Value: int64(memStats.Alloc),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_mem_sys_total", Name: MetricsPrefix + "_mem_sys_total",
Desc: DescMemSysTotal, Desc: DescMemSysTotal,
Value: int(memStats.Sys), Value: int64(memStats.Sys),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
// Database metrics
dbSize, err := h.metricsRepo.GetDatabaseSize()
if err != nil {
logbuch.Warn("failed to get database size (%v)", err)
}
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_db_total_bytes",
Desc: DescDatabaseSize,
Value: dbSize,
Labels: []mm.Label{},
})
// Miscellaneous
for _, qm := range conf.GetQueueMetrics() {
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_queue_jobs_enqueued",
Value: int64(qm.EnqueuedJobs),
Desc: DescJobQueueEnqueued,
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_queue_jobs_total_finished",
Value: int64(qm.FinishedJobs),
Desc: DescJobQueueTotalFinished,
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
})
}
return &metrics, nil return &metrics, nil
} }
@ -256,39 +293,39 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
} }
totalUsers, _ := h.userSrvc.Count() totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count() totalHeartbeats, _ := h.heartbeatSrvc.Count(true)
activeUsers, err := h.userSrvc.GetActive(false) activeUsers, err := h.userSrvc.GetActive(false)
if err != nil { if err != nil {
logbuch.Error("failed to retrieve active users for metric %v", err) logbuch.Error("failed to retrieve active users for metric - %v", err)
return nil, err return nil, err
} }
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_seconds_total", Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime, Desc: DescAdminTotalTime,
Value: totalSeconds, Value: int64(totalSeconds),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_heartbeats_total", Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats, Desc: DescAdminTotalHeartbeats,
Value: int(totalHeartbeats), Value: totalHeartbeats,
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_users_total", Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers, Desc: DescAdminTotalUsers,
Value: int(totalUsers), Value: totalUsers,
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_users_active_total", Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers, Desc: DescAdminActiveUsers,
Value: len(activeUsers), Value: int64(len(activeUsers)),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
@ -301,10 +338,10 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
} }
for _, uc := range userCounts { for _, uc := range userCounts {
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total", Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats, Desc: DescAdminUserHeartbeats,
Value: int(uc.Count), Value: uc.Count,
Labels: []mm.Label{{Key: "user", Value: uc.User}}, Labels: []mm.Label{{Key: "user", Value: uc.User}},
}) })
} }

View File

@ -1,13 +1,14 @@
package api package api
import ( import (
"github.com/muety/wakapi/helpers"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
) )
type SummaryApiHandler struct { type SummaryApiHandler struct {
@ -36,20 +37,26 @@ func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
// @ID get-summary // @ID get-summary
// @Tags summary // @Tags summary
// @Produce json // @Produce json
// @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any) // @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 6_months, last_6_months, 12_months, last_12_months, last_year, any, all_time)
// @Param from query string false "Start date (e.g. '2021-02-07')" // @Param from query string false "Start date (e.g. '2021-02-07')"
// @Param to query string false "End date (e.g. '2021-02-08')" // @Param to query string false "End date (e.g. '2021-02-08')"
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache" // @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
// @Param project query string false "Project to filter by"
// @Param language query string false "Language to filter by"
// @Param editor query string false "Editor to filter by"
// @Param operating_system query string false "OS to filter by"
// @Param machine query string false "Machine to filter by"
// @Param label query string false "Project label to filter by"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 200 {object} models.Summary // @Success 200 {object} models.Summary
// @Router /api/summary [get] // @Router /summary [get]
func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
summary, err, status := su.LoadUserSummary(h.summarySrvc, r) summary, err, status := routeutils.LoadUserSummary(h.summarySrvc, r)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return return
} }
utils.RespondJSON(w, r, http.StatusOK, summary) helpers.RespondJSON(w, r, http.StatusOK, summary)
} }

Some files were not shown because too many files have changed in this diff Show More