Compare commits

...

321 Commits

Author SHA1 Message Date
Ferdinand Mütsch
c320afaf3b chore: improved oidc error logging 2025-10-12 00:06:04 +02:00
Ferdinand Mütsch
140f8b2eac test: oidc endpoints 2025-10-11 23:50:00 +02:00
Ferdinand Mütsch
6880d4d524 test: auth middleware oidc methods 2025-10-10 12:21:35 +02:00
Ferdinand Mütsch
a07a4f48b4 feat: finish up oidc implementation (resolve #33) 2025-10-10 08:27:32 +02:00
Ferdinand Mütsch
faa6312cd8 feat(wip): prevent password reset for non-local users 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
cfa4a7a6ea feat(wip): implement oidc user account creation 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
e8420f8114 feat(wip): oidc login ui 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
50b8114959 chore: fix minor rebase errors 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
76af6981d5 feat(wip): implement openid connect login 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
78ef6b81db chore: minor refactorings to shared data middleware logic 2025-10-05 23:37:46 +02:00
Ferdinand Mütsch
c952184324 fix: migration for mariadb 2025-10-05 23:37:25 +02:00
Ferdinand Mütsch
068223839c fix: user model consistency checks 2025-10-05 23:07:24 +02:00
Ferdinand Mütsch
aea01fd51d breaking_change: drop mssql support 2025-10-04 21:16:09 +02:00
Ferdinand Mütsch
1b03043ebd chore: upgrade and clean dependencies 2025-10-04 20:57:59 +02:00
Ferdinand Mütsch
8b95cd5d9f fix: nil pointer when accessing non-existing shared data 2025-10-04 20:55:01 +02:00
Ferdinand Mütsch
b2fd840403 refactor: replace principal middleware by generic shared data middleware 2025-10-04 20:45:18 +02:00
Ferdinand Mütsch
3c54f5ecc9 docs: code comments on language mapping resolution (see #845) [skip ci] 2025-09-19 11:50:22 +02:00
Ferdinand Mütsch
213df9e00b chore: minor error logging [skip ci] 2025-09-16 01:02:22 +02:00
Ferdinand Mütsch
3154cd8519 fix: make summaries display actual data range or requested interval if no data present
fix: use unix era start time in wakatime compat endpoints (resolve #843)
2025-09-16 00:40:48 +02:00
Ferdinand Mütsch
849d7a8996 fix: summary from date display (resolve #843) 2025-09-16 00:02:33 +02:00
Ferdinand Mütsch
b12f76ec0f fix: decrease batch insert chunk size for sqlite compatibility (resolve #840) 2025-09-11 20:14:35 +02:00
Ferdinand Mütsch
029d1304f8 docs: update swagger docs [skip ci] 2025-09-11 08:37:26 +02:00
Ferdinand Mütsch
2082624132 chore: replace precomputed user heartbeat ranges by grouping view query 2025-09-07 21:45:42 +02:00
Ferdinand Mütsch
73d7213656 Merge branch 'fork/k2on/add-nix-icon' 2025-09-07 19:22:48 +02:00
Ferdinand Mütsch
74946953c5 fix: prevent overlapping duration regeneration jobs 2025-09-07 08:00:34 +02:00
Max Koon
794d49ad67 Delete wakapi.yml 2025-09-05 23:08:35 -04:00
Max Koon
7aa0773745 fix: bundle new nix icon 2025-09-05 23:05:32 -04:00
Max Koon
e7ff774343 feat: add nix language icon 2025-09-05 16:59:29 -04:00
Ferdinand Mütsch
ae51a762e1 Merge branch 'fork/k2on/theming' 2025-09-05 14:09:21 +02:00
Ferdinand Mütsch
eb169695d7 feat(compat): implement wakatime user agents endpoint (resolve #833) 2025-09-05 14:06:32 +02:00
Max Koon
df81cb34a2 refactor: add alpha value to all colors and fix index digits 2025-09-05 07:49:53 -04:00
Ferdinand Mütsch
57ddbfbd8b ci: build with latest go 1.25 experimental features [skip ci] 2025-09-05 11:08:57 +02:00
Ferdinand Mütsch
72663839eb fix: respect aliases when filtering by project labels (resolve #836) 2025-09-05 10:45:34 +02:00
Ferdinand Mütsch
5f25bd7e99 fix: make db migration script account for unhashed heartbeats [skip ci] 2025-09-05 09:40:11 +02:00
Ferdinand Mütsch
b4aa96ae2f perf: support mysql compressed connections 2025-09-02 21:28:49 +02:00
Ferdinand Mütsch
a060a73098 chore: bash script to export and import mysql tables as tsv [skip ci] 2025-09-02 09:56:15 +02:00
Max Koon
57d7be61fc Merge branch 'master' into theming 2025-09-01 15:41:10 -04:00
Max Koon
92bcfeeb10 refactor: change the rest of the colors 2025-09-01 15:37:44 -04:00
Ferdinand Mütsch
f6ba56e2a5 chore: less verbose info logging for active users [skip ci] 2025-09-01 19:54:25 +02:00
Max Koon
c41ae4a1a6 fix: bg to focused 2025-09-01 13:04:45 -04:00
Max Koon
b88a78da59 fix: add focus var 2025-09-01 13:01:27 -04:00
Max Koon
6d09ce7903 change: gray-800 from card to background 2025-09-01 12:36:12 -04:00
Max Koon
95f465ae30 refactor: color variables 2025-09-01 12:02:52 -04:00
Ferdinand Mütsch
cfe69df9ff chore: upgrade to go 1.25 2025-08-30 22:44:25 +02:00
Ferdinand Mütsch
3a74d4db7f feat: extend db migration script to filter by individual users [skip ci] 2025-08-29 15:33:29 +02:00
Ferdinand Mütsch
191936ec23 fix: proper usage of sqlite driver
fix: improve sqlite date parsing robustness
2025-08-29 15:33:29 +02:00
Ferdinand Mütsch
44bda0f209 docs: update readme [skip ci] 2025-08-29 15:33:29 +02:00
Ferdinand Mütsch
5f42c12933 chore: use wal journal mode for sqlite [skip ci] 2025-08-25 08:21:08 +02:00
Ferdinand Mütsch
e1a62ffc99 chore: ability to toggle certain entity types in db migration script [skip-ci] 2025-08-22 15:57:22 +02:00
Ferdinand Mütsch
9cd322a231 refactor: generic db migrations script
chore: various convenience repository methods
chore: make scripts folder its own module
2025-08-22 15:49:26 +02:00
Ferdinand Mütsch
4050c31b02 Merge branch 'fork/NazmusSayad/start-of-week-customization' 2025-08-22 14:13:29 +02:00
Ferdinand Mütsch
d021f57516 chore: remove unnecessary migration 2025-08-22 14:13:16 +02:00
Ferdinand Mütsch
87e332d5c5 chore: load testing tool [skip-ci] 2025-08-21 08:55:11 +02:00
Nazmus Sayad
bfd137e634 feat: add user preference for start of week 2025-08-18 22:40:45 +06:00
Ferdinand Mütsch
1c47ebf636 Merge remote-tracking branch 'origin/master' 2025-08-15 12:16:10 +02:00
Ferdinand Mütsch
4b02b6bdf5 chore: config option to skip mx dns record validation (resolve #826) 2025-08-15 11:55:52 +02:00
Henri Burau
ae66e69a1d remove debug log entry 2025-08-14 20:02:30 +10:00
Ferdinand Mütsch
850c176b7f chore: consistent indentation [skip-ci] 2025-08-09 11:33:29 +02:00
Ferdinand Mütsch
ad154294c8 fix: accept partially valid batch of heartbeats (resolve #824) 2025-08-08 18:20:11 +02:00
Ferdinand Mütsch
8b8fe21fed fix: assume coding as default category if not specified otherwise (see #817) 2025-08-02 11:06:39 +02:00
Ferdinand Mütsch
7f281184de chore: detect wsl as separate os (resolve #817, #718) 2025-08-02 11:03:15 +02:00
Ferdinand Mütsch
70eafc3144 chore: cockroach deprecation warning 2025-07-18 10:40:27 +02:00
Ferdinand Mütsch
4ffde946d5 chore: disable mysql table optimization 2025-07-18 10:37:54 +02:00
Ferdinand Mütsch
1e0ef43d92 chore: upgrade dependencies 2025-07-18 10:28:11 +02:00
Ferdinand Mütsch
700921406e feat: automatic background database vacuuming and table optimization (resolve #785) 2025-07-18 10:26:41 +02:00
Ferdinand Mütsch
9f925b7f02 ci: pin runner image ubuntu version to 24.04 2025-07-18 08:57:18 +02:00
jstoparczyk
7eab383276 migrate from post-requests tests to actual tests and (mostly) assertions
notable change: expect  fields to be of type array and length of 0, rather than only expecting their length to be 0
2025-06-27 19:00:14 +02:00
jstoparczyk
52925a3532 migrate from post-requests tests to actual tests and (mostly) assertions 2025-06-27 18:55:32 +02:00
jstoparczyk
bf69b81b62 rename default environment from prod to dev; make BASE_URL non-secret and add a default value for it; modify collection documentation accordingly to changes above 2025-06-27 14:18:37 +02:00
jstoparczyk
8212686c64 remove console.log's 2025-06-24 18:00:26 +02:00
jstoparczyk
4d210a719a replace newman with bruno cli and add installation of bruno cli to migration job in ci.yml 2025-06-24 17:41:45 +02:00
jstoparczyk
53743216dc replace newman run with bru run in the run_api_tests.sh script 2025-06-24 17:36:45 +02:00
jstoparczyk
970d9b3276 port wakapi_api_tests.postman_collection.json collections to Bruno format 2025-06-24 17:35:03 +02:00
jstoparczyk
4b0676881d update README 2025-06-24 17:29:47 +02:00
jstoparczyk
80198c87eb port wakapi.postman_collection.json to bruno format 2025-06-23 23:57:42 +02:00
Ferdinand Mütsch
509c96e9a6 feat: allow delegate signup to trusted proxy (resolve #808) 2025-06-17 17:00:22 +02:00
Ferdinand Mütsch
f564c3c6be Merge pull request #805 from muety/ci-docker
fix(ci): ARG for TARGETOS and TARGETARCH
2025-06-13 15:56:20 +02:00
Steven Tang
3f11202e54 fix(ci): ARG for TARGETOS and TARGETARCH
Resolves #804
2025-06-13 19:30:07 +10:00
Ferdinand Mütsch
632020c30b feat: current online users count (resolve #798) 2025-06-13 08:53:48 +02:00
Ferdinand Mütsch
fcf130b7a8 fix: database status in health endpoint [skip ci] 2025-06-10 08:56:17 +02:00
Ferdinand Mütsch
8b9c3e36c9 fix: race condition during summary reaggregation (see #801) 2025-06-10 08:52:55 +02:00
Ferdinand Mütsch
c6b8bf7b67 chore: conditionally return json or plain text from health endpoint 2025-06-10 07:35:32 +02:00
kurtnettle
5891681c24 feat(api): return health check as JSON 2025-06-10 01:32:58 +06:00
Ferdinand Mütsch
be4e59c548 Merge branch 'master' of github.com:muety/wakapi
# Conflicts:
#	coverage/coverage.out
2025-05-30 23:49:53 +02:00
Ferdinand Mütsch
d2d9a2cfa3 fix(wip): duplicate leaderboard entries due to inconsistent language spelling 2025-05-30 23:48:53 +02:00
Ferdinand Mütsch
39e12a2e70 chore: exclude changing assets from server push [skip ci] 2025-05-26 16:52:08 +02:00
Ferdinand Mütsch
50ab1ad26a ci: attempt to properly exclude files from sonarqube checks 2025-05-26 16:43:32 +02:00
Ferdinand Mütsch
7752027dd6 chore: use more aggressive cache busting at dev time (see #795) 2025-05-26 16:23:44 +02:00
Ferdinand Mütsch
8922daca42 chore: cache busting for frequently changing css and scripts (resolve #795) 2025-05-26 16:15:28 +02:00
Ferdinand Mütsch
544e0e4f90 chore: upgrade dependencies
fix: mssql docker testing tls certificate error
2025-05-26 16:09:46 +02:00
Ferdinand Mütsch
a678494f46 fix: panic when attempting to filter by category (resolve #796) 2025-05-26 16:09:24 +02:00
Ferdinand Mütsch
4853e8a264 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	coverage/coverage.out
2025-05-20 21:08:35 +02:00
Ferdinand Mütsch
d42c522c6b feat: auto-redirect api root route to frontpage for browsers (resolve #788) 2025-05-20 21:08:22 +02:00
Ferdinand Mütsch
ff287eaa12 fix: update test fixtures schema (resolve #786) 2025-05-10 17:02:04 +02:00
Ferdinand Mütsch
8c8ae5d7ee fix: purge user durations as part of data cleanup (relates to #785) 2025-05-10 11:16:46 +02:00
Ferdinand Mütsch
ca3035b14b fix: improve robustness of durations key column migration 2025-05-10 10:55:32 +02:00
Ferdinand Mütsch
66f4360b0c fix: include not null constraints for heartbeats customtime columns 2025-05-03 21:06:48 +02:00
Ferdinand Mütsch
78c2cc7593 chore: upgrade dependencies 2025-05-03 20:38:18 +02:00
Ferdinand Mütsch
c75785c6a8 docs: reformat readme 2025-05-03 20:33:10 +02:00
Ferdinand Mütsch
af2011b7c4 chore: minor additions to hourly breakdown chart 2025-05-03 20:28:24 +02:00
Ferdinand Mütsch
aa8a1d933c Merge branch 'fork/justin-jiajia/master' 2025-05-03 20:27:34 +02:00
jiajia
12afba7464 feat: support zooming functionalitiy for hourly breakdown chart 2025-05-01 20:47:12 +08:00
Ferdinand Mütsch
f9f7c3da71 Merge pull request #781 from shyim/improve-docker-build-speed
ci: improve docker build speed
2025-04-30 21:18:53 +02:00
Soner Sayakci
b6df2ce427 ci: improve docker build speed 2025-04-30 17:30:13 +02:00
jiajia
db368eb8c0 fix: update date and time formatting in timeline chart ticks for better readability 2025-04-30 20:53:17 +08:00
Ferdinand Mütsch
f58653f1ba fix: infinite loop caused by timezone weirdness in date range splitting (resolve #779) 2025-04-30 14:39:48 +02:00
Ferdinand Mütsch
06923b6313 fix(ui): make date picker min and max dates reactive (resolve #780) 2025-04-28 20:22:52 +02:00
Ferdinand Mütsch
84365442b0 fix: add non-natural primary key to durations table (resolve #777) 2025-04-25 18:22:02 +02:00
Ferdinand Mütsch
e7da40b307 chore: minor code style-related changes to the timeline chart 2025-04-25 08:22:37 +02:00
Ferdinand Mütsch
afc6d5853c chore: high memory usage debugging script [skip ci] 2025-04-25 07:36:49 +02:00
Ferdinand Mütsch
9843a406cc chore: use docker init system for running wakapi to fight zombies (resolve #713) [skip ci] 2025-04-23 14:46:33 +02:00
jiajia
165467931f support alias for hourly breakdown 2025-04-22 19:12:01 +08:00
jiajia
1f5e3a40be Merge remote-tracking branch 'upstream/master' 2025-04-21 20:22:48 +08:00
jiajia
0d6e9f9c90 Update a few names missed by last commit 2025-04-21 20:22:32 +08:00
jiajia
bd661d58c6 refactor summary view: replace 'dailystats' with 'timeline', while 'timeline' move to 'hourly breakdown' to align with the UI & show only 24 hours of hourly breakdown & refactor the hourly breakdown by only chartjs 2025-04-21 20:01:03 +08:00
jiajia
0f14b745b8 Add support for timeline 2025-04-13 17:50:23 +08:00
Ferdinand Mütsch
a842467067 fix(tests): another attempt to fix smtp tests 2025-04-11 14:58:58 +02:00
Ferdinand Mütsch
4fc605c782 fix: allow to run container as non-root user again (resolve #775) 2025-04-11 14:34:13 +02:00
Ferdinand Mütsch
5a2994fe47 fix: smtp tests with latest smtp4dev version 2025-04-11 14:02:11 +02:00
Ferdinand Mütsch
4ca7d79112 Merge pull request #773 from ycsh-w/msg-usr-name-invalid
Improve error message when signing up a user with invalid name
2025-04-07 07:35:05 +02:00
ycsh
00a45b90f7 specifically deal with invalid user name 2025-04-06 16:28:21 -07:00
ycsh
b25d05c43b auto reformat 2025-04-06 16:22:55 -07:00
Ferdinand Mütsch
b3668085c0 docs: comments [skip ci] 2025-03-26 21:54:37 +01:00
Ferdinand Mütsch
14fae4a3c8 fix: workaround for postgres timestamp issue (resolve #761) 2025-03-26 21:47:57 +01:00
Ferdinand Mütsch
bc2096f411 fix: properly expose docker secrets to wakapi process (resolve #769) 2025-03-23 11:19:27 +01:00
Ferdinand Mütsch
f21441ae1b fix: critical bug in cached durations aggregation (resolve #766) 2025-03-18 16:27:22 +01:00
Ferdinand Mütsch
43cc7ce1b0 fix: parse strange hbulderx user (resolve #765) 2025-03-17 17:06:20 +01:00
Ferdinand Mütsch
04491ec126 fix: duration overlap check (resolve #761) 2025-03-14 14:35:10 +01:00
Ferdinand Mütsch
e8f7d9b789 chore: minor changes to error displaying and page layout 2025-03-14 14:29:28 +01:00
Ferdinand Mütsch
8427c97d18 fix: various minor logging related fixes 2025-03-13 09:36:37 +01:00
Ferdinand Mütsch
9985e8d9de fix: fill missing category of browsing heartbeats (resolve #760) 2025-03-13 08:52:24 +01:00
Ferdinand Mütsch
9856994e74 docs: mention tparse [skip ci] 2025-03-12 17:09:14 +01:00
Ferdinand Mütsch
399cbb4463 chore: upgrade dependencies 2025-03-09 21:55:15 +01:00
Ferdinand Mütsch
70660cf154 chore: thread safety for request enhanced log lines [skip ci] 2025-03-09 21:50:50 +01:00
Ferdinand Mütsch
3f6c3c2c6d Merge pull request #758 from Darneus/master
allow smtp with no auth
2025-03-05 16:15:50 +01:00
Honza Kosák
ded8d2b46c fix: allow smtp with no auth 2025-03-05 12:48:11 +01:00
Ferdinand Mütsch
d37f25e68a fix: resolve faulty data for project details view (resolve #756) 2025-03-03 07:59:42 +01:00
Ferdinand Mütsch
8edb93853a Merge pull request #757 from krishnans2006/master [skip ci]
Add .env to .gitignore
2025-03-03 07:20:07 +01:00
Krishnan Shankar
94fb209639 Add .env to .gitignore 2025-03-02 17:47:55 -06:00
Ferdinand Mütsch
51208a133a fix: panic during summary aggregation (resolve #754) 2025-02-28 08:45:03 +01:00
Ferdinand Mütsch
f5d3f354e3 fix: index error during entity type deref 2025-02-28 07:08:15 +01:00
Ferdinand Mütsch
fde25948ab fix: compute leaderboard with consistent time interval (resolve #749) 2025-02-26 23:45:51 +01:00
Ferdinand Mütsch
1bd00f7209 ci: exclude external code from sonar analyses
chore: minor code changes
2025-02-24 07:48:05 +01:00
Ferdinand Mütsch
898154c5db refactor: language mapping augmentation of durations 2025-02-21 14:25:36 +01:00
Ferdinand Mütsch
3ee63bba65 test: update test database schema 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
b27e9bb083 fix: deadlock caused by not using open transaction during batch insert
fix: regenerate durations upon language mapping update
fix: minor ui
2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
7b7f5e94bd fix: include most prominent entity for each duration 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
71b18f1aaf fix: summary regeneration
feat: introduce multi-interval durations
2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
f9835fde71 chore(perf): generate and persist durations incrementally 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
77bb01020f fix: regenerate durations upon summary regeneration 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
ee9dd9688c chore: duration service method for regenerating by user 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
a97c233f0a chore: update migration tool 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
0ba7a838e8 chore: implement duration streaming repo methods 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
b210b4d82c fix: durations merging logic
test: cached durations retrieval
2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
48d534ba9e chore(perf): query filtered durations 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
0351d497fa refactor: make durations a persistent yet ephemeral database entity for query filter speedup (resolve #716) 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
3fc95be291 fix: tests 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
dca35946d4 chore: streamed version of filtered heartbeats repo method
chore: minor refactoring
2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
a13b9a96dd chore: minor refactoring of repo methods
chore: replace deprecated hashstructure library
perf: remove double duration hashing
perf: replace flv by xxhash
2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
95a0dd794b refactor: stream heartbeats from database asynchronously for getall 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
0ce55d26fe chore: migrate user heartbeats timeout preferences 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
bf6b1f854b docs: update readme to match refactored durations logic 2025-02-21 11:29:13 +01:00
Ferdinand Mütsch
8d3a049f4d refactor(wip): drop heartbeat padding in summary aggregation (see #675) 2025-02-21 11:29:11 +01:00
Ferdinand Mütsch
2b3f1d9ef4 fix: time zone issues in case database and server timezones vary 2025-02-21 10:39:53 +01:00
Ferdinand Mütsch
c48bdfca7e chore: update db migration tool (see #746) [skip ci] 2025-02-20 17:24:10 +01:00
Ferdinand Mütsch
bb484c42f3 chore: go 1.24 2025-02-18 22:27:21 +01:00
Ferdinand Mütsch
58c2fecd9d fix: update fields exposed by heartbeats get compat endpoint
fix: update fields used by download script
feat: provide heartbeats csv upload script (resolve #745)
2025-02-18 22:21:27 +01:00
Steven Tang
2ac63bc391 chore: docker/build-push-action v6, remove workflow_dispatch 2025-02-12 22:22:56 +11:00
Ferdinand Mütsch
9735f11c26 Merge pull request #742 from justin-jiajia/master
make timeline chart respect vibrant colors flag
2025-02-06 14:11:45 +01:00
jiajia
fc56c8595b let calendar view respect "Vibrant Colors" & move the drawDailyProjectChart into draw(subselection) 2025-02-06 17:28:05 +08:00
Ferdinand Mütsch
b9c5db26d6 Merge branch 'fork/justin-jiajia/master' (resolve #338)
# Conflicts:
#	models/view/summary.go
#	routes/summary.go
#	views/summary.tpl.html
2025-02-05 21:36:09 +01:00
Ferdinand Mütsch
674296ce23 Merge branch 'master' of github.com:muety/wakapi 2025-02-05 21:32:21 +01:00
Ferdinand Mütsch
c86419fd41 fix(ui): dashboard grid layout 2025-02-05 21:31:57 +01:00
Ferdinand Mütsch
8dfb30abcd chore: minor refactorings 2025-02-05 21:31:57 +01:00
jiajia
cadf781da5 Fix a minor problem that calendar view shows when there's also a 'no data' warning 2025-02-05 21:31:57 +01:00
jiajia
a7b3e8b78d Fix the HTML structure 2025-02-05 21:31:57 +01:00
jiajia
8310e4acc6 Fix the 'no data' for the calendar view 2025-02-05 21:31:57 +01:00
jiajia
5268b01084 Revert "Fix the activity chart"
This reverts commit e4e3f371a4.
2025-02-05 21:31:57 +01:00
jiajia
5c967ad454 Fix the activity chart 2025-02-05 21:31:57 +01:00
jiajia
0608cfa248 Fix the 'No data' for the Calendar view 2025-02-05 21:31:57 +01:00
jiajia
dc631ed180 Show all vertical bars for the 'Calendar View' 2025-02-05 21:31:57 +01:00
jiajia
cb4a8a8eb4 Limit the interval of the 'Calendar View' & Fix typo 2025-02-05 21:31:57 +01:00
jiajia
a6404b1949 Change the 'Calendar view' to meet the architecture rule & Show 'No Data' for the summary webpage 2025-02-05 21:31:57 +01:00
Zijia Yan
dff08d87b5 Delete my nonsense of the mocks 2025-02-05 21:31:57 +01:00
jiajia
9666d57091 Improve the porformance of the "Calendar view" 2025-02-05 21:31:57 +01:00
jiajia
317cdb01f8 Add a "Calendar view" just like what's on the wakatime dashboard 2025-02-05 21:31:57 +01:00
jiajia
d660deb44a Fix a minor problem that calendar view shows when there's also a 'no data' warning 2025-02-03 21:20:29 +08:00
Ferdinand Mütsch
a75f177759 feat: ability to change username (resolve #739) 2025-02-02 22:19:49 +01:00
Ferdinand Mütsch
8bd23c99ae chore: add check for sqlite cascades before changing user id 2025-02-02 21:56:22 +01:00
Ferdinand Mütsch
2fef990d96 feat: service methods for changing user name (see #739) 2025-02-02 11:52:25 +01:00
jiajia
abcf0e348c Fix the HTML structure 2025-02-02 12:56:17 +08:00
jiajia
da1ac4da3d Fix the 'no data' for the calendar view 2025-02-01 12:24:54 +08:00
jiajia
b3061ca2bd Revert "Fix the activity chart"
This reverts commit e4e3f371a4.
2025-02-01 10:52:45 +08:00
jiajia
e4e3f371a4 Fix the activity chart 2025-01-27 21:04:45 +08:00
jiajia
776ae3f7d8 Fix the 'No data' for the Calendar view 2025-01-27 20:31:30 +08:00
jiajia
03a306c3ac Show all vertical bars for the 'Calendar View' 2025-01-23 21:16:22 +08:00
Ferdinand Mütsch
7c3d4c51bf docs: fix typo in env var (see #738) [skip ci] 2025-01-23 06:38:50 +01:00
Ferdinand Mütsch
820540a2b5 fix: sample data gui headless mode errors [skip ci] 2025-01-22 22:57:59 +01:00
Ferdinand Mütsch
2e4021987b chore: minor fixes to sample data gui 2025-01-22 22:47:11 +01:00
Ferdinand Mütsch
42490015c8 feat: option to require auth for viewing leaderboard (resolve #738) 2025-01-22 22:26:20 +01:00
jiajia
698a99d026 Limit the interval of the 'Calendar View' & Fix typo 2025-01-22 20:05:24 +08:00
Ferdinand Mütsch
cc435eb04f docs: add funding json [skip ci] 2025-01-20 19:03:12 +01:00
Ferdinand Mütsch
b2fd1074c5 chore: upgrade pond dependency [skip ci] 2025-01-18 16:19:19 +01:00
Ferdinand Mütsch
b50b208688 chore: move summary time zone fix into summary model 2025-01-18 00:59:13 +01:00
Ferdinand Mütsch
e70edf1fb3 fix: time zone issue with activity chart (resolve #719) 2025-01-18 00:38:11 +01:00
Ferdinand Mütsch
0957051c79 chore: dependency upgrades 2025-01-17 17:27:23 +01:00
jiajia
5d92d8935d Change the 'Calendar view' to meet the architecture rule & Show 'No Data' for the summary webpage 2025-01-16 21:21:07 +08:00
Ferdinand Mütsch
a02c96ef5f Merge pull request #735 from ricristian/patch-1
Update README.md
2025-01-15 05:49:10 +01:00
Cristian R
c31e8f571e Update README.md
Update the helm repo repository url since the current one is not updated for quite some time. It includes some bugs also config map is not synchronized with current app values. 

I would recommend to wait for few days maybe the current owner of the project will accept my changes. 

In my changes there besides fixes it also includes some workflows that will generate automatically new helm charts versions based on current app releases
2025-01-15 00:04:06 +02:00
Zijia Yan
015fa9f64f Delete my nonsense of the mocks 2025-01-11 20:10:15 +08:00
jiajia
b03ba8a789 Improve the porformance of the "Calendar view" 2025-01-11 20:03:53 +08:00
jiajia
2597d55c55 Merge branch 'master' of https://github.com/justin-jiajia/wakapi 2025-01-10 01:53:41 +00:00
jiajia
5de844602c Add a "Calendar view" just like what's on the wakatime dashboard 2025-01-10 01:53:05 +00:00
Ferdinand Mütsch
1d5f51dcad Update README.md 2024-12-22 23:30:26 +01:00
Ferdinand Mütsch
5fa2515931 fix: readme card default custom title (resolve #715)
chore: add option to configure activity chart sharing separately
2024-12-20 21:16:55 +01:00
Ferdinand Mütsch
946985ef11 perf: user heartbeats query performance improvements [skip ci] 2024-11-25 22:48:01 +01:00
Ferdinand Mütsch
6dc2d8f817 chore: increase user total time counting interval [skip ci] 2024-11-25 20:55:27 +01:00
Ferdinand Mütsch
f8ea8a761d chore: some more logging [skip ci] 2024-11-25 20:49:06 +01:00
Ferdinand Mütsch
f162accfb4 refactor(perf): user first heartbeats query 2024-11-25 20:21:37 +01:00
Ferdinand Mütsch
24751ea2d0 refactor(perf): project stats query 2024-11-25 00:53:30 +01:00
Ferdinand Mütsch
b82a9e8251 Merge remote-tracking branch 'origin/master' 2024-11-22 14:29:37 +01:00
Ferdinand Mütsch
d628020c5c fix: sentry heartbeats tracer sampling 2024-11-22 14:20:34 +01:00
Ferdinand Mütsch
b96db4eafa fix: improve user agent regex
fix: support cursor editor (resolve #712)
2024-11-22 13:57:17 +01:00
Ferdinand Mütsch
1680d0d225 Merge pull request #710 from krishnans2006/master
Update smtp_pass environment variable in compose.yml
2024-11-22 07:39:16 +01:00
Krishnan Shankar
77910ef126 fix: update smtp_pass environment variable in compose.yml 2024-11-22 00:36:42 -06:00
Steven Tang
4f1b2d0a71 ci(release): remove step to delete old releases
Resolves #704
2024-11-17 22:19:21 +11:00
Steven Tang
65ac714866 ci: update softprops/action-gh-release action to v2
See #704
2024-11-17 19:03:36 +11:00
Ferdinand Mütsch
5bd1ffd14d Merge pull request #706
fix(config): reverse proxy ip ranges parse warning when unset
2024-11-11 11:05:57 +01:00
SIMULATAN
35110907b6 fix(config): reverse proxy ip ranges parse warning when unset 2024-11-11 10:39:38 +01:00
Ferdinand Mütsch
f4253678ce Merge pull request #705
Printer-friendly dashboard styling
2024-11-05 11:20:53 +01:00
jiajia
12f2ea6fda Printer-friendly dashboard styling 2024-11-05 09:52:13 +00:00
Ferdinand Mütsch
7e976e607e Merge branch 'master' of github.com:muety/wakapi 2024-11-04 22:53:56 +01:00
Ferdinand Mütsch
354c7f9626 chore: use zero-valued fields
fix: exclude all optional heartbeat fields from hash
2024-11-04 22:52:49 +01:00
Ferdinand Mütsch
d29b11173c docs: contrib guidelines 2024-11-03 23:24:16 +01:00
Ferdinand Mütsch
b04f8c573e chore: show date of first heartbeat in range (resolve #685) 2024-11-03 22:40:57 +01:00
Ferdinand Mütsch
c7b07476d0 fix: time format returned by stats compat endpoints (resolve #698) 2024-11-03 22:10:04 +01:00
Ferdinand Mütsch
a1ebc240b1 fix: do not crash when using dev mode outside project source folder (resolve #703) (also see #693) 2024-11-03 16:46:38 +01:00
Chen, Ron Gang
de91df382a Remove the dependencies field as it may cause errors if the text is too long 2024-11-03 12:59:08 +08:00
Chen, Ron Gang
b2828e7b7b add additional wakatime fields 2024-11-02 23:31:03 +08:00
Ferdinand Mütsch
24044aa4d2 Merge pull request #693 from notarock/master
Remove circular dependency when logging configuration loading error
2024-10-15 07:53:56 +02:00
Roch D'Amour
71b3fd08a9 Remove dependency on config to log config error 2024-10-15 01:19:24 -04:00
Ferdinand Mütsch
830f6c920d chore: upgrade dependencies 2024-10-15 00:26:00 +02:00
Ferdinand Mütsch
96c8143271 merge with latest remote master 2024-10-15 00:25:50 +02:00
Ferdinand Mütsch
763d722738 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	coverage/coverage.out
#	routes/api/heartbeat.go
#	routes/api/heartbeat_test.go
2024-10-15 00:22:38 +02:00
Ferdinand Mütsch
40e01c1b01 fix: make heartbeats endpoint respond to cors preflight requests (resolve #690) 2024-10-15 00:13:53 +02:00
Ferdinand Mütsch
699dab8b27 fix: properly parse user agents sent by desktop app 2024-10-14 23:13:57 +02:00
Ferdinand Mütsch
ef2776f2cd fix: placeholders replacement (fix #687) 2024-10-13 23:46:30 +02:00
Ferdinand Mütsch
65aa688b6b fix: make heartbeats endpoint support browser plugin again (resolve #683, resolve #688) 2024-10-13 23:02:33 +02:00
Ferdinand Mütsch
75c9ef6c10 test: heartbeat id unmarshaling 2024-10-13 22:07:42 +02:00
vlack
341c9a2d07 fix: ignore id in heartbeat post 2024-10-04 22:19:44 +03:00
Ferdinand Mütsch
2568fbd39b chore: user postgres 17 by default in docker compose stack [skip ci] 2024-09-27 15:49:09 +02:00
Ferdinand Mütsch
b6def215c5 chore: use compose secrets by default 2024-09-27 14:49:15 +02:00
Ferdinand Mütsch
dcd36db410 Merge branch 'fork/1fexd/docker-secrets' 2024-09-27 14:48:58 +02:00
1fexd
2135c95fc0 #356 feat: Add support for Docker secrets 2024-09-25 17:56:41 +02:00
Ferdinand Mütsch
6fe801a6fc chore: generate static assets 2024-09-24 17:56:07 +02:00
mylo
455e54c4f2 fix: adjusted KPI spacing, tablet potrait breakpoint 2024-09-24 22:45:23 +08:00
mylo
caa47bcaaf fix: added more breakpoints 2024-09-24 20:56:09 +08:00
mylo
187cf1e9a8 fix: for real this time, mobile, tablet and desktop responsive summary page 2024-09-24 20:51:22 +08:00
mylo
2cfc925085 fix: fixed date picker going out of view in mobile 2024-09-24 11:32:21 +08:00
mylo
81729a20f7 feat: made summary page look better in mobile views 2024-09-24 11:11:04 +08:00
Ferdinand Mütsch
758be88b77 chore: upgrade dependencies 2024-09-08 23:05:31 +02:00
Ferdinand Mütsch
9e97addb1a feat: wildcard aliases (resolve #607) 2024-09-08 22:58:22 +02:00
Ferdinand Mütsch
39ef066ce2 fix: signup rate limiting (resolve #674) 2024-09-08 21:25:52 +02:00
Ferdinand Mütsch
b17096ed3e fix: multi-log to sentry and console in parallel 2024-09-08 21:24:15 +02:00
Ferdinand Mütsch
0580492001 fix: sentry request logging
chore: attach release version info to sentry events
2024-09-07 16:00:14 +02:00
finn
ddffd464e3 Replace SentryLogWrapper by slog-sentry 2024-09-05 11:05:25 +07:00
Peter Oettig
e39c9ecbbf Merge pull request #671 from muety/665-trusted-proxy-ip-ranges
feat: support ip ranges for trusted proxy header auth (resolve #665)
2024-08-25 23:17:36 +02:00
Ferdinand Mütsch
5b5076de7b chore: minor code style [skip ci] 2024-08-25 23:13:53 +02:00
Ferdinand Mütsch
bdb82a8d39 chore: update readme [skip ci] 2024-08-23 09:58:53 +02:00
Ferdinand Mütsch
fdd59aeab1 feat: support ip ranges for trusted proxy header auth (resolve #665) 2024-08-23 09:51:46 +02:00
Ferdinand Mütsch
27bfad1bdc chore: pass sentry environment tag 2024-08-23 08:40:02 +02:00
Ferdinand Mütsch
b59bf0ea63 Merge branch 'master' of github.com:finnng/wakapi into finnng-master 2024-08-23 07:57:01 +02:00
Ferdinand Mütsch
54a780abbf Merge pull request #669 from yuedanlabs/update-docs [skip ci]
docs: Update README.md for docker compose usage
2024-08-23 07:48:05 +02:00
yuedanlabs
3b73b367a1 docs: Update README.md for docker compose usage 2024-08-23 09:19:05 +08:00
finn
34bf742ca8 Use conf.Log().Fatal to replace log.Fatal; Only initial Sentry logger once 2024-08-23 06:11:53 +07:00
yuedanlabs
96905bdf92 docs: Update README.md for docker compose usage 2024-08-22 18:13:16 +08:00
finn
d542ae9602 Update structured log for conf.Log().Request(r), logging middleware, and sentry logger 2024-08-21 09:08:43 +07:00
finn
f665b4497b Tidy go mod to remove logbuch 2024-08-20 12:31:52 +07:00
finn
79f4c03d72 Correct the sentry logs to use the format key:value 2024-08-20 09:20:58 +07:00
finn
b5bb3da9b6 Correct the logs to use the format key:value 2024-08-20 08:45:43 +07:00
finn
118e51139a First round replace logbuch to slog 2024-08-20 05:59:17 +07:00
Ferdinand Mütsch
0702ee25a8 chore: default to go 1.23 [skip ci] 2024-08-14 12:03:00 +02:00
Ferdinand Mütsch
77ec044902 ci: default to latest stable go in dockerfile
ci: sanitize dockerfile to make sonarqube happy [skip ci]
2024-08-11 22:03:15 +02:00
Ferdinand Mütsch
224c28fd93 feat: configurable heartbeats timeout and offset (resolve #156) 2024-08-11 21:48:49 +02:00
Ferdinand Mütsch
7b2f6a3c84 fix(ci): migration tests on mssql 2024-08-11 19:20:22 +02:00
Ferdinand Mütsch
680e3d7036 chore: upgrade dependencies 2024-08-11 19:04:11 +02:00
Ferdinand Mütsch
dada00b57a Merge pull request #660 from twangodev/patch-1 [skip ci]
Fix typo with account deletion
2024-07-22 06:55:37 +02:00
James Ding
cb2e9a7bbf Fix typo with account deletion 2024-07-21 13:59:48 -07:00
Ferdinand Mütsch
a857271b59 Merge pull request #659 from muety/ci/658
ci: run mail tests in workflow
2024-07-12 09:47:06 +02:00
Steven Tang
7a508268dd ci: run mail tests in workflow
Resolves #658
2024-07-11 21:52:01 +10:00
Ferdinand Mütsch
fe672bbfa2 chore: minor code cleanup [skip ci] 2024-07-11 07:50:14 +02:00
Ferdinand Mütsch
f05defc89e chore: upgrade dependencies (resolve #656) 2024-07-10 23:39:41 +02:00
Ferdinand Mütsch
fd6b7542af chore: upgrade to latest smtp lib (resolve #657) 2024-07-10 23:34:12 +02:00
Ferdinand Mütsch
a5565b12ea feat: allow to configure accepting self-signed tls certs for smtp
test: comprehensive smtp tests using smtp4dev
2024-07-10 23:21:11 +02:00
Ferdinand Mütsch
afe8634f0c docs: document projects query parameter as optional (resolve #653) 2024-07-06 08:32:30 +02:00
Ferdinand Mütsch
89550ee5c3 Merge pull request #652 from notarock/master
Improve badge integration sections
2024-06-24 10:35:42 +02:00
Roch D'Amour
cd13e28968 Integrations: show different params for shields.io 2024-06-20 03:55:12 -04:00
Roch D'Amour
9c1e8b9e23 Integrations: url-encode shields label 2024-06-20 03:36:18 -04:00
Ferdinand Mütsch
af85a65d0c Merge remote-tracking branch 'origin/master' 2024-06-13 20:59:46 +02:00
Ferdinand Mütsch
2af6027388 chore(docs): regenerate api docs 2024-06-13 20:59:38 +02:00
Ferdinand Mütsch
d1b940f84a docs: document gnome extension usage [skip ci] 2024-06-03 21:38:51 +02:00
Ferdinand Mütsch
1b230bbf2f fix: make categories stacked bar span full width 2024-05-11 16:32:05 +02:00
Ferdinand Mütsch
0f5bd4185f fix: wakatime summaries view model index error 2024-05-11 08:59:18 +02:00
Ferdinand Mütsch
7acaa83907 chore: update chartjs to latest 3.x version 2024-05-11 08:49:21 +02:00
Ferdinand Mütsch
966e973829 chore: category chart regenerate notice 2024-05-11 08:47:33 +02:00
Ferdinand Mütsch
57b7daae9c feat: implement category chart and summaries (resolve #630) 2024-05-11 08:36:40 +02:00
Ferdinand Mütsch
b22530f083 fix: wakatime dump importer downloading wrong file (resolve #640) 2024-05-10 13:23:08 +02:00
guoguangwu
fd5bdf9920 chore: remove refs to deprecated io/ioutil
Signed-off-by: guoguangwu <guoguangwug@gmail.com>
2024-05-07 21:57:23 +02:00
Ferdinand Mütsch
f2bffe025b chore: add pure postgres sql aggregation script by @cwilby 2024-05-07 21:57:13 +02:00
Ferdinand Mütsch
d4bbf0a2fb fix: make sonarqube happy [skip ci] 2024-04-07 23:13:14 +02:00
Ferdinand Mütsch
7c4145a506 fix: merge relicts 2024-04-07 23:04:37 +02:00
Ferdinand Mütsch
8e30116949 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	README.md
#	config.default.yml
#	config/config.go
#	coverage/coverage.out
#	go.mod
#	go.sum
#	models/user.go
#	models/view/login.go
#	routes/login.go
#	views/signup.tpl.html
2024-04-07 23:01:03 +02:00
Ferdinand Mütsch
9097bc5552 feat: signup captcha (resolve #635) 2024-04-07 22:58:18 +02:00
Ferdinand Mütsch
7f070ae143 chore(test): basic test cases to test for html template being fully rendered (see #634) 2024-04-01 18:10:13 +02:00
Ferdinand Mütsch
6149b0f55e refactor: view models (resolve #634) 2024-04-01 17:55:10 +02:00
Ferdinand Mütsch
724265fb85 ci: update setup-go action 2024-03-31 15:40:59 +02:00
Ferdinand Mütsch
c1cd40ce40 chore: upgrade dependencies (see #632) 2024-03-31 15:36:01 +02:00
Ferdinand Mütsch
1469648796 ci: revert original docker build workflow (resolve #632) [skip ci] 2024-03-31 08:37:33 +02:00
Ferdinand Mütsch
43f6bbde9a Merge pull request #633 from muety/2-11
Special Docker push for 2.11.0
2024-03-31 08:26:26 +02:00
Steven Tang
be54bf2f6f ci: re-enable push 2024-03-31 10:15:34 +11:00
Steven Tang
669f3c4c7e ci: test pushing of 2.11.0 without arm/v7 2024-03-31 10:15:10 +11:00
362 changed files with 19073 additions and 13087 deletions

BIN
.github/assets/screenshot_gnome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -6,7 +6,8 @@ on:
jobs:
test:
name: 'Unit- & API tests'
runs-on: ubuntu-latest
# because of https://github.com/usebruno/bruno/issues/5003, we currently need Node.js v20, so pinning runner image at ubuntu 24.04 for now
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
@@ -14,9 +15,9 @@ jobs:
uses: actions/checkout@v4
- name: Set up Go 1.x
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: ^1.21
go-version: ^1.25
id: go
- name: Get dependencies
@@ -27,28 +28,33 @@ jobs:
- name: API Tests
run: |
npm -g install newman
npm install -g @usebruno/cli
./testing/run_api_tests.sh
- name: Mail Tests
run: ./testing/run_mail_tests.sh
migration:
name: Migration tests
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres, mysql, mariadb, mssql]
db: [sqlite, postgres, mysql, mariadb]
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Set up Go 1.x
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: ^1.21
go-version: ^1.25
id: go
- run: npm install -g @usebruno/cli
- run: ./testing/run_api_tests.sh ${{ matrix.db }} --migration

View File

@@ -53,7 +53,7 @@ jobs:
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile

View File

@@ -32,9 +32,9 @@ jobs:
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: ^1.21
go-version: ^1.25
cache: false
id: go
@@ -57,7 +57,7 @@ jobs:
working-directory: ./dist
shell: bash
run: |
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} CGO_ENABLED=0 \
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} CGO_ENABLED=0 GOEXPERIMENT=greenteagc,jsonv2 \
go build -v -ldflags '-w -s' ../
- name: Compress working folder (Windows PowerShell)
@@ -73,13 +73,6 @@ jobs:
zip -9 wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip *
- name: Upload built executable to Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: ./dist/*.zip
- name: Delete old releases
uses: dev-drprasad/delete-older-releases@v0.3.2
with:
keep_latest: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@@ -12,3 +12,11 @@ config*.yml
pkged.go
package-lock.json
node_modules
.DS_Store
.venv
venv
.env
scripts/mysql_to_*.yml
*.db-journal
*.db-shm
*.db-wal

2
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,2 @@
# Contributing
Please refer to our [contribution guidelines](https://github.com/muety/wakapi/wiki/Contributing) and [design goals](https://github.com/muety/wakapi/wiki/Design-Goals).

View File

@@ -1,14 +1,16 @@
FROM golang:1.21-alpine AS build-env
FROM --platform=$BUILDPLATFORM golang:alpine AS build-env
WORKDIR /src
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
ADD ./go.mod ./go.sum ./
COPY ./go.mod ./go.sum ./
RUN go mod download
ADD . .
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -v -o wakapi main.go
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 GOEXPERIMENT=greenteagc,jsonv2 go build -ldflags "-s -w" -v -o wakapi main.go
WORKDIR /staging
RUN mkdir ./data ./app && \
@@ -47,11 +49,11 @@ ENV ENVIRONMENT=prod \
COPY --from=build-env /staging /
LABEL org.opencontainers.image.url="https://github.com/muety/wakapi" \
org.opencontainers.image.documentation="https://github.com/muety/wakapi" \
org.opencontainers.image.source="https://github.com/muety/wakapi" \
org.opencontainers.image.title="Wakapi" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.description="A minimalist, self-hosted WakaTime-compatible backend for coding statistics"
org.opencontainers.image.documentation="https://github.com/muety/wakapi" \
org.opencontainers.image.source="https://github.com/muety/wakapi" \
org.opencontainers.image.title="Wakapi" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.description="A minimalist, self-hosted WakaTime-compatible backend for coding statistics"
USER app

376
README.md
View File

@@ -31,7 +31,8 @@
<img src="static/assets/images/screenshot.webp" width="500px">
</p>
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
> [!IMPORTANT]
> Upvote Wakapi on [AlternativeTo](https://alternativeto.net/software/wakapi/about/) and [ProductHunt](https://www.producthunt.com/posts/wakapi-coding-statistics) to support the project 🌈.
## 🚀 Features
@@ -49,13 +50,11 @@ Installation instructions can be found below and in the [Wiki](https://github.co
## ⌨️ 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)
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).
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
@@ -79,6 +78,7 @@ $ SALT="$(cat /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | he
# Run the container
$ docker run -d \
--init \
-p 3000:3000 \
-e "WAKAPI_PASSWORD_SALT=$SALT" \
-v wakapi-data:/data \
@@ -86,12 +86,27 @@ $ docker run -d \
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.
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/ricristian/wakapi-helm-chart) for quick and easy deployment.
#### Docker Compose
Alternatively, you can use Docker Compose for an even more straightforward deployment. See [compose.yml](https://github.com/muety/wakapi/blob/master/compose.yml) for configuration details.
Wakapi supports [Docker Secrets](https://docs.docker.com/compose/how-tos/use-secrets/) for the following variables: `WAKAPI_PASSWORD_SALT`, `WAKAPI_DB_PASSWORD`, `WAKAPI_MAIL_SMTP_PASS`. You can set these either by having them mounted as a secret file, or directly pass them as environment variables.
##### Example
```bash
export WAKAPI_PASSWORD_SALT=changeme
export WAKAPI_DB_PASSWORD=changeme
export WAKAPI_MAIL_SMTP_PASS=changeme
docker compose up -d
```
If you prefer to persist data in a local directory while using SQLite as the database, make sure to set the correct `user` option in the Docker Compose configuration to avoid permission issues.
### 🧑‍💻 Option 4: Compile and run from source
@@ -110,16 +125,13 @@ $ ./wakapi -config wakapi.yml
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
💡 When running Wakapi standalone (without Docker), it is recommended to run it as
a [SystemD service](etc/wakapi.service).
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
### 💻 Client setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime-cli) client tools. In order to collect
statistics for Wakapi, you need to set them up.
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime-cli) 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. **Edit your local `~/.wakatime.cfg`** file as follows.
```ini
@@ -132,84 +144,96 @@ api_url = http://localhost:3000/api
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
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.
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.
| YAML key / Env. variable | Default | Description |
|------------------------------------------------------------------------------|--------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.leaderboard_enabled` /<br>`WAKAPI_LEADERBOARD_ENABLED` | `true` | Whether to enable the public leaderboard |
| `app.leaderboard_scope` /<br>`WAKAPI_LEADERBOARD_SCOPE` | `7_days` | Aggregation interval for public leaderboard (see [here](https://github.com/muety/wakapi/blob/7d156cd3edeb93af2997bd95f12933b0aabef0c9/config/config.go#L71) for allowed values) |
| `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 |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
| `app.data_cleanup_time` /<br>`WAKAPI_DATA_CLEANUP_TIME` | `0 0 6 * * 0` | When to perform data cleanup operations (see `app.data_retention_months`) |
| `app.import_enabled` /<br>`WAKAPI_IMPORT_ENABLED` | `true` | Whether data imports from WakaTime or other Wakapi instances are permitted |
| `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 |
| `app.import_backoff_min` /<br>`WAKAPI_IMPORT_BACKOFF_MIN` | `5` | "Cooldown" period in minutes before user may attempt another data import |
| `app.import_max_rate` /<br>`WAKAPI_IMPORT_MAX_RATE` | `24` | Minimum number of hours to wait after a successful data import before user may attempt another one |
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.custom_languages` | - | Map from file endings to language names |
| `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)) |
| `app.date_format` /<br>`WAKAPI_DATE_FORMAT` | `Mon, 02 Jan 2006` | Go time format strings to format human-readable date (see [`Time.Format`](https://pkg.go.dev/time#Time.Format)) |
| `app.datetime_format` /<br>`WAKAPI_DATETIME_FORMAT` | `Mon, 02 Jan 2006 15:04` | Go time format strings to format human-readable datetime (see [`Time.Format`](https://pkg.go.dev/time#Time.Format)) |
| `app.support_contact` /<br>`WAKAPI_SUPPORT_CONTACT` | `hostmaster@wakapi.dev` | E-Mail address to display as a support contact on the page |
| `app.data_retention_months` /<br>`WAKAPI_DATA_RETENTION_MONTHS` | `-1` | Maximum retention period in months for user data (heartbeats) (-1 for unlimited) |
| `app.max_inactive_months` /<br>`WAKAPI_MAX_INACTIVE_MONTHS` | `12` | Maximum number of inactive months after which to delete user accounts without data (-1 for unlimited) |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (set to `'-'` to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (set to `'-'` to disable IPv6) |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (set to `'-'` to disable UNIX socket) |
| `server.listen_socket_mode` /<br> `WAKAPI_LISTEN_SOCKET_MODE` | `0666` | Permission mode to create UNIX socket with |
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `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 |
| `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.invite_codes` /<br> `WAKAPI_INVITE_CODES` | `true` | Whether to enable registration by invite codes. Primarily useful if registration is disabled (invite-only server). |
| `security.disable_frontpage` /<br> `WAKAPI_DISABLE_FRONTPAGE` | `false` | Whether to disable landing page (useful for personal instances) |
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `security.trusted_header_auth` /<br> `WAKAPI_TRUSTED_HEADER_AUTH` | `false` | Whether to enable trusted header authentication for reverse proxies (see [#534](https://github.com/muety/wakapi/issues/534)). **Use with caution!** |
| `security.trusted_header_auth_key` /<br> `WAKAPI_TRUSTED_HEADER_AUTH_KEY` | `Remote-User` | Header field for trusted header authentication. **Caution:** proxy must be configured to strip this header from client requests! |
| `security.trust_reverse_proxy_ips` /<br> `WAKAPI_TRUST_REVERSE_PROXY_IPS` | - | Comma-separated list IPv4 or IPv6 addresses of reverse proxies to trust to handle authentication. |
| `security.signup_max_rate` /<br> `WAKAPI_SIGNUP_MAX_RATE` | `5/1h` | Rate limiting config for signup endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `security.login_max_rate` /<br> `WAKAPI_LOGIN_MAX_RATE` | `10/1m` | Rate limiting config for login endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `security.password_reset_max_rate` /<br> `WAKAPI_PASSWORD_RESET_MAX_RATE` | `5/1h` | Rate limiting config for password reset endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
| `db.socket` /<br> `WAKAPI_DB_SOCKET` | - | Database UNIX socket (alternative to `host`) (for MySQL only) |
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`, `mssql`) |
| `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 |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`]) |
| `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) |
| `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! |
| `enable_pprof` /<br> `WAKAPI_ENABLE_PPROF` | `false` | Whether to expose [pprof](https://pkg.go.dev/runtime/pprof) profiling data as an endpoint for debugging |
| YAML key / Env. variable | Default | Description |
|---------------------------------------------------------------------------------------------|--------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.leaderboard_enabled` /<br>`WAKAPI_LEADERBOARD_ENABLED` | `true` | Whether to enable the public leaderboard |
| `app.leaderboard_scope` /<br>`WAKAPI_LEADERBOARD_SCOPE` | `7_days` | Aggregation interval for public leaderboard (see [here](https://github.com/muety/wakapi/blob/7d156cd3edeb93af2997bd95f12933b0aabef0c9/config/config.go#L71) for allowed values) |
| `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 |
| `app.leaderboard_require_auth` /<br>`WAKAPI_LEADERBOARD_REQUIRE_AUTH` | `false` | Restrict leaderboard access to logged in users only |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
| `app.data_cleanup_time` /<br>`WAKAPI_DATA_CLEANUP_TIME` | `0 0 6 * * 0` | When to perform data cleanup operations (see `app.data_retention_months`) |
| `app.optimize_database_time` /<br>`WAKAPI_OPTIMIZE_DATABASE_TIME` | `0 0 8 1 * *` | When to perform database vacuuming (SQLite, Postgres) or table optimization (MySQL) |
| `app.import_enabled` /<br>`WAKAPI_IMPORT_ENABLED` | `true` | Whether data imports from WakaTime or other Wakapi instances are permitted |
| `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 |
| `app.import_backoff_min` /<br>`WAKAPI_IMPORT_BACKOFF_MIN` | `5` | "Cooldown" period in minutes before user may attempt another data import |
| `app.import_max_rate` /<br>`WAKAPI_IMPORT_MAX_RATE` | `24` | Minimum number of hours to wait after a successful data import before user may attempt another one |
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.warm_caches /`<br>`WAKAPI_WARM_CACHES` | `true` | Whether to perform some initial cache warming upon startup |
| `app.custom_languages` | - | Map from file endings to language names |
| `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)) |
| `app.date_format` /<br>`WAKAPI_DATE_FORMAT` | `Mon, 02 Jan 2006` | Go time format strings to format human-readable date (see [`Time.Format`](https://pkg.go.dev/time#Time.Format)) |
| `app.datetime_format` /<br>`WAKAPI_DATETIME_FORMAT` | `Mon, 02 Jan 2006 15:04` | Go time format strings to format human-readable datetime (see [`Time.Format`](https://pkg.go.dev/time#Time.Format)) |
| `app.support_contact` /<br>`WAKAPI_SUPPORT_CONTACT` | `hostmaster@wakapi.dev` | E-Mail address to display as a support contact on the page |
| `app.data_retention_months` /<br>`WAKAPI_DATA_RETENTION_MONTHS` | `-1` | Maximum retention period in months for user data (heartbeats) (-1 for unlimited) |
| `app.max_inactive_months` /<br>`WAKAPI_MAX_INACTIVE_MONTHS` | `12` | Maximum number of inactive months after which to delete user accounts without data (-1 for unlimited) |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (set to `'-'` to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (set to `'-'` to disable IPv6) |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (set to `'-'` to disable UNIX socket) |
| `server.listen_socket_mode` /<br> `WAKAPI_LISTEN_SOCKET_MODE` | `0666` | Permission mode to create UNIX socket with |
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `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 |
| `security.allow_signup` /<br> `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.signup_captcha` /<br> `WAKAPI_SIGNUP_CAPTCHA` | `false` | Whether the registration form requires solving a CAPTCHA |
| `security.invite_codes` /<br> `WAKAPI_INVITE_CODES` | `true` | Whether to enable registration by invite codes. Primarily useful if registration is disabled (invite-only server). |
| `security.disable_frontpage` /<br> `WAKAPI_DISABLE_FRONTPAGE` | `false` | Whether to disable landing page (useful for personal instances) |
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `security.trusted_header_auth` /<br> `WAKAPI_TRUSTED_HEADER_AUTH` | `false` | Whether to enable trusted header authentication for reverse proxies (see [#534](https://github.com/muety/wakapi/issues/534)). **Use with caution!** |
| `security.trusted_header_auth_key` /<br> `WAKAPI_TRUSTED_HEADER_AUTH_KEY` | `Remote-User` | Header field for trusted header authentication. **Caution:** proxy must be configured to strip this header from client requests! |
| `security.trusted_header_auth_allow_signup` /<br> `WAKAPI_TRUSTED_HEADER_AUTH_ALLOW_SIGNUP` | `false` | Whether to allow creation of new users based on upstream trusted header authentication (see [#808](https://github.com/muety/wakapi/issues/808)) |
| `security.trust_reverse_proxy_ips` /<br> `WAKAPI_TRUST_REVERSE_PROXY_IPS` | - | Comma-separated list of IPv4 or IPv6 addresses or CIDRs of reverse proxies to trust to handle authentication (e.g. `172.17.0.1`, `192.168.0.0/24`, `[::1]`). |
| `security.signup_max_rate` /<br> `WAKAPI_SIGNUP_MAX_RATE` | `5/1h` | Rate limiting config for signup endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `security.login_max_rate` /<br> `WAKAPI_LOGIN_MAX_RATE` | `10/1m` | Rate limiting config for login endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `security.password_reset_max_rate` /<br> `WAKAPI_PASSWORD_RESET_MAX_RATE` | `5/1h` | Rate limiting config for password reset endpoint in format `<max_req>/<multiplier><unit>`, where `unit` is one of `s`, `m` or `h`. |
| `security.oidc` | `[]` | List of OpenID Connect provider configurations (for details, see [wiki](https://github.com/muety/wakapi/wiki/OpenID-Connect-login-(SSO))) |
| `security.oidc[0].name` /<br> `WAKAPI_OIDC_PROVIDER_NAME` | - | Name / identifier for the OpenID Connect provider (e.g. `gitlab`) |
| `security.oidc[0].client_id` /<br> `WAKAPI_OIDC_PROVIDER_CLIENT_ID` | - | OAuth client name with this provider |
| `security.oidc[0].client_secret` /<br> `WAKAPI_OIDC_PROVIDER_CLIENT_SECRET` | - | OAuth client secret with this provider |
| `security.oidc[0].endpoint` /<br> `WAKAPI_OIDC_PROVIDER_ENDPOINT` | - | OpenID Connect provider API entrypoint (for [discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)) |
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
| `db.socket` /<br> `WAKAPI_DB_SOCKET` | - | Database UNIX socket (alternative to `host`) (for MySQL only) |
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`) |
| `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 only) |
| `db.compress` /<br> `WAKAPI_DB_COMPRESS` | `false` | Whether to enable compression for database connection (MySQL 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 |
| `mail.skip_verify_mx_record` /<br> `WAKAPI_MAIL_SKIP_VERIFY_MX_RECORD` | `false` | Whether to skip validating MX DNS record for user email addresses |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`]) |
| `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.smtp.skip_verify` /<br> `WAKAPI_MAIL_SMTP_SKIP_VERIFY` | `false` | Whether to allow invalid or self-signed certificates for TLS-encrypted SMTP |
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.environment` /<br> `WAKAPI_SENTRY_ENVIRONMENT` | (`env`) | Sentry [environment](https://docs.sentry.io/concepts/key-terms/environments/) tag (defaults to `env` / `ENV`) |
| `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! |
| `enable_pprof` /<br> `WAKAPI_ENABLE_PPROF` | `false` | Whether to expose [pprof](https://pkg.go.dev/runtime/pprof) profiling data as an endpoint for debugging |
### Supported databases
@@ -219,28 +243,22 @@ Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of differ
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
* [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_)
* [Microsoft SQL Server](https://hub.docker.com/_/microsoft-mssql-server) (_Microsoft SQL Server_)
## 🔐 Authentication
Wakapi supports different types of user authentication.
* **Cookie:** This method is used in the browser. Users authenticate by sending along an encrypted, secure, HTTP-only
cookie (`wakapi_auth`) that was set in the server's response upon login.
* **Cookie:** This method is used in the browser. Users authenticate by sending along an encrypted, secure, HTTP-only cookie (`wakapi_auth`) that was set in the server's response upon login.
* **API key:**
* **Via header:** This method is inspired
by [WakaTime's auth. mechanism](https://wakatime.com/developers/#authentication) and is the common way to
authenticate against API endpoints. Users set the `Authorization` header to `Basic <BASE64_TOKEN>`, where the
latter part corresponds to your base64-hashed API key.
* **Vis query param:** Alternatively, users can also pass their plain API key as a query parameter (
e.g. `?api_key=86648d74-19c5-452b-ba01-fb3ec70d4c2f`) in the URL with every request.
* **Trusted header:** This mechanism allows to delegate authentication to a **reverse proxy** (e.g. for SSO), that
Wakapi will then trust blindly. See [#534](https://github.com/muety/wakapi/issues/534) for details.
* **Via header:** This method is inspired by [WakaTime's auth. mechanism](https://wakatime.com/developers/#authentication) and is the common way to authenticate against API endpoints. Users set the `Authorization` header to `Basic <BASE64_TOKEN>`, where the latter part corresponds to your base64-hashed API key.
* **Vis query param:** Alternatively, users can also pass their plain API key as a query parameter (e.g. `?api_key=86648d74-19c5-452b-ba01-fb3ec70d4c2f`) in the URL with every request.
* **Trusted header:** This mechanism allows to delegate authentication to a **reverse proxy** (e.g. for SSO), that Wakapi will then trust blindly. See [#534](https://github.com/muety/wakapi/issues/534) for details.
* Must be enabled via `trusted_header_auth` and configuring `trust_reverse_proxy_ip` in the config
* Warning: This type of authentication is quite prone to misconfiguration. Make sure that your reverse proxy
properly strips relevant headers from client requests.
* Warning: This type of authentication is quite prone to misconfiguration. Make sure that your reverse proxy properly strips relevant headers from client requests.
### Single Sign-On / OpenID Connect
Wakapi supports login via external identity providers via OpenID Connect. See [our wiki](https://github.com/muety/wakapi/wiki/OpenID-Connect-login-(SSO)) for details.
## 🔧 API endpoints
@@ -287,26 +305,17 @@ scrape_configs:
#### 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)
### 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
Wakapi also integrates
with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy
cards for you. Here is an example. To use this, don't forget to **enable public data**
under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact&range=last_7_days)
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example. To use this, don't forget to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
<details>
<summary>Click to view code</summary>
@@ -318,11 +327,9 @@ under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
</details>
<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. To use this, don't forget
to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi. To use this, don't forget to **enable public data** under [Settings -> Permissions](https://wakapi.dev/settings#permissions).
Preview:
@@ -350,30 +357,36 @@ Preview:
### Browser Plugin (Chrome & Firefox)
The [browser-wakatime](https://github.com/wakatime/browser-wakatime) plugin enables you to track your web surfing in
WakaTime (and Wakapi, of course). Visited websites will appear as "files" in the summary. Follow these instructions to
get started:
The [browser-wakatime](https://github.com/wakatime/browser-wakatime) plugin enables you to track your web surfing in WakaTime (and Wakapi, of course). Visited websites will appear as "files" in the summary. Follow these instructions to get started:
1. Install the browser extension from the official
store ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/wakatimes), [Chrome](https://chrome.google.com/webstore/detail/wakatime/jnbbnacmeggbgdjgaoojpmhdlkkpblgi?hl=de))
1. Install the browser extension from the official store ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/wakatimes), [Chrome](https://chrome.google.com/webstore/detail/wakatime/jnbbnacmeggbgdjgaoojpmhdlkkpblgi?hl=de))
2. Open the extension settings dialog
3. Configure it like so (see screenshot below):
* API Key: Your personal API key (get it at [wakapi.dev](https://wakapi.dev))
* Logging Type: _Only the domain_
* API URL: `https://wakapi.dev/api/compat/wakatime/v1` (alternatively, replace _wakapi.dev_ with your self-hosted
instance hostname)
* API URL: `https://wakapi.dev/api/compat/wakatime/v1` (alternatively, replace _wakapi.dev_ with your self-hosted instance hostname)
4. Save
5. Start browsing!
![](.github/assets/screenshot_browser_plugin.png)
Note: the plugin will only sync heartbeats once in a while, so it might take some time for them to appear on Wakapi.
To "force" it to sync, simply bring up the plugin main dialog.
Note: the plugin will only sync heartbeats once in a while, so it might take some time for them to appear on Wakapi. To "force" it to sync, simply bring up the plugin main dialog.
### Gnome Extension
If you're using the GNOME desktop, there is a quick way to display your today's coding statistics in the status bar.
![](.github/assets/screenshot_gnome.png)
Simply install the [Executor](https://extensions.gnome.org/extension/2932/executor/) extension and add the following command as a status bar indicator:
```bash
~/.wakatime/wakatime-cli-linux-amd64 --today
```
## 📦 Data Export
You can export your coding activity from Wakapi to CSV in the form of raw heartbeats. While there is no way to
accomplish this directly through the web UI, we provide an easy-to-use Python [script](scripts/download_heartbeats.py)
You can export your coding activity from Wakapi to CSV in the form of raw heartbeats. While there is no way to achieve this directly through the web UI, we provide an easy-to-use Python [script](scripts/download_heartbeats.py)
instead.
```bash
@@ -397,40 +410,30 @@ python scripts/download_heartbeats.py --api_key 04648d14-15c9-432b-b901-dbeec70d
## 👍 Best practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com)
or [nginx](https://www.nginx.com/), to enable **TLS encryption** (HTTPS).
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), to enable **TLS encryption** (HTTPS).
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`.
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
### 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
```bash
$ CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -json -coverprofile=coverage/coverage.out ./... -run ./...
$ go install github.com/mfridman/tparse@latest # optional
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./... | tparse -all
```
### 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 [Bruno](https://www.usebruno.com/) collection and can be run either from inside Bruno, or using [Bruno CLI](https://www.npmjs.com/package/@usebruno/cli) as a command-line runner.
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 Bruno assures) and their assertions may rely on specific previous tests having succeeded.
#### Prerequisites (Linux only)
@@ -438,8 +441,8 @@ assertions may rely on specific previous tests having succeeded.
# 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
# 2. newman
$ npm install -g newman
# 2. bruno cli
$ npm install -g @usebruno/cli
```
#### How to run (Linux only)
@@ -452,10 +455,7 @@ $ ./testing/run_api_tests.sh
### Building web assets
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.
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
$ yarn
@@ -466,13 +466,7 @@ New icons can be added by editing the `icons` array in [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.
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:
@@ -492,8 +486,7 @@ $ yarn compress
## ❔ FAQs
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.
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>
<summary><b>What data are sent to Wakapi?</b></summary>
@@ -509,9 +502,7 @@ to Wakapi as well. You might find answers there.
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>
@@ -523,16 +514,13 @@ All data are cached locally on your machine and sent in batches once you're onli
<details>
<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>
<summary><b>How does Wakapi compare to WakaTime?</b></summary>
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi,
include:
Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime features, that are missing Wakapi, include:
<ul>
<li>Leaderboards</li>
@@ -543,16 +531,13 @@ include:
<li>Richer API</li>
</ul>
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.
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>
<summary><b>How are durations calculated?</b></summary>
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.
Inferring a measure for your coding time from heartbeats works similar to WakaTime, see their [docs](https://wakatime.com/faq#timeout). Traditionally, Wakapi used to _pad_ every heartbeat before a `<timeout>`-long break with a padding of 2 minutes by default. Now, after the refactoring addressed in [#675](https://github.com/muety/wakapi/issues/675), Wakapi's logic is prtty much the same as WakaTime's, including a manually configurable timeout (default is 10 minutes).
Here is an example (circles are heartbeats):
@@ -562,43 +547,32 @@ Here is an example (circles are heartbeats):
```
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?
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>
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
<li><b>WakaTime</b> (with 2 min timeout): 20 sec
<li><b>Wakapi:</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
<li><b>With 10 min timeout:</b>: 3 min 20 sec
<li><b>With 2 min timeout:</b> 20 sec
<li><b>Previously (with 2 min timeout + padding):</b> 10 sec + 2 min + 10 sec = 2 min 20 sec</li>
</ul>
Wakapi adds a "padding" of two minutes before the third heartbeat. This is why total times will slightly vary between
Wakapi and WakaTime.
</details>
See [this comment](https://github.com/muety/wakapi/issues/716#issuecomment-2668887035) for another example.
## 👥 Community contributions
* 💻 [Code] Image generator from Wakapi
stats [LacazeThomas/wakapi-stats](https://github.com/LacazeThomas/wakapi-stats) (`Go`)
* 💻 [Code] Discord integration for
Wakapi - [LLoneDev6/Wakapi-Discord](https://github.com/LoneDev6/Wakapi-Discord) (`JavaScript`)
* 💻 [Code] Alternative heartbeats export
script - [wakapiexporter.nim](https://github.com/theAkito/mini-tools-nim/tree/master/generic/web/wakapiexporter) (`Nim`)
* 💻 [Code] Wakapi Helm chart for K8s
deployments - [andreymaznyak/wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) (`YAML`)
* 💻 [Code] Image generator from Wakapi stats [LacazeThomas/wakapi-stats](https://github.com/LacazeThomas/wakapi-stats) (`Go`)
* 💻 [Code] Discord integration for Wakapi - [LLoneDev6/Wakapi-Discord](https://github.com/LoneDev6/Wakapi-Discord) (`JavaScript`)
* 💻 [Code] Alternative heartbeats export script - [wakapiexporter.nim](https://github.com/theAkito/mini-tools-nim/tree/master/generic/web/wakapiexporter) (`Nim`)
* 💻 [Code] Wakapi Helm chart for K8s deployments - [andreymaznyak/wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) (`YAML`)
* 🗒 [Article] [Wakamonth: hours reporting tool](https://bitstillery.com/2024/01/09/wakamonth-hours-reporting-tool/)
## 👏 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
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 **[server.camp](https://server.camp)** for sponsoring server infrastructure for Wakapi.dev.

View File

@@ -0,0 +1,43 @@
meta {
name: Create heartbeat
type: http
seq: 1
}
post {
url: {{BASE_URL}}/api/heartbeat
body: json
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
X-Machine-Name: devmachine
User-Agent: wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0
Content-Type: application/json
}
body:json {
[
{
"entity": "/home/user1/dev/proejct1/main.go",
"project": "Project 1",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{tsNow}}
},
{
"entity": "/home/user1/dev/proejct1/main.go",
"project": "Project 1",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts1MonthAgo}}
}
]
}

View File

@@ -0,0 +1,8 @@
meta {
name: Heartbeats
seq: 2
}
auth {
mode: none
}

11
bruno/Misc/Get health.bru Normal file
View File

@@ -0,0 +1,11 @@
meta {
name: Get health
type: http
seq: 1
}
get {
url: {{BASE_URL}}/api/health
body: none
auth: inherit
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get metrics
type: http
seq: 2
}
get {
url: {{BASE_URL}}/api/metrics
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,28 @@
meta {
name: Send diagnostics
type: http
seq: 3
}
post {
url: {{BASE_URL}}/api/plugins/errors
body: json
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
X-Machine-Name: devmachine
User-Agent: wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0
}
body:json {
{
"platform": "unset",
"architecture": "unset",
"plugin": "",
"cli_version": "unset",
"logs": "{\"caller\":\"/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:189\",\"func\":\"runCmd\",\"level\":\"error\",\"message\":\"failed to run command: failed to send heartbeat(s) due to api error: failed to send heartbeats via api client: invalid response status from \\\"https://bin.muetsch.io/n7jnywu/users/current/heartbeats.bulk\\\". got: 404, want: 201/202. body: \\\"\\\"\",\"now\":\"2021-08-07T00:33:26+02:00\",\"version\":\"unset\"}\n",
"stacktrace": "goroutine 1 [running]:\nruntime/debug.Stack(0x0, 0xc0001f8680, 0x196)\n\t/opt/go/src/runtime/debug/stack.go:24 +0x9f\ngithub.com/wakatime/wakatime-cli/cmd/legacy.runCmd(0xc000103680, 0xc33c60, 0x0)\n\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:194 +0x26c\ngithub.com/wakatime/wakatime-cli/cmd/legacy.RunCmdWithOfflineSync(0xc000103680, 0xc33c60)\n\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:163 +0x35\ngithub.com/wakatime/wakatime-cli/cmd/legacy.Run(0xc0000be2c0, 0xc000103680)\n\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:90 +0x62e\ngithub.com/wakatime/wakatime-cli/cmd.NewRootCMD.func1(0xc0000be2c0, 0xc00028bd40, 0x0, 0x2)\n\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:31 +0x34\ngithub.com/spf13/cobra.(*Command).execute(0xc0000be2c0, 0xc000020190, 0x2, 0x2, 0xc0000be2c0, 0xc000020190)\n\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:854 +0x2c2\ngithub.com/spf13/cobra.(*Command).ExecuteC(0xc0000be2c0, 0xc000000180, 0xc0006bff78, 0x407d65)\n\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:958 +0x375\ngithub.com/spf13/cobra.(*Command).Execute(...)\n\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:895\ngithub.com/wakatime/wakatime-cli/cmd.Execute()\n\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:227 +0x2b\nmain.main()\n\t/home/ferdinand/dev/wakatime-cli/main.go:6 +0x25\n"
}
}

8
bruno/Misc/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: Misc
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Shields data
type: http
seq: 1
}
get {
url: {{BASE_URL}}/api/compat/shields/v1/n1try/interval:today/language:Go
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

8
bruno/Shields/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: Shields
seq: 4
}
auth {
mode: none
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get summary
type: http
seq: 1
}
get {
url: {{BASE_URL}}/api/summary?interval=last_7_days
body: none
auth: none
}
params:query {
interval: last_7_days
}
headers {
Authorization: Basic {{TOKEN}}
}

8
bruno/Summary/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: Summary
seq: 3
}
auth {
mode: none
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get all time
type: http
seq: 1
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/all_time_since_today
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get heartbeats
type: http
seq: 2
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2021-02-10
body: none
auth: none
}
params:query {
date: 2021-02-10
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get stats with range
type: http
seq: 4
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/stats/last_7_days
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get stats
type: http
seq: 3
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/stats
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get statusbar
type: http
seq: 6
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/statusbar/today
body: none
auth: none
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,20 @@
meta {
name: Get summaries
type: http
seq: 5
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/summaries?start=2020-03-01T15:04:05Z&end=2020-03-31T15:04:05Z
body: none
auth: none
}
params:query {
start: 2020-03-01T15:04:05Z
end: 2020-03-31T15:04:05Z
}
headers {
Authorization: Basic {{TOKEN}}
}

View File

@@ -0,0 +1,8 @@
meta {
name: WakaTime
seq: 5
}
auth {
mode: none
}

9
bruno/bruno.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "wakapi",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

37
bruno/collection.bru Normal file
View File

@@ -0,0 +1,37 @@
meta {
name: Wakapi
seq: 1
}
auth {
mode: none
}
script:pre-request {
const apiKey = bru.getEnvVar('API_KEY')
if (!apiKey) {
throw new Error('no api key given')
}
const token = base64encode(apiKey)
bru.setVar('TOKEN', token)
function base64encode(str) {
return Buffer.from(str, 'utf-8').toString('base64')
}
bru.setVar('tsNow', parseInt(new Date().getTime() / 1000))
bru.setVar('ts1MinAgo', parseInt((new Date().getTime() - 1000*60*1) / 1000))
bru.setVar('ts2MinAgo', parseInt((new Date().getTime() - 1000*60*2) / 1000))
bru.setVar('ts1HourAgo', parseInt((new Date().getTime() - 1000*60*60*1) / 1000))
bru.setVar('ts1MonthAgo', parseInt((new Date().getTime() - 1000*60*60*24*30*1) / 1000))
}
docs {
# Wakapi basic API routes
Start by selecting an environment (there is a premade one called "dev") and configuring your `API_KEY`.
For a complete list of API routes, refer to <https://wakapi.dev/swagger-ui>.
}

View File

@@ -0,0 +1,6 @@
vars {
BASE_URL: http://localhost:3000
}
vars:secret [
API_KEY
]

View File

@@ -1,29 +1,60 @@
version: '3.7'
services:
wakapi:
build: .
init: true
ports:
- 3000:3000
restart: always
restart: unless-stopped
environment:
# See README.md and config.default.yml for all config options
WAKAPI_DB_TYPE: "postgres"
WAKAPI_DB_NAME: "wakapi"
WAKAPI_DB_USER: "wakapi"
WAKAPI_DB_PASSWORD: "choose-a-password"
WAKAPI_DB_HOST: "db"
WAKAPI_DB_PORT: "5432"
ENVIRONMENT: "prod"
WAKAPI_DB_PASSWORD_FILE: "/run/secrets/db_password" # alternatively, set WAKAPI_DB_PASSWORD directly without the use of secrets
WAKAPI_PASSWORD_SALT_FILE: "/run/secrets/password_salt" # alternatively, set WAKAPI_PASSWORD_SALT directly without the use of secrets
WAKAPI_MAIL_SMTP_PASS_FILE: "/run/secrets/smtp_pass" # alternatively, set WAKAPI_MAIL_SMTP_PASS directly without the use of secrets
secrets:
- source: password_salt
target: password_salt
uid: '1000'
gid: '1000'
mode: '0400'
- source: smtp_pass
target: smtp_pass
uid: '1000'
gid: '1000'
mode: '0400'
- source: db_password
target: db_password
uid: '1000'
gid: '1000'
mode: '0400'
db:
image: postgres:12.3
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "choose-a-password"
POSTGRES_PASSWORD_FILE: "/run/secrets/db_password" # alternatively, set POSTGRES_PASSWORD directly without the use of secrets
POSTGRES_DB: "wakapi"
volumes:
- wakapi-db-data:/var/lib/postgresql/data
secrets:
- db_password
# secrets can be defined either from a local file or from an environment variable defined on the client host (the one that runs `docker compose` command)
# see https://docs.docker.com/compose/how-tos/use-secrets/ for details
secrets:
password_salt:
environment: WAKAPI_PASSWORD_SALT
smtp_pass:
environment: WAKAPI_MAIL_SMTP_PASS
db_password:
environment: WAKAPI_DB_PASSWORD
volumes:
wakapi-db-data: {}

View File

@@ -19,9 +19,11 @@ app:
leaderboard_enabled: true # whether to enable public leaderboards
leaderboard_scope: 7_days # leaderboard time interval (e.g. 14_days, 6_months, ...)
leaderboard_generation_time: '0 0 6 * * *,0 0 18 * * *' # times at which to re-calculate the leaderboard
leaderboard_require_auth: false # restrict leaderboard access only to logged in user
aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
report_time_weekly: '0 0 18 * * 5' # time at which to fan out weekly reports (extended cron)
data_cleanup_time: '0 0 6 * * 0' # time at which to run old data cleanup (if enabled through data_retention_months)
optimize_database_time: '0 0 8 1 * *' # time at which to run database vacuuming (sqlite, postgres) or table optimization (mysql)
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_enabled: true # whether data import from wakatime or other wakapi instances is allowed
import_backoff_min: 5 # time (in minutes) for "cooldown" before allowing another data import attempt by a user
@@ -30,6 +32,7 @@ app:
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)
max_inactive_months: 12 # maximum months of inactivity before deleting user accounts
warm_caches: true # whether to run some initial cache warming upon startup
custom_languages:
vue: Vue
jsx: JSX
@@ -38,6 +41,21 @@ app:
ipynb: Python
svelte: Svelte
astro: Astro
canonical_language_names:
'java': 'Java'
'ini': 'INI'
'xml': 'XML'
'jsx': 'JSX'
'tsx': 'TSX'
'php': 'PHP'
'yaml': 'YAML'
'toml': 'TOML'
'sql': 'SQL'
'css': 'CSS'
'scss': 'SCSS'
'jsp': 'JSP'
'svg': 'SVG'
'csv': 'CSV'
# 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
@@ -56,10 +74,11 @@ db:
user: # leave blank when using sqlite3
password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
dialect: sqlite3 # mysql, postgres, sqlite3, mssql
dialect: sqlite3 # mysql, postgres, sqlite3
charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) (true means encrypt=true in mssql)
max_conn: 10 # 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)
compress: false # whether to use compression during transport (mysql only)
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security:
@@ -67,16 +86,19 @@ security:
insecure_cookies: true # should be set to 'false', except when not running with HTTPS (e.g. on localhost)
cookie_max_age: 172800
allow_signup: true
signup_captcha: false
invite_codes: true # whether to enable invite codes for overriding disabled signups
disable_frontpage: false
expose_metrics: false
enable_proxy: false # only intended for production instance at wakapi.dev
trusted_header_auth: false # whether to enable trusted header auth for reverse proxies, use with caution!! (https://github.com/muety/wakapi/issues/534)
trusted_header_auth_key: Remote-User # header field for trusted header auth (warning: your proxy must correctly strip this header from client requests!!)
trust_reverse_proxy_ips: # single ip address of the reverse proxy which you trust to pass headers for authentication
signup_max_rate: 5/1h # signup endpoint rate limit pattern
login_max_rate: 10/1m # login endpoint rate limit pattern
password_reset_max_rate: 5/1h # password reset endpoint rate limit pattern
enable_proxy: false # only intended for production instance at wakapi.dev
trusted_header_auth: false # whether to enable trusted header auth for reverse proxies, use with caution!! (https://github.com/muety/wakapi/issues/534)
trusted_header_auth_key: Remote-User # header field for trusted header auth (warning: your proxy must correctly strip this header from client requests!!)
trusted_header_auth_allow_signup: false # whether to allow creation of new users based on upstream trusted header authentication (https://github.com/muety/wakapi/issues/808)
trust_reverse_proxy_ips: # single ip address of the reverse proxy which you trust to pass headers for authentication
signup_max_rate: 5/1h # signup endpoint rate limit pattern
login_max_rate: 10/1m # login endpoint rate limit pattern
password_reset_max_rate: 5/1h # password reset endpoint rate limit pattern
oidc: # list of openid connect providers available for user signup and login, see https://github.com/muety/wakapi/wiki/OpenID-Connect-login-(SSO)
sentry:
dsn: # leave blank to disable sentry integration
@@ -97,6 +119,7 @@ mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp']
sender: Wakapi <noreply@wakapi.dev>
skip_verify_mx_record: false # whether to skip validating mx dns record for user email addresses
# smtp settings when sending mails via smtp
smtp:

View File

@@ -13,13 +13,14 @@ import (
"github.com/duke-git/lancet/v2/slice"
"github.com/emvi/logbuch"
"log/slog"
"github.com/gofrs/uuid/v5"
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/data"
"github.com/muety/wakapi/utils"
"github.com/robfig/cron/v3"
uuid "github.com/satori/go.uuid"
)
const (
@@ -34,12 +35,15 @@ const (
KeyLatestTotalUsers = "latest_total_users"
KeyLastImport = "last_import" // import attempt
KeyLastImportSuccess = "last_successful_import" // last actual successful import
KeyFirstHeartbeat = "first_heartbeat"
KeySubscriptionNotificationSent = "sub_reminder"
KeyNewsbox = "newsbox"
KeyInviteCode = "invite"
KeySharedData = "shared_data"
SessionKeyDefault = "default"
CookieKeySession = "wakapi_session"
CookieKeyAuth = "wakapi_auth"
SessionValueOidcState = "oidc_state"
SessionValueOidcIdTokenPayload = "oidc_id_token"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
@@ -72,9 +76,12 @@ var emailProviders = []string{
// first wakatime commit was on this day ;-) so no real heartbeats should exist before
// https://github.com/wakatime/legacy-python-cli/commit/3da94756aa1903c1cca5035803e3f704e818c086
const heartbeatsMinDate = "2013-07-06"
const colorsFile = "data/colors.json"
var leaderboardScopes = []string{"24_hours", "week", "month", "year", "7_days", "14_days", "30_days", "6_months", "12_months", "all_time"}
var appStartTime = time.Now()
var cfg *Config
var env string
@@ -82,46 +89,53 @@ type appConfig struct {
LeaderboardEnabled bool `yaml:"leaderboard_enabled" default:"true" env:"WAKAPI_LEADERBOARD_ENABLED"`
LeaderboardScope string `yaml:"leaderboard_scope" default:"7_days" env:"WAKAPI_LEADERBOARD_SCOPE"`
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"0 0 6 * * *,0 0 18 * * *" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
LeaderboardRequireAuth bool `yaml:"leaderboard_require_auth" default:"false" env:"WAKAPI_LEADERBOARD_REQUIRE_AUTH"`
AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"0 0 18 * * 5" env:"WAKAPI_REPORT_TIME_WEEKLY"`
DataCleanupTime string `yaml:"data_cleanup_time" default:"0 0 6 * * 0" env:"WAKAPI_DATA_CLEANUP_TIME"`
OptimizeDatabaseTime string `yaml:"optimize_database_time" default:"0 0 8 1 * *" env:"WAKAPI_OPTIMIZE_DATABASE_TIME"`
ImportEnabled bool `yaml:"import_enabled" default:"true" env:"WAKAPI_IMPORT_ENABLED"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportMaxRate int `yaml:"import_max_rate" default:"24" env:"WAKAPI_IMPORT_MAX_RATE"` // at max one successful import every x hours
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"168h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
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"`
DataCleanupDryRun bool `yaml:"data_cleanup_dry_run" default:"false" env:"WAKAPI_DATA_CLEANUP_DRY_RUN"` // for debugging only
MaxInactiveMonths int `yaml:"max_inactive_months" default:"-1" env:"WAKAPI_MAX_INACTIVE_MONTHS"`
WarmCaches bool `yaml:"warm_caches" default:"true" env:"WAKAPI_WARM_CACHES"`
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"`
DateFormat string `yaml:"date_format" default:"Mon, 02 Jan 2006" env:"WAKAPI_DATE_FORMAT"`
DateTimeFormat string `yaml:"datetime_format" default:"Mon, 02 Jan 2006 15:04" env:"WAKAPI_DATETIME_FORMAT"`
CustomLanguages map[string]string `yaml:"custom_languages"`
CanonicalLanguageNames map[string]string `yaml:"canonical_language_names"` // lower case, compacted representation -> canonical name
Colors map[string]map[string]string `yaml:"-"`
}
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
SignupCaptcha bool `yaml:"signup_captcha" default:"false" env:"WAKAPI_SIGNUP_CAPTCHA"`
InviteCodes bool `yaml:"invite_codes" default:"true" env:"WAKAPI_INVITE_CODES"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
EnableProxy bool `yaml:"enable_proxy" default:"false" env:"WAKAPI_ENABLE_PROXY"` // only intended for production instance at wakapi.dev
DisableFrontpage bool `yaml:"disable_frontpage" default:"false" env:"WAKAPI_DISABLE_FRONTPAGE"`
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
TrustedHeaderAuth bool `yaml:"trusted_header_auth" default:"false" env:"WAKAPI_TRUSTED_HEADER_AUTH"`
TrustedHeaderAuthKey string `yaml:"trusted_header_auth_key" default:"Remote-User" env:"WAKAPI_TRUSTED_HEADER_AUTH_KEY"`
TrustReverseProxyIps string `yaml:"trust_reverse_proxy_ips" default:"" env:"WAKAPI_TRUST_REVERSE_PROXY_IPS"` // comma-separated list of trusted reverse proxy ips
SignupMaxRate string `yaml:"signup_max_rate" default:"5/1h" env:"WAKAPI_SIGNUP_MAX_RATE"`
LoginMaxRate string `yaml:"login_max_rate" default:"10/1m" env:"WAKAPI_LOGIN_MAX_RATE"`
PasswordResetMaxRate string `yaml:"password_reset_max_rate" default:"5/1h" env:"WAKAPI_PASSWORD_RESET_MAX_RATE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
SessionKey []byte `yaml:"-"`
trustReverseProxyIpParsed []net.IP
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
TrustedHeaderAuth bool `yaml:"trusted_header_auth" default:"false" env:"WAKAPI_TRUSTED_HEADER_AUTH"`
TrustedHeaderAuthKey string `yaml:"trusted_header_auth_key" default:"Remote-User" env:"WAKAPI_TRUSTED_HEADER_AUTH_KEY"`
TrustedHeaderAuthAllowSignup bool `yaml:"trusted_header_auth_allow_signup" default:"false" env:"WAKAPI_TRUSTED_HEADER_AUTH_ALLOW_SIGNUP"`
TrustReverseProxyIps string `yaml:"trust_reverse_proxy_ips" default:"" env:"WAKAPI_TRUST_REVERSE_PROXY_IPS"` // comma-separated list of trusted reverse proxy ips
SignupMaxRate string `yaml:"signup_max_rate" default:"5/1h" env:"WAKAPI_SIGNUP_MAX_RATE"`
LoginMaxRate string `yaml:"login_max_rate" default:"10/1m" env:"WAKAPI_LOGIN_MAX_RATE"`
PasswordResetMaxRate string `yaml:"password_reset_max_rate" default:"5/1h" env:"WAKAPI_PASSWORD_RESET_MAX_RATE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
SessionKey []byte `yaml:"-"`
OidcProviders []oidcProviderConfig `yaml:"oidc"` // TODO: support to read from env.
trustReverseProxyIpsParsed []net.IPNet
}
type dbConfig struct {
@@ -135,8 +149,10 @@ type dbConfig struct {
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
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:"10" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
Compress bool `yaml:"compress" default:"false" env:"WAKAPI_DB_COMPRESS"`
MysqlOptimize bool `default:"false" env:"WAKAPI_MYSQL_OPTIMIZE"` // apparently not recommended, because usually has very little effect but takes forever and partially locks the table
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
}
@@ -165,24 +181,34 @@ type subscriptionsConfig struct {
type sentryConfig struct {
Dsn string `env:"WAKAPI_SENTRY_DSN"`
Environment string `env:"WAKAPI_SENTRY_ENVIRONMENT"`
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
SampleRate float32 `yaml:"sample_rate" default:"0.75" env:"WAKAPI_SENTRY_SAMPLE_RATE"`
SampleRateHeartbeats float32 `yaml:"sample_rate_heartbeats" default:"0.1" env:"WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS"`
}
type mailConfig struct {
Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"`
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
Smtp SMTPMailConfig `yaml:"smtp"`
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"`
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
Smtp SMTPMailConfig `yaml:"smtp"`
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
SkipVerifyMXRecord bool `yaml:"skip_verify_mx_record" env:"WAKAPI_MAIL_SKIP_VERIFY_MX_RECORD" default:"false"`
}
type SMTPMailConfig struct {
Host string `env:"WAKAPI_MAIL_SMTP_HOST"`
Port uint `env:"WAKAPI_MAIL_SMTP_PORT"`
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
Host string `env:"WAKAPI_MAIL_SMTP_HOST"`
Port uint `env:"WAKAPI_MAIL_SMTP_PORT"`
Username string `env:"WAKAPI_MAIL_SMTP_USER"`
Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
SkipVerify bool `env:"WAKAPI_MAIL_SMTP_SKIP_VERIFY"`
}
type oidcProviderConfig struct {
Name string `yaml:"name" env:"WAKAPI_OIDC_PROVIDER_NAME"`
ClientID string `yaml:"client_id" env:"WAKAPI_OIDC_PROVIDER_CLIENT_ID"`
ClientSecret string `yaml:"client_secret" env:"WAKAPI_OIDC_PROVIDER_CLIENT_SECRET"`
Endpoint string `yaml:"endpoint" env:"WAKAPI_OIDC_PROVIDER_ENDPOINT"` // base url from which auto-discovery (.well-known/openid-configuration) can be found
}
type Config struct {
@@ -201,6 +227,10 @@ type Config struct {
Mail mailConfig
}
func (c *Config) AppStartTimestamp() string {
return fmt.Sprintf("%d", appStartTime.Unix())
}
func (c *Config) CreateCookie(name, value string) *http.Cookie {
return c.createCookie(name, value, c.Server.BasePath, c.Security.CookieMaxAgeSec)
}
@@ -210,6 +240,10 @@ func (c *Config) GetClearCookie(name string) *http.Cookie {
}
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
if path == "" {
path = "/"
}
return &http.Cookie{
Name: name,
Value: value,
@@ -233,6 +267,13 @@ func (c *appConfig) GetCustomLanguages() map[string]string {
return utils.CloneStringMap(c.CustomLanguages, false)
}
func (c *appConfig) GetCanonicalLanguageNames() map[string]string {
if c.CanonicalLanguageNames == nil {
return make(map[string]string)
}
return utils.CloneStringMap(c.CanonicalLanguageNames, false)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return utils.CloneStringMap(c.Colors["languages"], true)
}
@@ -251,12 +292,12 @@ func (c *appConfig) GetAggregationTimeCron() string {
timeParts := strings.Split(c.AggregationTime, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
@@ -274,12 +315,12 @@ func (c *appConfig) GetWeeklyReportCron() string {
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
@@ -299,12 +340,12 @@ func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
timeParts := strings.Split(s, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
@@ -328,18 +369,39 @@ func (c *appConfig) HeartbeatsMaxAge() time.Duration {
}
func (c *securityConfig) ParseTrustReverseProxyIPs() {
c.trustReverseProxyIpParsed = make([]net.IP, 0)
c.trustReverseProxyIpsParsed = make([]net.IPNet, 0)
for _, ip := range strings.Split(c.TrustReverseProxyIps, ",") {
if parsedIp := net.ParseIP(strings.TrimSpace(ip)); parsedIp == nil {
logbuch.Warn("failed to parse reverse proxy ip")
} else {
c.trustReverseProxyIpParsed = append(c.trustReverseProxyIpParsed, parsedIp)
// the config value is empty by default
if ip == "" {
continue
}
// try parse as address range
_, parsedIpNet, err := net.ParseCIDR(ip)
if err == nil {
c.trustReverseProxyIpsParsed = append(c.trustReverseProxyIpsParsed, *parsedIpNet)
continue
}
// try parse as single ip
parsedIp := net.ParseIP(strings.TrimSpace(ip))
if parsedIp != nil {
ipBits := net.IPv4len * 8
if parsedIp.To4() == nil {
ipBits = net.IPv6len * 8
}
ipNet := net.IPNet{IP: parsedIp, Mask: net.CIDRMask(ipBits, ipBits)}
c.trustReverseProxyIpsParsed = append(c.trustReverseProxyIpsParsed, ipNet)
continue
}
slog.Warn("failed to parse reverse proxy ip ranges")
}
}
func (c *securityConfig) TrustReverseProxyIPs() []net.IP {
return c.trustReverseProxyIpParsed
func (c *securityConfig) TrustReverseProxyIPs() []net.IPNet {
return c.trustReverseProxyIpsParsed
}
func (c *securityConfig) GetSignupMaxRate() (int, time.Duration) {
@@ -354,11 +416,21 @@ func (c *securityConfig) GetPasswordResetMaxRate() (int, time.Duration) {
return c.parseRate(c.PasswordResetMaxRate)
}
func (c *securityConfig) GetOidcProvider(name string) (*OidcProvider, error) {
return GetOidcProvider(name)
}
func (c *securityConfig) ListOidcProviders() []string {
return slice.Map[oidcProviderConfig, string](c.OidcProviders, func(i int, provider oidcProviderConfig) string {
return provider.Name
})
}
func (c *securityConfig) parseRate(rate string) (int, time.Duration) {
pattern := regexp.MustCompile("(\\d+)/(\\d+)([smh])")
matches := pattern.FindStringSubmatch(rate)
if len(matches) != 4 {
logbuch.Fatal("failed to parse rate pattern '%s'", rate)
Log().Fatal("failed to parse rate pattern", "rate", rate)
}
limit, _ := strconv.Atoi(matches[1])
@@ -417,12 +489,16 @@ func readColors() map[string]map[string]string {
raw := data.ColorsFile
if IsDev(env) {
raw, _ = os.ReadFile("data/colors.json")
if _, err := os.Stat(colorsFile); err == nil {
raw, _ = os.ReadFile(colorsFile)
} else {
Log().Warn("attempted to read colors from local fs in dev mode, but failed", "file", colorsFile)
}
}
var colors = make(map[string]map[string]string)
if err := json.Unmarshal(raw, &colors); err != nil {
logbuch.Fatal(err.Error())
Log().Fatal(err.Error())
}
return colors
@@ -451,29 +527,37 @@ func Get() *Config {
func Load(configFlag string, version string) *Config {
config := &Config{}
if err := configor.New(&configor.Config{}).Load(config, configFlag); err != nil {
logbuch.Fatal("failed to read config: %v", err)
Log().Fatal("failed to read config", err)
}
env = config.Env
InitLogger(config.IsDev())
config.Version = strings.TrimSpace(version)
tagVersionMatch, _ := regexp.MatchString(`\d+\.\d+\.\d+`, version)
tagVersionMatch, _ := regexp.MatchString(`\d+\.\d+\.\d+`, config.Version)
if tagVersionMatch {
config.Version = "v" + config.Version
}
config.InstanceId = uuid.NewV4().String()
config.InstanceId = uuid.Must(uuid.NewV4()).String()
config.App.Colors = readColors()
config.Db.Dialect = resolveDbDialect(config.Db.Type)
if config.Db.Type == "cockroach" {
slog.Warn("cockroach is not officially supported, it is strongly recommended to migrate to postgres instead")
}
if config.Db.IsMssql() {
slog.Error("mssql is not supported anymore, sorry")
os.Exit(1)
}
hashKey := securecookie.GenerateRandomKey(64)
blockKey := securecookie.GenerateRandomKey(32)
sessionKey := securecookie.GenerateRandomKey(32)
if IsDev(env) {
logbuch.Warn("using temporary keys to sign and encrypt cookies in dev mode, make sure to set env to production for real-world use")
slog.Warn("using temporary keys to sign and encrypt cookies in dev mode, make sure to set env to production for real-world use")
hashKey, blockKey = getTemporarySecureKeys()
blockKey = hashKey
}
@@ -491,78 +575,85 @@ func Load(configFlag string, version string) *Config {
}
if config.Sentry.Dsn != "" {
logbuch.Info("enabling sentry integration")
initSentry(config.Sentry, config.IsDev())
if config.Sentry.Environment == "" {
config.Sentry.Environment = config.Env
}
slog.Info("enabling sentry integration", "environment", config.Sentry.Environment)
initSentry(config.Sentry, config.IsDev(), config.Version)
}
if config.App.DataRetentionMonths <= 0 {
logbuch.Info("disabling data retention policy, keeping data forever")
slog.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)
slog.Warn(dataRetentionWarning)
}
// some validation checks
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")
Log().Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
if config.Db.MaxConn < 2 && !config.Db.IsSQLite() {
Log().Warn("you should use a pool of at least 2 database connections")
}
if config.Db.MaxConn > 1 && config.Db.IsSQLite() {
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
Log().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
}
if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
Log().Fatal("unknown mail provider", "provider", config.Mail.Provider)
}
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
logbuch.Fatal("invalid duration set for heartbeat_max_age")
Log().Fatal("invalid duration set for heartbeat_max_age")
}
if config.Security.TrustedHeaderAuth && len(config.Security.trustReverseProxyIpParsed) == 0 {
if config.Security.TrustedHeaderAuth && len(config.Security.trustReverseProxyIpsParsed) == 0 {
config.Security.TrustedHeaderAuth = false
}
if d, err := time.Parse(config.App.DateFormat, config.App.DateFormat); err != nil || !d.Equal(time.Date(2006, time.January, 2, 0, 0, 0, 0, d.Location())) {
logbuch.Fatal("invalid date format '%s'", config.App.DateFormat)
Log().Fatal("invalid date format", "format", config.App.DateFormat)
}
if d, err := time.Parse(config.App.DateTimeFormat, config.App.DateTimeFormat); err != nil || !d.Equal(time.Date(2006, time.January, 2, 15, 4, 0, 0, d.Location())) {
logbuch.Fatal("invalid datetime format '%s'", config.App.DateTimeFormat)
Log().Fatal("invalid datetime format", "format", config.App.DateTimeFormat)
}
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")
Log().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")
Log().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")
Log().Fatal("invalid cron expression for leaderboard_generation_time")
}
}
// see models/interval.go
if !slice.Contain[string](leaderboardScopes, config.App.LeaderboardScope) {
logbuch.Fatal("leaderboard scope is not a valid constant")
Log().Fatal("leaderboard scope is not a valid constant")
}
// 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")
slog.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")
slog.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")
slog.Warn("you're using deprecated syntax for 'leaderboard_generation_time', please change it to a semicolon-separated list if valid cron expressions")
}
Set(config)
// post config-load tasks
initOpenIDConnect(config)
return Get()
}
@@ -582,3 +673,11 @@ func BeginningOfWakatime() time.Time {
t, _ := time.Parse(SimpleDateFormat, heartbeatsMinDate)
return t
}
func initOpenIDConnect(config *Config) {
// openid connect
for _, c := range config.Security.OidcProviders {
RegisterOidcProvider(&c)
slog.Info("registered openid connect provider", "provider", c.Name)
}
}

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -25,10 +26,11 @@ func Test_mysqlConnectionString(t *testing.T) {
Dialect: "mysql",
Charset: "utf8mb4",
MaxConn: 10,
Compress: true,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&compress=true&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Host,
@@ -48,10 +50,11 @@ func Test_mysqlConnectionStringSocket(t *testing.T) {
Dialect: "mysql",
Charset: "utf8mb4",
MaxConn: 10,
Compress: true,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@unix(%s)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
"%s:%s@unix(%s)/%s?charset=utf8mb4&parseTime=true&loc=%s&compress=true&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Socket,
@@ -86,21 +89,6 @@ func Test_sqliteConnectionString(t *testing.T) {
Name: "test_name",
Dialect: "sqlite3",
}
assert.Equal(t, c.Name, sqliteConnectionString(c))
}
func Test_mssqlConnectionString(t *testing.T) {
c := &dbConfig{
Name: "dbinstance",
Host: "test_host",
Port: 1433,
User: "test_user",
Password: "test_password",
Dialect: "mssql",
Ssl: true,
}
assert.Equal(t,
"sqlserver://test_user:test_password@test_host:1433?database=dbinstance&encrypt=true",
mssqlConnectionString(c))
assert.True(t, strings.HasPrefix(sqliteConnectionString(c), c.Name))
assert.Contains(t, strings.ToLower(sqliteConnectionString(c)), "journal_mode=wal")
}

View File

@@ -2,12 +2,10 @@ package config
import (
"fmt"
"net/url"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
)
@@ -18,12 +16,17 @@ A quick note to myself including some clarifications about time zones.
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps))
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
- Setting a `loc` parameter specifies what location parsed time.Time objects will be in, however, does not affect the session time zone setting (https://github.com/go-sql-driver/mysql#loc)
- I.e., when not setting `time_zone` in addition, the session time zone will probably default to the server time zone (UTC in case of Docker)
- Session time zone will result in conversions of inserted times from that time zone to UTC
- From my understanding, TIMESTAMP only stores a plain time value without tz information and then converts it only for retrieval to whatever tz is set for the session
- Setting a `loc` parameter specifies location parsed time.Time objects are interpreted in, however, does not affect the session time zone setting (https://github.com/go-sql-driver/mysql#loc)
- I.e., when not setting `time_zone` in addition, the session time zone will default to the SYSTEM time zone (UTC in case of Docker)
- Session time zone will result in conversions of inserted times from that time zone to UTC (but, again, we don't use that)
- TIMESTAMP only stores a plain time value without tz information and then converts it only for retrieval to whatever tz is set for the session
- E.g., when inserting '2021-04-27 08:26:07' with session tz set to Europe/Berlin and then viewing the database table with UTC tz will return '2021-04-27 06:26:07' instead
- Currently, no session tz is set (only loc), so the database server will assume it receives UTC. However, as no tz is set when retrieving the values either, they are also going to be returned just as is and as long as `loc=Local` is set properly, they are parsed in Go code with the correct time zone
- Loc is a parameter of the driver and used in conjunction with parseTime. It results in time.Time objects being converted to that respective zone before storage and converted back to it during retrieval
- E.g., when inserting '2021-04-27 08:26:07 +01:00' with loc set to UTC results in the actual database column to show '2021-04-27 07:26:07'
- A loc value of "Local" is effective the same as setting loc to the value of the TZ environment variable (or system default otherwise)
- Note that if loc is different from "Local", when using models.CustomTime, values are first received in loc zone and then converted to whatever Wakapi's current zone is (e.g. specified through TZ env.), see https://github.com/muety/wakapi/blob/bc2096f4117275d110a84f5b367aa8fdb4bd87ba/models/shared.go#L95.
- Currently, we don't set a session tz (only loc), so the database server will assume it receives dates in its system time. However, as no tz is set when retrieving the values either, they are also going to be returned just as is and as long as `loc=Local` is set properly, they are parsed in Go code with the correct time zone
- In other words, using loc causes the Go driver to take care of zones, while using a session timezone causes MySQL to take care of zones (?)
- As long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
- It is important to have same `time_zone` and `loc` parameters set when sending and receiving, no matter what it is (writing / reading in 'UTC' will yield same results as writing / reading in 'Europe/Berlin')
@@ -37,6 +40,14 @@ A quick note to myself including some clarifications about time zones.
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
For Postgres, the story is even a different one.
- There are `timestamp` and `timestamptz` columns, whereby the former seems to behave very strangely.
- Apparently, when storing dates, they'll just chop off zone information entirely, while for retrieval, all dates are interpreted as UTC
- If Wakapi runs in CEST, '2025-03-25T14:00:00 +02:00' will end up as '2025-03-25T14:00:00' in the database and become '2025-03-25T16:00:00 +02:00' when retrieved back (at least in case of our annoying models.CustomTime, because of https://github.com/muety/wakapi/blob/bc2096f4117275d110a84f5b367aa8fdb4bd87ba/models/shared.go#L95)
- For `timestamptz`, columns don't actually store tz information either (https://github.com/jackc/pgx/issues/520#issuecomment-479692198), but at least allow for correct retrieval. The driver will return dates already in the application's TZ
- Here (https://github.com/go-gorm/gorm/issues/4834) is an interesting discussion on the issue
- According to https://www.cybertec-postgresql.com/en/time-zone-management-in-postgresql/, good practice is to always use timestamptz, leaving conversions to the database itself
*/
func (c *dbConfig) GetDialector() gorm.Dialector {
@@ -52,8 +63,6 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
})
case SQLDialectSqlite:
return sqlite.Open(sqliteConnectionString(c))
case SQLDialectMssql:
return sqlserver.Open(mssqlConnectionString(c))
}
return nil
@@ -70,13 +79,14 @@ func mysqlConnectionString(config *dbConfig) string {
host = fmt.Sprintf("unix(%s)", config.Socket)
}
return fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
return fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=true&loc=%s&compress=%v&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
host,
config.Name,
config.Charset,
"Local",
config.Compress,
)
}
@@ -90,6 +100,9 @@ func postgresConnectionString(config *dbConfig) string {
sslmode = "require"
}
// note: passing a `timezone` param here doesn't seem to have any effect, neither with `timestamp`, not for `timestamptz` columns
// possibly related to https://github.com/go-gorm/postgres/issues/199 ?
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
config.Host,
config.Port,
@@ -101,25 +114,5 @@ func postgresConnectionString(config *dbConfig) string {
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func mssqlConnectionString(config *dbConfig) string {
query := url.Values{}
query.Add("database", config.Name)
if config.Ssl {
query.Add("encrypt", "true")
}
u := &url.URL{
Scheme: "sqlserver",
User: url.UserPassword(config.User, config.Password),
Host: fmt.Sprintf("%s:%d", config.Host, config.Port),
RawQuery: query.Encode(),
}
return u.String()
return fmt.Sprintf("%s?busy_timeout=10000&journal_mode=wal", config.Name)
}

View File

@@ -8,18 +8,19 @@ type ApplicationEvent struct {
}
const (
TopicUser = "user.*"
TopicHeartbeat = "heartbeat.*"
TopicProjectLabel = "project_label.*"
EventUserUpdate = "user.update"
EventUserDelete = "user.delete"
EventHeartbeatCreate = "heartbeat.create"
EventProjectLabelCreate = "project_label.create"
EventProjectLabelDelete = "project_label.delete"
EventWakatimeFailure = "wakatime.failure"
FieldPayload = "payload"
FieldUser = "user"
FieldUserId = "user.id"
TopicUser = "user.*"
TopicHeartbeat = "heartbeat.*"
TopicProjectLabel = "project_label.*"
EventUserUpdate = "user.update"
EventUserDelete = "user.delete"
EventHeartbeatCreate = "heartbeat.create"
EventProjectLabelCreate = "project_label.create"
EventProjectLabelDelete = "project_label.delete"
EventWakatimeFailure = "wakatime.failure"
EventLanguageMappingsChanged = "language_mappings.changed"
FieldPayload = "payload"
FieldUser = "user"
FieldUserId = "user.id"
)
var eventHub *hub.Hub

View File

@@ -8,7 +8,10 @@ import (
// ChooseFS returns a local (DirFS) file system when on 'dev' environment and the given go-embed file system otherwise
func ChooseFS(localDir string, embeddedFS fs.FS) fs.FS {
if Get().IsDev() {
return os.DirFS(localDir)
if _, err := os.Stat(localDir); err == nil {
return os.DirFS(localDir)
}
Log().Warn("attempted to use local fs for directory in dev mode, but failed", "path", localDir)
}
return embeddedFS
}

View File

@@ -2,9 +2,9 @@ package config
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/utils"
"log/slog"
)
var jobQueues map[string]*artifex.Dispatcher
@@ -13,6 +13,7 @@ var jobCounts map[string]int
const (
QueueDefault = "wakapi.default"
QueueProcessing = "wakapi.processing"
QueueProcessing2 = "wakapi.processing_2"
QueueReports = "wakapi.reports"
QueueMails = "wakapi.mail"
QueueImports = "wakapi.imports"
@@ -32,6 +33,7 @@ func init() {
func StartJobs() {
InitQueue(QueueDefault, 1)
InitQueue(QueueProcessing, utils.HalfCPUs())
InitQueue(QueueProcessing2, utils.HalfCPUs())
InitQueue(QueueReports, 1)
InitQueue(QueueMails, 1)
InitQueue(QueueImports, 1)
@@ -42,7 +44,7 @@ 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)
slog.Info("creating job queue", "name", name, "workers", workers)
jobQueues[name] = artifex.NewDispatcher(workers, 4096)
jobQueues[name].Start()
return nil

View File

@@ -1,7 +1,6 @@
package config
import (
"github.com/emvi/logbuch"
"github.com/gorilla/securecookie"
"io"
"os"
@@ -15,13 +14,13 @@ func getTemporarySecureKeys() (hashKey, blockKey []byte) {
if _, err := os.Stat(keyFile); err == nil {
file, err := os.Open(keyFile)
if err != nil {
logbuch.Fatal("failed to open dev keys file, %v", err)
Log().Fatal("failed to open dev keys file", "error", err)
}
defer file.Close()
combinedKey, err := io.ReadAll(file)
if err != nil {
logbuch.Fatal("failed to read key from file")
Log().Fatal("failed to read key from file", "error", err)
}
return combinedKey[:32], combinedKey[32:64]
}
@@ -29,13 +28,13 @@ func getTemporarySecureKeys() (hashKey, blockKey []byte) {
// otherwise, generate random keys and save them
file, err := os.OpenFile(keyFile, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
logbuch.Fatal("failed to open dev keys file, %v", err)
Log().Fatal("failed to open dev keys file", "error", err)
}
defer file.Close()
combinedKey := securecookie.GenerateRandomKey(64)
if _, err := file.Write(combinedKey); err != nil {
logbuch.Fatal("failed to write key to file")
Log().Fatal("failed to write key to file", "error", err)
}
return combinedKey[:32], combinedKey[32:64]
}

16
config/logging.go Normal file
View File

@@ -0,0 +1,16 @@
package config
import (
"log/slog"
"os"
)
func InitLogger(isDev bool) {
var handler slog.Handler
if isDev {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
}
slog.SetDefault(slog.New(handler))
}

90
config/oidc.go Normal file
View File

@@ -0,0 +1,90 @@
package config
import (
"context"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type OidcProvider struct {
Name string
OAuth2 *oauth2.Config
Verifier *oidc.IDTokenVerifier
}
type IdTokenPayload struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Expiry int64 `json:"exp"`
Name string `json:"name"`
Nickname string `json:"nickname"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
ProviderName string `json:"provider_name"` // custom field, not part of actual id token response
}
func (token *IdTokenPayload) Exp() time.Time {
return time.Unix(token.Expiry, 0)
}
func (token *IdTokenPayload) IsValid() bool {
return token.Exp().After(time.Now())
}
func (token *IdTokenPayload) Username() string {
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
if s := strings.TrimSpace(token.PreferredUsername); s != "" {
return s
}
if s := strings.TrimSpace(token.Nickname); s != "" {
return s
}
if s := strings.TrimSpace(token.Subject); s != "" {
return s
}
return ""
}
var oidcProviders = make(map[string]*OidcProvider)
func RegisterOidcProvider(providerCfg *oidcProviderConfig) {
cfg := Get()
provider, err := oidc.NewProvider(context.Background(), providerCfg.Endpoint)
if err != nil {
Log().Fatal(fmt.Sprintf("failed to initialize oidc provider at %s", providerCfg.Endpoint), "error", err)
return
}
oauth2Conf := oauth2.Config{
ClientID: providerCfg.ClientID,
ClientSecret: providerCfg.ClientSecret,
RedirectURL: fmt.Sprintf("%s/oidc/%s/callback", cfg.Server.GetPublicUrl(), providerCfg.Name),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
oidcProviders[providerCfg.Name] = &OidcProvider{
Name: providerCfg.Name,
OAuth2: &oauth2Conf,
Verifier: provider.Verifier(&oidc.Config{ClientID: providerCfg.ClientID}),
}
}
func GetOidcProvider(name string) (*OidcProvider, error) {
provider, ok := oidcProviders[name]
if !ok {
return nil, fmt.Errorf("oidc provider not found: %s", name)
}
return provider, nil
}
func MustGetOidcProvider(name string) *OidcProvider {
provider, _ := GetOidcProvider(name)
return provider
}

View File

@@ -1,104 +1,78 @@
package config
import (
"github.com/emvi/logbuch"
"github.com/getsentry/sentry-go"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/getsentry/sentry-go"
slogmulti "github.com/samber/slog-multi"
slogsentry "github.com/samber/slog-sentry/v2"
)
// How to: Logging
// Use logbuch.[Debug|Info|Warn|Error|Fatal]() by default
// Use slog.[Debug|Info|Warn|Error|Fatal]() by default
// Use config.Log().[Debug|Info|Warn|Error|Fatal]() when wanting the log to appear in Sentry as well
type capturingWriter struct {
Writer io.Writer
Message string
// SentryLogger wraps slog.Logger and provides a Fatal method
type SentryLogger struct {
*slog.Logger
}
func (c *capturingWriter) Clear() {
c.Message = ""
}
var sentryLogger *SentryLogger
func (c *capturingWriter) Write(p []byte) (n int, err error) {
c.Message = string(p)
return c.Writer.Write(p)
}
// SentryWrapperLogger is a wrapper around a logbuch.Logger that forwards events to Sentry in addition and optionally allows to attach a request context
type SentryWrapperLogger struct {
*logbuch.Logger
req *http.Request
outWriter *capturingWriter
errWriter *capturingWriter
}
func Log() *SentryWrapperLogger {
ow, ew := &capturingWriter{Writer: os.Stdout}, &capturingWriter{Writer: os.Stderr}
return &SentryWrapperLogger{
Logger: logbuch.NewLogger(ow, ew),
outWriter: ow,
errWriter: ew,
func New() *SentryLogger {
level := slog.LevelInfo
if IsDev(env) {
level = slog.LevelDebug
}
}
func (l *SentryWrapperLogger) Request(req *http.Request) *SentryWrapperLogger {
l.req = req
return l
}
func (l *SentryWrapperLogger) Debug(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Debug(msg, params...)
l.log(l.errWriter.Message, sentry.LevelDebug)
}
func (l *SentryWrapperLogger) Info(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Info(msg, params...)
l.log(l.errWriter.Message, sentry.LevelInfo)
}
func (l *SentryWrapperLogger) Warn(msg string, params ...interface{}) {
l.outWriter.Clear()
l.Logger.Warn(msg, params...)
l.log(l.errWriter.Message, sentry.LevelWarning)
}
func (l *SentryWrapperLogger) Error(msg string, params ...interface{}) {
l.errWriter.Clear()
l.Logger.Error(msg, params...)
l.log(l.errWriter.Message, sentry.LevelError)
}
func (l *SentryWrapperLogger) Fatal(msg string, params ...interface{}) {
l.errWriter.Clear()
l.Logger.Fatal(msg, params...)
l.log(l.errWriter.Message, sentry.LevelFatal)
}
func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
event := sentry.NewEvent()
event.Level = level
event.Message = msg
if l.req != nil {
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
hub := h.(*sentry.Hub)
hub.Scope().SetRequest(l.req)
if uid := getPrincipal(l.req); uid != "" {
hub.Scope().SetUser(sentry.User{ID: uid})
filterRequestInfo := slogmulti.NewWithAttrsInlineMiddleware(func(attrs []slog.Attr, next func([]slog.Attr) slog.Handler) slog.Handler {
attrsNew := []slog.Attr{}
for _, attr := range attrs {
if attr.Key != "request" {
attrsNew = append(attrsNew, attr)
}
hub.CaptureEvent(event)
return
}
}
return next(attrsNew)
})
sentry.CaptureEvent(event)
return &SentryLogger{Logger: slog.New(
slogmulti.Fanout(
slogmulti.Pipe(filterRequestInfo).Handler(slog.Default().Handler()),
slogsentry.Option{Level: level}.NewSentryHandler(),
),
)}
}
func Log() *SentryLogger {
// note: do not set any state (e.g. request attribute) on this cached logger instance
if sentryLogger != nil {
return sentryLogger
}
sentryLogger = New()
return sentryLogger
}
func (l *SentryLogger) Fatal(msg string, args ...any) {
l.Error(msg, args...)
sentry.Flush(2 * time.Second)
os.Exit(1)
}
func (l *SentryLogger) Request(r *http.Request) *SentryLogger {
ll := New()
ll.Logger = ll.Logger.With("request", r)
if uid := getPrincipal(r); uid != "" {
ll.Logger = ll.Logger.With(slog.Group("user", slog.String("id", uid)))
}
return ll
}
var heartbeatsRouteRegex = regexp.MustCompile(`^POST /api/(?:compat/wakatime/)?(?:v1/)?(?:users/[\w\d-_]+/)?heartbeats?(?:\.bulk)?$`)
var excludedRoutes = []string{
"GET /assets",
"GET /api/health",
@@ -106,10 +80,12 @@ var excludedRoutes = []string{
"GET /docs",
}
func initSentry(config sentryConfig, debug bool) {
func initSentry(config sentryConfig, debug bool, releaseVersion string) {
if err := sentry.Init(sentry.ClientOptions{
Dsn: config.Dsn,
Debug: debug,
Environment: config.Environment,
Release: releaseVersion,
AttachStacktrace: true,
EnableTracing: config.EnableTracing,
TracesSampler: func(ctx sentry.SamplingContext) float64 {
@@ -119,34 +95,30 @@ func initSentry(config sentryConfig, debug bool) {
return 0.0
}
}
if txName == "POST /api/heartbeat" {
if heartbeatsRouteRegex.Match([]byte(txName)) {
return float64(config.SampleRateHeartbeats)
}
return float64(config.SampleRate)
},
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
if hint.Context != nil {
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
if uid := getPrincipal(req); uid != "" {
event.User.ID = uid
}
}
}
// optional pre-processing before sending the event off
return event
},
}); err != nil {
logbuch.Fatal("failed to initialized sentry - %v", err)
Log().Fatal("failed to initialized sentry", "error", err)
}
}
// returns a user id
func getPrincipal(r *http.Request) string {
type principalIdentityGetter interface {
GetPrincipalIdentity() string
sharedData := r.Context().Value(KeySharedData)
if sharedData == nil {
Log().Error("request shared data not set while retrieving principal for sentry logging")
return ""
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalIdentityGetter).GetPrincipalIdentity()
val := sharedData.(*SharedData).MustGet(MiddlewareKeyPrincipalId)
if val == nil {
return ""
}
return ""
return val.(string)
}

View File

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

20
config/shared_data.go Normal file
View File

@@ -0,0 +1,20 @@
package config
import (
"github.com/muety/wakapi/lib"
)
type SharedDataKey string
const (
MiddlewareKeyPrincipal = SharedDataKey("principal")
MiddlewareKeyPrincipalId = SharedDataKey("principal_identity")
)
type SharedData struct {
*lib.ConcurrentMap[SharedDataKey, interface{}]
}
func NewSharedData() *SharedData {
return &SharedData{lib.NewConcurrentMap[SharedDataKey, interface{}]()}
}

13
config/testutils.go Normal file
View File

@@ -0,0 +1,13 @@
package config
func WithOidcProvider(c *Config, name, clientId, clientSecret, Endpoint string) *Config {
providerConf := oidcProviderConfig{
Name: name,
ClientID: clientId,
ClientSecret: clientSecret,
Endpoint: Endpoint,
}
c.Security.OidcProviders = append(c.Security.OidcProviders, providerConf)
RegisterOidcProvider(&providerConf) // config must be Set() for this to work
return c
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,36 @@
#!/bin/bash
# usage: file_env VAR [DEFAULT]
# ie: file_env 'XYZ_DB_PASSWORD' 'example'
# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of
# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature)
file_env() {
local var="$1"
local fileVar="${var}_FILE"
local def="${2:-}"
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar"
exit 1
fi
local val="$def"
if [ "${!var:-}" ]; then
val="${!var}"
elif [ "${!fileVar:-}" ]; then
val="$(< "${!fileVar}")"
fi
export "$var"="$val"
unset "$fileVar"
}
file_env "WAKAPI_PASSWORD_SALT"
file_env "WAKAPI_DB_PASSWORD"
file_env "WAKAPI_MAIL_SMTP_PASS"
file_env "WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"
file_env "WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
exec ./wakapi
else
echo "Waiting for database to come up"
exec ./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

View File

@@ -15,11 +15,8 @@ wakapi.yourdomain.tld {
@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
}

88
funding.json Normal file
View File

@@ -0,0 +1,88 @@
{
"version": "v1.0.0",
"entity": {
"type": "individual",
"role": "owner",
"name": "Ferdinand Mütsch",
"email": "ferdinand@muetsch.io",
"description": "Author and maintainer of Wakapi and many other projects.",
"webpageUrl": {
"url": "https://muetsch.io",
"wellKnown": "https://muetsch.io/.well-known/funding-manifest-urls"
}
},
"projects": [
{
"guid": "wakapi",
"name": "Wakapi",
"description": "A minimalist, self-hosted WakaTime-compatible backend for coding statistics.",
"webpageUrl": {
"url": "https://wakapi.dev",
"wellKnown": "https://wakapi.dev/.well-known/funding-manifest-urls"
},
"repositoryUrl": {
"url": "https://wakapi.dev/github",
"wellKnown": "https://wakapi.dev/.well-known/funding-manifest-urls"
},
"licenses": [
"spdx:MIT"
],
"tags": [
"productivity",
"self-hosted",
"developer-tools",
"time-tracker",
"wakatime",
"coding-stats"
]
}
],
"funding": {
"channels": [
{
"guid": "github-sponsors",
"type": "other",
"address": "https://github.com/sponsors/muety",
"description": "Use GitHub Sponsors to fund my work. This is most convenient for me, but feel free to reach out for alternatives."
}
],
"plans": [
{
"guid": "hosting-wakapi-monthly",
"status": "active",
"name": "Hosting support for Wakapi",
"description": "This will cover the monthly server hosting costs for Wakapi.",
"amount": 5,
"currency": "EUR",
"frequency": "monthly",
"channels": [
"github-sponsors"
]
},
{
"guid": "devtime-wakapi-monthly",
"status": "active",
"name": "Developer compensation for Wakapi",
"description": "This will cover the cost of one developer (myself) working occasionally on Wakapi.",
"amount": 50,
"currency": "EUR",
"frequency": "monthly",
"channels": [
"github-sponsors"
]
},
{
"guid": "angel-plan",
"status": "active",
"name": "Goodwill plan",
"description": "Pay anything you wish to show your goodwill for any of my projects.",
"amount": 0,
"currency": "EUR",
"frequency": "one-time",
"channels": [
"github-sponsors"
]
}
]
}
}

122
go.mod
View File

@@ -1,90 +1,114 @@
module github.com/muety/wakapi
go 1.21
go 1.25
require (
codeberg.org/Codeberg/avatars v1.0.0
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
github.com/alexedwards/argon2id v1.0.0
github.com/alitto/pond v1.8.3
github.com/duke-git/lancet/v2 v2.2.9
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
github.com/emersion/go-smtp v0.20.2
github.com/emvi/logbuch v1.2.0
github.com/getsentry/sentry-go v0.27.0
github.com/glebarez/sqlite v1.10.0
github.com/go-chi/chi/v5 v5.0.11
github.com/go-chi/httprate v0.9.0
github.com/gorilla/schema v1.2.1
github.com/alitto/pond/v2 v2.5.0
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dchest/captcha v1.1.0
github.com/duke-git/lancet/v2 v2.3.7
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/getsentry/sentry-go v0.35.3
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/httprate v0.15.0
github.com/gofrs/uuid/v5 v5.3.2
github.com/gohugoio/hashstructure v0.5.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.2.2
github.com/gorilla/sessions v1.4.0
github.com/hashicorp/golang-lru v1.0.2
github.com/jinzhu/configor v1.2.2
github.com/leandro-lugaresi/hub v1.1.1
github.com/lpar/gzipped/v2 v2.1.0
github.com/mileusna/useragent v1.3.4
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/mileusna/useragent v1.3.5
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v74 v74.30.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.3
github.com/swaggo/swag v1.16.6
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.19.0
gorm.io/driver/mysql v1.5.4
gorm.io/driver/postgres v1.5.6
gorm.io/driver/sqlite v1.5.5
gorm.io/driver/sqlserver v1.5.3
gorm.io/gorm v1.25.7
golang.org/x/crypto v0.42.0
golang.org/x/oauth2 v0.31.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.31.0
)
require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/becheran/wildmatch-go v1.0.0
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-openapi/jsonpointer v0.20.2 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect
github.com/go-openapi/spec v0.20.14 // indirect
github.com/go-openapi/swag v0.22.9 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.3 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.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.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/microsoft/go-mssqldb v1.6.0 // indirect
github.com/microsoft/go-mssqldb v1.9.3 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.1 // indirect
github.com/rs/cors v1.11.1
github.com/samber/lo v1.51.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-multi v1.5.0
github.com/samber/slog-sentry/v2 v2.9.3
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/files v1.0.1 // indirect
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.18.0 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/image v0.31.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.29.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)
godebug x509negativeserial=1 // https://stackoverflow.com/a/79062183/3112139

370
go.sum
View File

@@ -1,27 +1,35 @@
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1 h1:/iHxaJhsFr0+xVFfbMr5vxz848jyiWuIEDhYq3y5odY=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 h1:vcYCAze6p19qBW7MhZybIsqD8sMV8js0NyQM8JDnVtg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0 h1:yfJe15aSwEQ6Oo6J+gdfdulPNoZ3TEhmbhLIoxZcA+U=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 h1:T028gtTPiYt/RMUfs8nVsAL7FDQrfLlrm/NnRG/zcC4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA=
github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0 h1:HCc0+LpPfpCKs6LGGLAhwBARt9632unrVcI6i8s/8os=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
@@ -30,88 +38,115 @@ github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyR
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs=
github.com/alitto/pond v1.8.3/go.mod h1:CmvIIGd5jKLasGI3D87qDkQxjzChdKMmnXMg3fG6M6Q=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/alitto/pond/v2 v2.5.0 h1:vPzS5GnvSDRhWQidmj2djHllOmjFExVFbDGCw1jdqDw=
github.com/alitto/pond/v2 v2.5.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/duke-git/lancet/v2 v2.2.9 h1:ik02ZrFg/OU0lduLfmNqo73mAhpY2a3Fm1RUFcoEtqk=
github.com/duke-git/lancet/v2 v2.2.9/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HHHNs=
github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4=
github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/getsentry/sentry-go v0.35.3 h1:u5IJaEqZyPdWqe/hKlBKBBnMTSxB/HenCqF3QLabeds=
github.com/getsentry/sentry-go v0.35.3/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/httprate v0.9.0 h1:21A+4WDMDA5FyWcg7mNrhj63aNT8CGh+Z1alOE/piU8=
github.com/go-chi/httprate v0.9.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q=
github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/Do=
github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw=
github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE=
github.com/go-openapi/swag v0.22.9/go.mod h1:3/OXnFfnMAwBD099SwYRk7GD3xOrr1iL7d/XNLXVVwE=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
@@ -124,13 +159,16 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -139,18 +177,13 @@ github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeK
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
github.com/lpar/gzipped/v2 v2.1.0 h1:87/ug239roEqXLVOnXZg6NjDfFvMwmkGTKnFWJPUA9U=
github.com/lpar/gzipped/v2 v2.1.0/go.mod h1:G3UlFoFYzjCx6NV4zDmD1BIWMNBaJuKoUvxrEWJuZ3Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc=
github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
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/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
@@ -159,12 +192,16 @@ github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59 h1:kbREB9muGo4sHLoZ
github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
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/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -173,60 +210,89 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
github.com/samber/slog-multi v1.5.0 h1:UDRJdsdb0R5vFQFy3l26rpX3rL3FEPJTJ2yKVjoiT1I=
github.com/samber/slog-multi v1.5.0/go.mod h1:im2Zi3mH/ivSY5XDj6LFcKToRIWPw1OcjSVSdXt+2d0=
github.com/samber/slog-sentry/v2 v2.9.3 h1:2/PZa78BFe0FuW/wm6Q3kBcd1phb1dBFHsCWZ4wX8Ko=
github.com/samber/slog-sentry/v2 v2.9.3/go.mod h1:HGQRgN11HkZqSw/X493Zr65yIRx9ZpjZ2T5v2Dx/REc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
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/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -235,15 +301,29 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -254,20 +334,38 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -275,19 +373,27 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ=
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -299,23 +405,39 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso=
gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs=
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/driver/sqlserver v1.5.3 h1:rjupPS4PVw+rjJkfvr8jn2lJ8BMhT4UW5FwuJY0P3Z0=
gorm.io/driver/sqlserver v1.5.3/go.mod h1:B+CZ0/7oFJ6tAlefsKoyxdgDCXJKSgwS2bMOQZT0I00=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA=
modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"github.com/muety/wakapi/config"
"strings"
"time"
)
@@ -36,6 +37,14 @@ func FormatDateTimeHuman(date time.Time) string {
return date.Format(config.Get().App.DateTimeFormat)
}
func FormatDateTimeHumanTZ(date time.Time) string {
format := config.Get().App.DateTimeFormat
if !strings.HasSuffix(format, "MST") {
format += " MST"
}
return date.Format(format)
}
func FormatDateHuman(date time.Time) string {
return date.Format(config.Get().App.DateFormat)
}

View File

@@ -25,6 +25,6 @@ func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object inte
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)
config.Log().Request(r).Error("error while writing json response", "error", err)
}
}

View File

@@ -2,9 +2,10 @@ package helpers
import (
"errors"
"time"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
func ParseInterval(interval string) (*models.IntervalKey, error) {
@@ -21,20 +22,20 @@ func MustParseInterval(interval string) *models.IntervalKey {
return key
}
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz)
func MustResolveIntervalRawTZ(interval string, tz *time.Location, startOfWeek time.Weekday) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz, startOfWeek)
return from, to
}
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
func ResolveIntervalRawTZ(interval string, tz *time.Location, startOfWeek time.Weekday) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
return err, time.Time{}, time.Time{}
}
return ResolveIntervalTZ(parsed, tz)
return ResolveIntervalTZ(parsed, tz, startOfWeek)
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location, startOfWeek time.Weekday) (err error, from, to time.Time) {
now := time.Now().In(tz)
to = now
@@ -47,10 +48,10 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalPastDay:
from = now.Add(-24 * time.Hour)
case models.IntervalThisWeek:
from = utils.BeginOfThisWeek(tz)
from = utils.BeginOfThisWeek(tz, startOfWeek)
case models.IntervalLastWeek:
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = utils.BeginOfThisWeek(tz)
from = utils.BeginOfThisWeek(tz, startOfWeek).AddDate(0, 0, -7)
to = utils.BeginOfThisWeek(tz, startOfWeek)
case models.IntervalThisMonth:
from = utils.BeginOfThisMonth(tz)
case models.IntervalLastMonth:
@@ -72,7 +73,7 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalPast12Months:
from = now.AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
from = utils.UnixEra()
default:
err = errors.New("invalid interval")
}

View File

@@ -1,16 +1,17 @@
package helpers
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
)
func TestResolveMaximumRange_Default(t *testing.T) {
for i := 1; i <= 367; i++ {
err1, maximumInterval := ResolveMaximumRange(i)
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC)
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC, time.Monday)
assert.Nil(t, err1)
assert.Nil(t, err2)

View File

@@ -2,9 +2,11 @@ package helpers
import (
"errors"
"github.com/muety/wakapi/models"
"net/http"
"time"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
)
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
@@ -15,9 +17,9 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
var from, to time.Time
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
err, from, to = ResolveIntervalRawTZ(interval, user.TZ(), user.StartOfWeekDay())
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
err, from, to = ResolveIntervalRawTZ(start, user.TZ(), user.StartOfWeekDay())
} else {
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
if err != nil {
@@ -69,15 +71,21 @@ func ParseSummaryFilters(r *http.Request) *models.Filters {
if q := r.URL.Query().Get("entity"); q != "" {
filters.With(models.SummaryBranch, q)
}
if q := r.URL.Query().Get("category"); q != "" {
filters.With(models.SummaryCategory, q)
}
return filters
}
func extractUser(r *http.Request) *models.User {
type principalGetter interface {
GetPrincipal() *models.User
sharedData := r.Context().Value(config.KeySharedData)
if sharedData == nil {
config.Log().Error("request shared data not set while retrieving principal")
return nil
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal()
val := sharedData.(*config.SharedData).MustGet(config.MiddlewareKeyPrincipal)
if val == nil {
return nil
}
return nil
return val.(*models.User)
}

44
lib/concurrent_map.go Normal file
View File

@@ -0,0 +1,44 @@
package lib
import "sync"
type ConcurrentMap[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func NewConcurrentMap[K comparable, V any]() *ConcurrentMap[K, V] {
return &ConcurrentMap[K, V]{
items: make(map[K]V),
}
}
func (m *ConcurrentMap[K, V]) Set(key K, value V) {
m.mu.Lock()
defer m.mu.Unlock()
m.items[key] = value
}
func (m *ConcurrentMap[K, V]) Get(key K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
value, ok := m.items[key]
return value, ok
}
func (m *ConcurrentMap[K, V]) MustGet(key K) V {
val, _ := m.Get(key)
return val
}
func (m *ConcurrentMap[K, V]) Delete(key K) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.items, key)
}
func (m *ConcurrentMap[K, V]) Len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.items)
}

89
main.go
View File

@@ -5,6 +5,7 @@ import (
"flag"
"io/fs"
"log"
"log/slog"
"net"
"net/http"
"os"
@@ -12,15 +13,13 @@ import (
"time"
"github.com/duke-git/lancet/v2/condition"
"github.com/emvi/logbuch"
_ "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/lpar/gzipped/v2"
httpSwagger "github.com/swaggo/http-swagger"
_ "gorm.io/driver/mysql"
_ "gorm.io/driver/postgres"
_ "gorm.io/driver/sqlite"
_ "gorm.io/driver/sqlserver"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -68,6 +67,7 @@ var (
keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
durationRepository *repositories.DurationRepository
)
var (
@@ -124,13 +124,7 @@ func main() {
// Configure Swagger docs
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
// Set log level
if config.IsDev() {
logbuch.SetLevel(logbuch.LevelDebug)
} else {
logbuch.SetLevel(logbuch.LevelInfo)
}
logbuch.Info("Wakapi " + version)
slog.Info("Wakapi", "version", config.Version)
// Set up GORM
gormLogger := logger.New(
@@ -144,11 +138,10 @@ func main() {
// Connect to database
var err error
logbuch.Info("starting with %s database", config.Db.Dialect)
slog.Info("starting with database", "dialect", config.Db.Dialect)
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}, conf.GetWakapiDBOpts(&config.Db))
if err != nil {
logbuch.Error(err.Error())
logbuch.Fatal("could not open database")
conf.Log().Fatal("could not connect to database", "error", err)
}
if config.IsDev() {
@@ -156,8 +149,7 @@ func main() {
}
sqlDb, err := db.DB()
if err != nil {
logbuch.Error(err.Error())
logbuch.Fatal("could not connect to database")
conf.Log().Fatal("could not connect to database", "error", err)
}
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
@@ -179,22 +171,23 @@ func main() {
keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
durationRepository = repositories.NewDurationRepository(db)
// Services
mailService = mail.NewMailService()
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(mailService, userRepository)
keyValueService = services.NewKeyValueService(keyValueRepository)
userService = services.NewUserService(keyValueService, mailService, userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
durationService = services.NewDurationService(durationRepository, heartbeatService, userService, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, durationService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService, durationService)
reportService = services.NewReportService(summaryService, userService, mailService)
activityService = services.NewActivityService(summaryService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService, aliasRepository) // can pass any repo here
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
if config.App.LeaderboardEnabled {
@@ -215,6 +208,7 @@ func main() {
routes.Init()
// API Handlers
rootApiHandler := api.NewApiRootHandler()
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
@@ -223,6 +217,7 @@ func main() {
avatarHandler := api.NewAvatarHandler()
activityHandler := api.NewActivityApiHandler(userService, activityService)
badgeHandler := api.NewBadgeHandler(userService, summaryService)
captchaHandler := api.NewCaptchaHandler()
// Compat Handlers
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
@@ -233,17 +228,18 @@ func main() {
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
wakatimeV1LeadersHandler := wtV1Routes.NewLeadersHandler(userService, leaderboardService)
wakatimeV1UserAgentsHandler := wtV1Routes.NewUserAgentsHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService, keyValueService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
summaryHandler := routes.NewSummaryHandler(summaryService, userService, heartbeatService, durationService, aliasService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, durationService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
projectsHandler := routes.NewProjectsHandler(userService, heartbeatService)
homeHandler := routes.NewHomeHandler(userService, keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService, keyValueService)
imprintHandler := routes.NewImprintHandler(keyValueService)
leaderboardHandler := condition.TernaryOperator[bool, routes.Handler](config.App.LeaderboardEnabled, routes.NewLeaderboardHandler(userService, leaderboardService), routes.NewNoopHandler())
leaderboardHandler := condition.Ternary[bool, routes.Handler](config.App.LeaderboardEnabled, routes.NewLeaderboardHandler(userService, leaderboardService), routes.NewNoopHandler())
// Other Handlers
relayHandler := relay.NewRelayHandler()
@@ -254,8 +250,8 @@ func main() {
middleware.CleanPath,
middleware.StripSlashes,
middleware.Recoverer,
middlewares.NewPrincipalMiddleware(),
middlewares.NewLoggingMiddleware(logbuch.Info, []string{
middlewares.NewSharedDataMiddleware(),
middlewares.NewLoggingMiddleware(slog.Info, []string{
"/assets",
"/favicon",
"/service-worker.js",
@@ -289,6 +285,7 @@ func main() {
relayHandler.RegisterRoutes(rootRouter)
// API route registrations
rootApiHandler.RegisterRoutes(apiRouter)
summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
@@ -305,7 +302,9 @@ func main() {
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
wakatimeV1LeadersHandler.RegisterRoutes(apiRouter)
wakatimeV1UserAgentsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
captchaHandler.RegisterRoutes(apiRouter)
// Static Routes
// https://github.com/golang/go/issues/43431
@@ -325,7 +324,7 @@ func main() {
router.Get("/swagger-ui/*", httpSwagger.WrapHandler)
if config.EnablePprof {
logbuch.Info("profiling enabled, exposing pprof data at http://127.0.0.1:6060/debug/pprof")
slog.Info("profiling enabled, exposing pprof data", "url", "http://127.0.0.1:6060/debug/pprof")
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
@@ -364,9 +363,9 @@ func listen(handler http.Handler) {
if config.Server.ListenSocket != "-" && config.Server.ListenSocket != "" {
// Remove if exists
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
logbuch.Info("👉 Removing unix socket %s", config.Server.ListenSocket)
slog.Info("👉 Removing unix socket", "listenSocket", config.Server.ListenSocket)
if err := os.Remove(config.Server.ListenSocket); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}
sSocket = &http.Server{
@@ -378,65 +377,65 @@ func listen(handler http.Handler) {
if config.UseTLS() {
if s4 != nil {
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s4.Addr)
slog.Info("👉 Listening for HTTPS... ✅", "address", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}
if s6 != nil {
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s6.Addr)
slog.Info("👉 Listening for HTTPS... ✅", "address", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}
if sSocket != nil {
logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
slog.Info("👉 Listening for HTTPS... ✅", "address", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
if err := os.Chmod(config.Server.ListenSocket, os.FileMode(config.Server.ListenSocketMode)); err != nil {
logbuch.Warn("failed to set user permissions for unix socket, %v", err)
slog.Warn("failed to set user permissions for unix socket", "error", err)
}
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}
} else {
if s4 != nil {
logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
slog.Info("👉 Listening for HTTP... ✅", "address", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}
if s6 != nil {
logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
slog.Info("👉 Listening for HTTP... ✅", "address", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}
if sSocket != nil {
logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
slog.Info("👉 Listening for HTTP... ✅", "address", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
if err := os.Chmod(config.Server.ListenSocket, os.FileMode(config.Server.ListenSocketMode)); err != nil {
logbuch.Warn("failed to set user permissions for unix socket, %v", err)
slog.Warn("failed to set user permissions for unix socket", "error", err)
}
if err := sSocket.Serve(unixListener); err != nil {
logbuch.Fatal(err.Error())
conf.Log().Fatal(err.Error())
}
}()
}

View File

@@ -3,12 +3,15 @@ package middlewares
import (
"errors"
"fmt"
"github.com/duke-git/lancet/v2/slice"
"github.com/muety/wakapi/helpers"
"net"
"net/http"
"strings"
"github.com/duke-git/lancet/v2/slice"
"github.com/gofrs/uuid/v5"
"github.com/muety/wakapi/helpers"
routeutils "github.com/muety/wakapi/routes/utils"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
@@ -28,15 +31,17 @@ type AuthenticateMiddleware struct {
config *conf.Config
userSrvc services.IUserService
optionalForPaths []string
optionalForMethods []string
redirectTarget string // optional
redirectErrorMessage string // optional
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
}
}
@@ -45,6 +50,11 @@ func (m *AuthenticateMiddleware) WithOptionalFor(paths ...string) *AuthenticateM
return m
}
func (m *AuthenticateMiddleware) WithOptionalForMethods(methods ...string) *AuthenticateMiddleware {
m.optionalForMethods = methods
return m
}
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
m.redirectTarget = path
return m
@@ -64,6 +74,12 @@ func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
var user *models.User
if m.tryHandleOidc(w, r) {
// user has expired oidc token, thus is redirected to provider and will come back to callback endpoint
// notably, if user does have a valid, non-expired id token, they will also have a valid auth cookie, so proceed as usual
return
}
user, err := m.tryGetUserByCookie(r)
if err != nil {
user, err = m.tryGetUserByApiKeyHeader(r)
@@ -72,11 +88,11 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
user, err = m.tryGetUserByApiKeyQuery(r)
}
if err != nil && m.config.Security.TrustedHeaderAuth {
user, err = m.tryGetUserByTrustedHeader(r)
user, err = m.tryGetUserByTrustedHeader(r, m.config.Security.TrustedHeaderAuthAllowSignup)
}
if err != nil || user == nil {
if m.isOptional(r.URL.Path) {
if m.isOptional(r) {
next(w, r)
return
}
@@ -86,7 +102,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
w.Write([]byte(conf.ErrUnauthorized))
} else {
if m.redirectErrorMessage != "" {
session, _ := conf.GetSessionStore().Get(r, conf.SessionKeyDefault)
session, _ := conf.GetSessionStore().Get(r, conf.CookieKeySession)
session.AddFlash(m.redirectErrorMessage, "error")
session.Save(r, w)
}
@@ -100,9 +116,14 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
next(w, r)
}
func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
func (m *AuthenticateMiddleware) isOptional(r *http.Request) bool {
for _, p := range m.optionalForPaths {
if strings.HasPrefix(requestPath, p) || requestPath == p {
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
return true
}
}
for _, m := range m.optionalForMethods {
if r.Method == strings.ToUpper(m) {
return true
}
}
@@ -138,16 +159,41 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
return user, nil
}
func (m *AuthenticateMiddleware) tryGetUserByTrustedHeader(r *http.Request) (*models.User, error) {
func (m *AuthenticateMiddleware) tryGetUserByTrustedHeader(r *http.Request, create bool) (*models.User, error) {
if !m.config.Security.TrustedHeaderAuth {
return nil, errors.New("trusted header auth disabled")
}
create = create && m.config.Security.TrustedHeaderAuthAllowSignup // double-check
remoteUser := r.Header.Get(m.config.Security.TrustedHeaderAuthKey)
if remoteUser == "" {
return nil, errors.New("trusted header field empty")
}
if addr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr); err != nil || !slice.ContainBy[net.IP](m.config.Security.TrustReverseProxyIPs(), func(ip net.IP) bool {
return addr.IP.Equal(ip)
if addr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr); err != nil || !slice.ContainBy[net.IPNet](m.config.Security.TrustReverseProxyIPs(), func(ipNet net.IPNet) bool {
return ipNet.Contains(addr.IP) // if err != nil, addr is nil
}) {
return nil, errors.New("reverse proxy not trusted")
}
user, err := m.userSrvc.GetUserById(remoteUser)
if err == nil {
return user, nil
}
if err.Error() != "record not found" || !create {
return nil, err
}
// register new user solely based on upstream provided username (see https://github.com/muety/wakapi/issues/808)
signup := &models.Signup{
Username: remoteUser,
Password: uuid.Must(uuid.NewV4()).String(), // throwaway random string as password
}
conf.Log().Request(r).Warn("registering new remotely authenticated user based on trusted header auth", "user_id", remoteUser)
if _, _, err := m.userSrvc.CreateOrGet(signup, false); err != nil {
return nil, err
}
return m.userSrvc.GetUserById(remoteUser)
}
@@ -167,3 +213,31 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return user, nil
}
// redirect if oidc id token was found, but expired
// returns true if further authentication can be skipped
func (m *AuthenticateMiddleware) tryHandleOidc(w http.ResponseWriter, r *http.Request) bool {
idToken := routeutils.GetOidcIdTokenPayload(r)
if idToken == nil {
return false
}
if !idToken.IsValid() { // expired
provider, err := m.config.Security.GetOidcProvider(idToken.ProviderName)
if err != nil {
conf.Log().Request(r).Error("failed to get provider from id token", "provider", idToken.ProviderName, "sub", idToken.Subject)
return false
}
if _, err := m.userSrvc.GetUserByOidc(provider.Name, idToken.Subject); err != nil {
conf.Log().Request(r).Error("got expired oidc token for non-oidc user", "provider", idToken.ProviderName, "sub", idToken.Subject)
return false
}
state := routeutils.SetNewOidcState(r, w)
http.Redirect(w, r, provider.OAuth2.AuthCodeURL(state), http.StatusFound)
return true
}
return false
}

View File

@@ -2,11 +2,19 @@ package middlewares
import (
"encoding/base64"
"errors"
"fmt"
"github.com/muety/wakapi/config"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/muety/wakapi/config"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/oauth2-proxy/mockoidc"
"github.com/stretchr/testify/mock"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
@@ -112,34 +120,7 @@ func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Disabled(t *testing.T)
testUser := &models.User{ID: "user01"}
mockRequest := &http.Request{
Header: http.Header{"Remote-User": []string{testUser.ID}},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest)
assert.Error(t, actualErr)
assert.Nil(t, result)
}
func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Untrusted(t *testing.T) {
cfg := config.Empty()
cfg.Security.TrustedHeaderAuth = true
cfg.Security.TrustedHeaderAuthKey = "Remote-User"
cfg.Security.TrustReverseProxyIps = "192.168.0.1"
cfg.Security.ParseTrustReverseProxyIPs()
config.Set(cfg)
testUser := &models.User{ID: "user01"}
mockRequest := &http.Request{
Header: http.Header{
"Remote-User": []string{testUser.ID},
"X-Forwarded-For": []string{"192.168.0.1"},
},
Header: http.Header{"Remote-User": []string{testUser.ID}},
RemoteAddr: "127.0.0.1:54654",
}
@@ -148,34 +129,233 @@ func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Untrusted(t *testing.T
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest, false)
assert.Error(t, actualErr)
assert.Nil(t, result)
}
func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Untrusted(t *testing.T) {
cfg := config.Empty()
cfg.Security.TrustedHeaderAuth = true
cfg.Security.TrustedHeaderAuthKey = "Remote-User"
cfg.Security.TrustReverseProxyIps = "127.0.0.1,::1,192.168.0.1,192.168.178.0/24,33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb,1ddc:e2d6:dcce:ab6c::1/64"
cfg.Security.ParseTrustReverseProxyIPs()
config.Set(cfg)
testIps := []string{"192.168.0.2", "192.168.179.35", "[33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafa]", "[1ddc:e2d6:dcce:ab6b:1ba7:7aaa:58dc:a42b]"} // none of these should be authorized
testUser := &models.User{ID: "user01"}
for _, ip := range testIps {
mockRequest := &http.Request{
Header: http.Header{
"Remote-User": []string{testUser.ID},
"X-Forwarded-For": []string{"192.168.0.1"}, // forward for some trusted ip -> header should be ignored for auth. checks, because only actual reverse proxy must be legitimized
},
RemoteAddr: fmt.Sprintf("%s:54654", ip),
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest, false)
assert.Error(t, actualErr)
assert.Nil(t, result)
}
}
func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Success(t *testing.T) {
cfg := config.Empty()
cfg.Security.TrustedHeaderAuth = true
cfg.Security.TrustedHeaderAuthKey = "Remote-User"
cfg.Security.TrustReverseProxyIps = "127.0.0.1,::1"
cfg.Security.TrustReverseProxyIps = "127.0.0.1,::1,192.168.0.1,192.168.178.0/24,33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb,1ddc:e2d6:dcce:ab6c::1/64"
cfg.Security.ParseTrustReverseProxyIPs()
config.Set(cfg)
testIps := []string{"127.0.0.1", "[::1]", "192.168.0.1", "192.168.178.1", "192.168.178.35", "[33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb]", "[1ddc:e2d6:dcce:ab6c:2ba7:7aaa:58dc:a42b]", "[1ddc:e2d6:dcce:ab6c:1ba7:7aaa:58dc:a42b]"} // all of these should be authorized
testUser := &models.User{ID: "user01"}
mockRequest := &http.Request{
Header: http.Header{"Remote-User": []string{testUser.ID}},
RemoteAddr: "[::1]:54654",
for _, ip := range testIps {
mockRequest := &http.Request{
Header: http.Header{"Remote-User": []string{testUser.ID}},
RemoteAddr: fmt.Sprintf("%s:54654", ip),
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest, false)
assert.Equal(t, testUser, result)
assert.Nil(t, actualErr)
}
}
func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_NoSignup(t *testing.T) {
cfg := config.Empty()
cfg.Security.TrustedHeaderAuth = true
cfg.Security.TrustedHeaderAuthAllowSignup = false
cfg.Security.TrustedHeaderAuthKey = "Remote-User"
cfg.Security.TrustReverseProxyIps = "127.0.0.1,::1,192.168.0.1,192.168.178.0/24,33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb,1ddc:e2d6:dcce:ab6c::1/64"
cfg.Security.ParseTrustReverseProxyIPs()
config.Set(cfg)
testIps := []string{"127.0.0.1", "[::1]", "192.168.0.1", "192.168.178.1", "192.168.178.35", "[33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb]", "[1ddc:e2d6:dcce:ab6c:2ba7:7aaa:58dc:a42b]", "[1ddc:e2d6:dcce:ab6c:1ba7:7aaa:58dc:a42b]"} // all of these should be authorized
testUser := &models.User{ID: "nonexisting"}
for _, ip := range testIps {
mockRequest := &http.Request{
Header: http.Header{"Remote-User": []string{testUser.ID}},
RemoteAddr: fmt.Sprintf("%s:54654", ip),
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(nil, errors.New("record not found"))
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest, false)
assert.Error(t, actualErr)
assert.Nil(t, result)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 1)
}
}
func TestAuthenticateMiddleware_tryGetUserByTrustedHeader_Signup(t *testing.T) {
cfg := config.Empty()
cfg.Security.TrustedHeaderAuth = true
cfg.Security.TrustedHeaderAuthAllowSignup = true
cfg.Security.TrustedHeaderAuthKey = "Remote-User"
cfg.Security.TrustReverseProxyIps = "127.0.0.1,::1,192.168.0.1,192.168.178.0/24,33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb,1ddc:e2d6:dcce:ab6c::1/64"
cfg.Security.ParseTrustReverseProxyIPs()
config.Set(cfg)
testIps := []string{"127.0.0.1", "[::1]", "192.168.0.1", "192.168.178.1", "192.168.178.35", "[33b7:08d8:c07a:c2ee:0fac:cb95:dadc:dafb]", "[1ddc:e2d6:dcce:ab6c:2ba7:7aaa:58dc:a42b]", "[1ddc:e2d6:dcce:ab6c:1ba7:7aaa:58dc:a42b]"} // all of these should be authorized
testUser := &models.User{ID: "tobecreated"}
for _, ip := range testIps {
mockRequest := &http.Request{
Header: http.Header{"Remote-User": []string{testUser.ID}},
RemoteAddr: fmt.Sprintf("%s:54654", ip),
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(nil, errors.New("record not found"))
userServiceMock.On("CreateOrGet", mock.Anything, false).Return(testUser, true, nil)
userServiceMock.On("GetUserById", testUser.ID).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest, true)
assert.Error(t, actualErr)
assert.Nil(t, result)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 2)
}
}
func TestAuthenticateMiddleware_tryHandleOidc_NoToken(t *testing.T) {
config.Set(config.Empty())
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", testUser.ID).Return(testUser, nil)
r := httptest.NewRequest(http.MethodGet, "/summary", nil)
w := httptest.NewRecorder()
sut := NewAuthenticateMiddleware(userServiceMock)
result, actualErr := sut.tryGetUserByTrustedHeader(mockRequest)
assert.Equal(t, testUser, result)
assert.Nil(t, actualErr)
assert.False(t, sut.tryHandleOidc(w, r))
assert.NotEqual(t, w.Code, http.StatusTemporaryRedirect)
assert.NotEqual(t, w.Code, http.StatusFound)
}
func TestAuthenticateMiddleware_tryHandleOidc_InvalidToken_ExistingUser(t *testing.T) {
const (
testProvider = "mock"
testSub = "testsub"
)
var testUser = &models.User{ID: "testuser"}
oidcMock, _ := mockoidc.Run()
defer oidcMock.Shutdown()
cfg := config.Empty()
config.Set(cfg)
config.WithOidcProvider(cfg, testProvider, oidcMock.ClientID, oidcMock.ClientSecret, oidcMock.Addr()+"/oidc")
r := httptest.NewRequest(http.MethodGet, "/summary", nil)
w := httptest.NewRecorder()
testIdToken := &config.IdTokenPayload{
Subject: testSub,
Expiry: time.Now().Add(-time.Minute).Unix(),
ProviderName: testProvider,
}
routeutils.SetOidcIdTokenPayload(testIdToken, r, w)
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByOidc", testProvider, testSub).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
assert.True(t, sut.tryHandleOidc(w, r))
assert.Equal(t, w.Code, http.StatusFound)
assert.True(t, strings.HasPrefix(w.Header().Get("Location"), oidcMock.AuthorizationEndpoint()))
assert.NotEmpty(t, routeutils.GetOidcState(r))
assert.Contains(t, w.Header().Get("Location"), fmt.Sprintf("state=%s", routeutils.GetOidcState(r)))
}
func TestAuthenticateMiddleware_tryHandleOidc_InvalidToken_NonExistingUser(t *testing.T) {
const (
testProvider = "mock"
testSub = "testsub"
)
oidcMock, _ := mockoidc.Run()
defer oidcMock.Shutdown()
cfg := config.Empty()
config.Set(cfg)
config.WithOidcProvider(cfg, testProvider, oidcMock.ClientID, oidcMock.ClientSecret, oidcMock.Addr()+"/oidc")
r := httptest.NewRequest(http.MethodGet, "/summary", nil)
w := httptest.NewRecorder()
testIdToken := &config.IdTokenPayload{
Subject: testSub,
Expiry: time.Now().Add(-time.Minute).Unix(),
ProviderName: testProvider,
}
routeutils.SetOidcIdTokenPayload(testIdToken, r, w)
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByOidc", testProvider, testSub).Return(nil, errors.New(""))
sut := NewAuthenticateMiddleware(userServiceMock)
assert.False(t, sut.tryHandleOidc(w, r))
assert.NotEqual(t, w.Code, http.StatusTemporaryRedirect)
assert.NotEqual(t, w.Code, http.StatusFound)
}
func TestAuthenticateMiddleware_tryHandleOidc_ValidToken(t *testing.T) {
config.Set(config.Empty())
userServiceMock := new(mocks.UserServiceMock)
r := httptest.NewRequest(http.MethodGet, "/summary", nil)
w := httptest.NewRecorder()
routeutils.SetOidcIdTokenPayload(&config.IdTokenPayload{
Expiry: time.Now().Add(1 * time.Minute).Unix(),
}, r, w)
sut := NewAuthenticateMiddleware(userServiceMock)
assert.False(t, sut.tryHandleOidc(w, r))
assert.NotEqual(t, w.Code, http.StatusTemporaryRedirect)
assert.NotEqual(t, w.Code, http.StatusFound)
}
// TODO: somehow test cookie auth function

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@@ -14,7 +13,7 @@ import (
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/patrickmn/go-cache"
"io"
"io/ioutil"
"log/slog"
"net/http"
"time"
)
@@ -63,13 +62,13 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
err := m.filterByCache(r)
if err != nil {
logbuch.Warn("%v", err)
slog.Warn("filter cache error", "error", err)
return
}
body, _ := ioutil.ReadAll(r.Body)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
r.Body = io.NopCloser(bytes.NewBuffer(body))
// prevent cycles
downstreamInstanceId := ownInstanceId
@@ -105,7 +104,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) {
request, err := http.NewRequest(method, url, body)
if err != nil {
logbuch.Warn("error constructing relayed request - %v", err)
slog.Warn("error constructing relayed request", "error", err)
return
}
@@ -117,12 +116,12 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
response, err := m.httpClient.Do(request)
if err != nil {
logbuch.Warn("error executing relayed request - %v", err)
slog.Warn("error executing relayed request", "error", err)
return
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
logbuch.Warn("failed to relay request for user %s, got status %d", forUser.ID, response.StatusCode)
slog.Warn("failed to relay request for user", "userID", forUser.ID, "statusCode", response.StatusCode)
// TODO: use leaky bucket instead of expiring cache?
if _, found := m.failureCache.Get(forUser.ID); !found {
@@ -134,7 +133,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
Fields: map[string]interface{}{config.FieldUser: forUser, config.FieldPayload: n},
})
} else if n%10 == 0 {
logbuch.Warn("%d / %d failed wakatime heartbeat relaying attempts for user %s within last 24 hours", n, maxFailuresPerDay, forUser.ID)
slog.Warn("failed wakatime heartbeat relaying attempts for user", "failedCount", n, "maxFailures", maxFailuresPerDay, "userID", forUser.ID)
}
}
}
@@ -149,12 +148,12 @@ func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
return err
}
body, _ := ioutil.ReadAll(r.Body)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
r.Body = io.NopCloser(bytes.NewBuffer(body))
var rawData interface{}
if err := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))).Decode(&rawData); err != nil {
if err := json.NewDecoder(io.NopCloser(bytes.NewBuffer(body))).Decode(&rawData); err != nil {
return err
}
@@ -183,14 +182,14 @@ func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
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)
slog.Warn("only relaying partial heartbeats for user", "relayedCount", len(newData), "totalCount", len(heartbeats), "userID", user.ID)
}
buf := bytes.Buffer{}
if err := json.NewEncoder(&buf).Encode(newData); err != nil {
return err
}
r.Body = ioutil.NopCloser(&buf)
r.Body = io.NopCloser(&buf)
return nil
}

View File

@@ -1,6 +1,7 @@
package middlewares
// Borrowed from https://gist.github.com/elithrar/887d162dfd0c539b700ab4049c76e22b
// Alternatively, we could use https://github.com/samber/slog-chi, however, it pulls in another bunch of dependencies and log messages are more verbose and feel almost little bloated
import (
"io"
@@ -42,15 +43,14 @@ func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
lg.logFunc(
"[request] status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s, user=%s",
ww.Status(),
r.Method,
r.URL.String(),
duration,
ww.BytesWritten(),
readUserIP(r),
readUserID(r),
lg.logFunc("[request]",
"status", ww.Status(),
"method", r.Method,
"uri", r.URL.String(),
"duration", duration,
"bytes", ww.BytesWritten(),
"addr", readUserIP(r),
"user", readUserID(r),
)
}

View File

@@ -1,71 +1,16 @@
package middlewares
import (
"context"
"github.com/muety/wakapi/models"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
)
const keyPrincipal = "principal"
type PrincipalContainer struct {
principal *models.User
}
func (c *PrincipalContainer) SetPrincipal(user *models.User) {
c.principal = user
}
func (c *PrincipalContainer) GetPrincipal() *models.User {
return c.principal
}
func (c *PrincipalContainer) GetPrincipalIdentity() string {
if c.principal == nil {
return ""
}
return c.principal.Identity()
}
// 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
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
// handler 1 will not have access to values handler 2 writes to its context. In addition, Context.WithValue
// returns a new context with the old context as a parent.
//
// As a concrete example, SentryMiddleware as well as LoggingMiddleware should be quite the outer layers,
// while AuthenticationMiddleware is on the very inside of the chain. However, we still want sentry or the
// logger to have access to the user object populated by the auth. middleware, if present.
//
// This middleware shall be included as the outermost layers and it injects a stateful container that does
// nothing but conditionally hold a reference to an authenticated user object.
//
// Other reference: https://stackoverflow.com/questions/55972869/send-errors-to-sentry-with-golang-and-mux
type PrincipalMiddleware struct {
handler http.Handler
}
func NewPrincipalMiddleware() func(handler http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &PrincipalMiddleware{handler: h}
}
}
func (p *PrincipalMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), keyPrincipal, &PrincipalContainer{})
p.handler.ServeHTTP(w, r.WithContext(ctx))
}
func SetPrincipal(r *http.Request, user *models.User) {
if p := r.Context().Value(keyPrincipal); p != nil {
p.(*PrincipalContainer).SetPrincipal(user)
}
routeutils.SetPrincipal(r, user)
}
func GetPrincipal(r *http.Request) *models.User {
if p := r.Context().Value(keyPrincipal); p != nil {
return p.(*PrincipalContainer).GetPrincipal()
}
return nil
return routeutils.GetPrincipal(r)
}

View File

@@ -0,0 +1,42 @@
package middlewares
import (
"context"
"net/http"
"github.com/muety/wakapi/config"
)
const key = config.KeySharedData
// 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
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
// handler 1 will not have access to values handler 2 writes to its context. In addition, Context.WithValue
// returns a new context with the old context as a parent.
//
// As a concrete example, SentryMiddleware as well as LoggingMiddleware should be quite the outer layers,
// while AuthenticationMiddleware is on the very inside of the chain. However, we still want sentry or the
// logger to have access to the user object populated by the auth. middleware, if present.
//
// This middleware shall be included as the outermost layers and it injects a stateful container that does
// nothing but conditionally hold a reference to an authenticated user object.
//
// Other reference: https://stackoverflow.com/questions/55972869/send-errors-to-sentry-with-golang-and-mux
type SharedDataMiddleware struct {
Data *config.SharedData
handler http.Handler
}
func NewSharedDataMiddleware() func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return &SharedDataMiddleware{handler: h}
}
}
func (s *SharedDataMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data := config.NewSharedData()
ctx := context.WithValue(r.Context(), key, data)
s.handler.ServeHTTP(w, r.WithContext(ctx))
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -16,12 +16,12 @@ func init() {
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
if migrator.HasTable(oldTableName) {
logbuch.Info("renaming '%s' table to '%s'", oldTableName, newTableName)
slog.Info("renaming table", "oldName", oldTableName, "newName", newTableName)
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
return err
}
logbuch.Info("renaming '%s' index to '%s'", oldIndexName, newIndexName)
slog.Info("renaming index", "oldName", oldIndexName, "newName", newIndexName)
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
}
return nil

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -22,12 +22,12 @@ func init() {
// https://stackoverflow.com/a/1884893/3112139
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
// things like deleting all summaries won't work in those cases unless an entirely new db is created
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
slog.Info("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
logbuch.Info("key-value table not yet existing")
slog.Info("key-value table not yet existing")
return nil
}
@@ -51,7 +51,7 @@ func init() {
for name, table := range constraints {
if migrator.HasConstraint(table, name) {
logbuch.Info("dropping constraint '%s'", name)
slog.Info("dropping constraint", "name", name)
if err := migrator.DropConstraint(table, name); err != nil {
return err
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -17,12 +17,12 @@ func init() {
if cfg.Db.Dialect == config.SQLDialectSqlite {
// see 20201106_migration_cascade_constraints
logbuch.Info("not attempting to drop and regenerate constraints on sqlite")
slog.Info("not attempting to drop and regenerate constraints on sqlite")
return nil
}
if !migrator.HasTable(&models.KeyStringValue{}) {
logbuch.Info("key-value table not yet existing")
slog.Info("key-value table not yet existing")
return nil
}
@@ -31,7 +31,7 @@ func init() {
}
if migrator.HasConstraint(&models.Alias{}, "fk_aliases_user") {
logbuch.Info("dropping constraint 'fk_aliases_user'")
slog.Info("dropping constraint", "name", "fk_aliases_user")
if err := migrator.DropConstraint(&models.Alias{}, "fk_aliases_user"); err != nil {
return err
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -38,14 +38,14 @@ func init() {
}
if cfg.Db.Dialect == config.SQLDialectSqlite {
logbuch.Info("not attempting to drop column 'badges_enabled' on sqlite")
slog.Info("not attempting to drop column on sqlite", "column", "badges_enabled")
return nil
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
}
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
slog.Info("dropped column after substituting it by sharing indicators", "column", "badges_enabled")
return nil
},

View File

@@ -1,9 +1,9 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -12,7 +12,7 @@ func init() {
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if err := db.Migrator().DropTable("gorp_migrations"); err == nil {
logbuch.Info("dropped table 'gorp_migrations'")
slog.Info("dropped table", "table", "gorp_migrations")
}
return nil
},

View File

@@ -2,10 +2,10 @@ package migrations
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -19,14 +19,14 @@ func init() {
rawDb, err := db.DB()
if err != nil {
logbuch.Error("failed to retrieve raw sql db instance")
slog.Error("failed to retrieve raw sql db instance")
return err
}
if _, err := rawDb.Exec(fmt.Sprintf("delete from summary_items where type = %d", models.SummaryLabel)); err != nil {
logbuch.Error("failed to delete project label summary items")
slog.Error("failed to delete project label summary items")
return err
}
logbuch.Info("successfully deleted project label summary items")
slog.Info("successfully deleted project label summary items")
setHasRun(name, db)
return nil

View File

@@ -1,9 +1,9 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -15,7 +15,7 @@ func init() {
return nil
}
logbuch.Info("this may take a while!")
slog.Info("this may take a while!")
if cfg.Db.IsMySQL() {
tx := db.Begin()
@@ -40,7 +40,7 @@ func init() {
} else {
// sqlite doesn't allow for changing column type easily
// https://stackoverflow.com/a/2083562/3112139
logbuch.Warn("unable to migrate id columns to bigint on %s", cfg.Db.Dialect)
slog.Warn("unable to migrate id columns to bigint", "dialect", cfg.Db.Dialect)
}
setHasRun(name, db)

View File

@@ -2,10 +2,10 @@ package migrations
import (
"database/sql"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -17,7 +17,7 @@ func init() {
return nil
}
logbuch.Info("this may take a while!")
slog.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
@@ -35,7 +35,7 @@ func init() {
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
logbuch.Error("failed to retroactively determine total summary heartbeats")
slog.Error("failed to retroactively determine total summary heartbeats")
return err
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -16,7 +16,7 @@ func init() {
return nil
}
logbuch.Info("this may take a while!")
slog.Info("this may take a while!")
// find all summaries whose num_heartbeats is zero even though they have items
var faultyIds []uint
@@ -45,7 +45,7 @@ func init() {
return err
}
logbuch.Info("corrected heartbeats counter of %d summaries", result.RowsAffected)
slog.Info("corrected heartbeats counter of summaries", "count", result.RowsAffected)
setHasRun(name, db)
return nil

View File

@@ -1,9 +1,9 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -16,7 +16,7 @@ func init() {
}
if cfg.Db.IsMySQL() {
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
slog.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;")
@@ -29,7 +29,7 @@ func init() {
db.Exec("SET foreign_key_checks=1;")
db.Exec("SET unique_checks=1;")
logbuch.Info("migrated timestamp columns to millisecond precision")
slog.Info("migrated timestamp columns to millisecond precision")
}
setHasRun(name, db)

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -19,14 +19,14 @@ func init() {
migrator := db.Migrator()
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
logbuch.Info("running migration '%s'", name)
slog.Info("running migration", "name", name)
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
slog.Warn("failed to drop constraint", "constraint", "fk_diagnostics_user", "error", err)
}
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
slog.Warn("failed to drop column", "table", "diagnostics", "column", "user_id", "error", err)
}
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
// migration to fix https://github.com/muety/wakapi/issues/346
@@ -25,9 +25,9 @@ func init() {
}
if cfg.Db.IsSQLite() && db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
logbuch.Info("running migration '%s'", name)
slog.Info("running migration", "name", name)
if err := db.Migrator().DropIndex(&models.Heartbeat{}, idxName); err != nil {
logbuch.Warn("failed to drop %s", idxName)
slog.Warn("failed to drop index", "indexName", idxName)
}
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -19,10 +19,10 @@ func init() {
migrator := db.Migrator()
if migrator.HasTable(&models.LeaderboardItem{}) && migrator.HasColumn(&models.LeaderboardItem{}, "rank") {
logbuch.Info("running migration '%s'", name)
slog.Info("running migration", "name", name)
if err := migrator.DropColumn(&models.LeaderboardItem{}, "rank"); err != nil {
logbuch.Warn("failed to drop 'rank' column (%v)", err)
slog.Warn("failed to drop column", "column", "rank", "error", err)
}
}

View File

@@ -4,10 +4,10 @@ import (
"regexp"
"strings"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
// due to an error in the model definition, idx_time_user used to only cover 'user_id', but not time column
@@ -25,7 +25,7 @@ func init() {
var drop bool
if cfg.Db.IsMssql() {
//mssql migrator doesn't support GetIndexes() currently
// mssql migrator doesn't support GetIndexes() currently
// mssql is implemented after this migration, so ignore it.
return nil
}
@@ -67,7 +67,7 @@ func init() {
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")
slog.Info("index 'idx_time_user' needs to be recreated, this may take a while")
return nil
},

View File

@@ -1,9 +1,9 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -15,7 +15,7 @@ func init() {
return nil
}
logbuch.Info("running migration '%s'", name)
slog.Info("running migration", "name", name)
if err := db.Exec("UPDATE heartbeats SET language = 'Astro' where language = '' and entity like '%.astro'").Error; err != nil {
return err

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
)
func init() {
@@ -19,7 +19,7 @@ func init() {
migrator := db.Migrator()
if migrator.HasColumn(&models.User{}, "subscription_renewal") {
logbuch.Info("running migration '%s'", name)
slog.Info("running migration", "name", name)
if err := db.Exec("UPDATE users SET subscription_renewal = subscribed_until WHERE subscribed_until is not null").Error; err != nil {
return err

View File

@@ -1,7 +1,7 @@
package migrations
import (
"github.com/alitto/pond"
"github.com/alitto/pond/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
@@ -22,7 +22,7 @@ func init() {
return err
}
wp := pond.New(utils.AllCPUs(), 0)
wp := pond.NewPool(utils.AllCPUs())
// this is the most inefficient way to perform the update, but i couldn't find a way to do this is a single query
for _, h := range heartbeats {

View File

@@ -0,0 +1,30 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20241220-share_activity_chart_flag"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
db.
Model(&models.User{}).
Where("share_data_max_days < ?", 0).
Or("share_data_max_days >= ?", 365).
Update("share_activity_chart", true)
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,44 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20250219-update_heartbeats_timeout"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
minTimeout := models.MinHeartbeatsTimeout.Seconds()
maxTimeout := models.MaxHeartbeatsTimeout.Seconds()
defaultTimeout := models.DefaultHeartbeatsTimeout.Seconds()
defaultTimeoutLegacy := models.DefaultHeartbeatsTimeoutLegacy.Seconds()
db.
Model(&models.User{}).
Where("heartbeats_timeout_sec < ?", minTimeout).
Update("heartbeats_timeout_sec", minTimeout)
db.
Model(&models.User{}).
Where("heartbeats_timeout_sec > ?", maxTimeout).
Update("heartbeats_timeout_sec", maxTimeout)
db.
Model(&models.User{}).
Where("heartbeats_timeout_sec = ?", defaultTimeoutLegacy).
Update("heartbeats_timeout_sec", defaultTimeout)
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,33 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20250313-fix_browsing_category"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
db.
Model(&models.Heartbeat{}).
Where("category = ?", "").
Where(db.
Where("type = ?", "domain").
Or("type = ?", "url")).
Update("category", "browsing")
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,66 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
// In the context if https://github.com/muety/wakapi/issues/777, we retroactively added a primary key column to the durations table.
// However, SQLite doesn't allow to alter an existing table that way. Workaround is to create a new one and copy its contents.
func init() {
const name = "20250425-add_durations_primary_key"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if cfg.Db.Dialect != config.SQLDialectSqlite {
return nil
}
if !db.Migrator().HasTable("durations") {
return nil
}
if db.Migrator().HasColumn(&models.Duration{}, "id") {
return nil
}
if err := db.Transaction(func(tx *gorm.DB) error {
if tx.Migrator().HasColumn(&models.Duration{}, "interval") { // legacy stuff
if err := tx.Migrator().DropColumn(&models.Duration{}, "interval"); err != nil {
return err
}
}
if err := tx.Migrator().RenameTable("durations", "durations_old"); err != nil {
return err
}
if tx.Migrator().HasIndex(&models.Duration{}, "idx_time_duration_user") {
if err := tx.Migrator().DropIndex(&models.Duration{}, "idx_time_duration_user"); err != nil {
return err
}
}
if err := tx.Migrator().CreateTable(&models.Duration{}); err != nil {
return err
}
if err := tx.Exec("insert into durations(user_id, time, duration, project, language, editor, operating_system, machine, category, branch, entity, num_heartbeats, group_hash, timeout) select * from durations_old").Error; err != nil {
return err
}
if err := tx.Migrator().DropTable("durations_old"); err != nil {
return err
}
return nil
}); err != nil {
return err
}
setHasRun(name, db)
return nil
},
}
registerPreMigration(f)
}

View File

@@ -0,0 +1,30 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
// see https://github.com/muety/wakapi/issues/817#issuecomment-3146365708
func init() {
const name = "20250802_fix_default_coding_category"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if err := db.Exec("update heartbeats set category = 'coding' where category = '' and type = 'file' and language != ''").Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20250802-fix_wsl_os"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if err := db.Exec("update heartbeats set operating_system = 'WSL' where user_agent like '%-WSL2-%'").Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,51 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
)
func init() {
const name = "20250907-add_user_heartbeats_range_view"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
const q = "select u.id as user_id, min(h.time) as first, max(h.time) as last " +
"from users u left join heartbeats h on u.id = h.user_id " +
"group by u.id"
if err := db.Transaction(func(tx *gorm.DB) error {
// https://stackoverflow.com/a/1236008/3112139
if cfg.Db.IsSQLite() {
if err := tx.Migrator().DropView("user_heartbeats_range"); err != nil {
return err
}
}
if err := tx.Migrator().CreateView("user_heartbeats_range", gorm.ViewOption{
Query: db.Raw(q),
Replace: !cfg.Db.IsSQLite(),
}); err != nil {
return err
}
if err := tx.Exec("delete from key_string_values where "+utils.QuoteSql(db, "%s like ?", "key"), "first_heartbeat_%").Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,35 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20251005-drop_duplicate_emails"
f := migrationFunc{
name: name,
background: false,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
// til: https://chat.mistral.ai/chat/86e338b7-dad7-4478-8950-11efbf94aa4d
const q = "update users " +
"set email = null " +
"where users.id in " +
"(select id from (select id, row_number() over (partition by email order by id) as row_num from users where email is not null) as t1 " +
"where row_num > 1);"
if err := db.Exec(q).Error; err != nil {
return err
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -1,19 +1,21 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"log/slog"
"sort"
"strings"
)
type gormMigrationFunc func(db *gorm.DB) error
type GormMigrationFunc func(db *gorm.DB) error
type migrationFunc struct {
f func(db *gorm.DB, cfg *config.Config) error
name string
// be careful with background migrations, because they must not lock or have side effects on each other, and they must be safe to fail silently
background bool
}
type migrationFuncs []migrationFunc
@@ -23,7 +25,7 @@ var (
postMigrations migrationFuncs
)
func GetMigrationFunc(cfg *config.Config) gormMigrationFunc {
func GetMigrationFunc(cfg *config.Config) GormMigrationFunc {
switch cfg.Db.Dialect {
default:
return func(db *gorm.DB) error {
@@ -57,6 +59,9 @@ func GetMigrationFunc(cfg *config.Config) gormMigrationFunc {
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Duration{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
@@ -78,7 +83,7 @@ func Run(db *gorm.DB, cfg *config.Config) {
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
if err := GetMigrationFunc(cfg)(db); err != nil {
logbuch.Fatal(err.Error())
config.Log().Fatal("migration failed", "error", err)
}
}
@@ -86,9 +91,9 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(preMigrations)
for _, m := range preMigrations {
logbuch.Info("potentially running migration '%s'", m.name)
slog.Info("potentially running migration", "name", m.name)
if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
config.Log().Fatal("migration failed", "name", m.name, "error", err)
}
}
}
@@ -97,9 +102,18 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
sort.Sort(postMigrations)
for _, m := range postMigrations {
logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
slog.Info("potentially running migration", "name", m.name)
run := func(m migrationFunc) {
if err := m.f(db, cfg); err != nil {
config.Log().Fatal("migration failed", "name", m.name, "error", err)
}
}
if m.background {
go run(m)
} else {
run(m)
}
}
}

View File

@@ -1,10 +1,10 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
"log/slog"
)
func hasRun(name string, db *gorm.DB) bool {
@@ -12,7 +12,7 @@ func hasRun(name string, db *gorm.DB) bool {
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
slog.Info("no need to migrate", "name", name)
return true
}
return false
@@ -23,6 +23,6 @@ func setHasRun(name string, db *gorm.DB) {
Key: name,
Value: "done",
}).Error; err != nil {
logbuch.Error("failed to mark migration %s as run - %v", name, err)
slog.Error("failed to mark migration as run", "name", name, "error", err)
}
}

View File

@@ -6,6 +6,7 @@ import (
)
type AliasRepositoryMock struct {
BaseRepositoryMock
mock.Mock
}

27
mocks/base_repository.go Normal file
View File

@@ -0,0 +1,27 @@
package mocks
import (
"github.com/stretchr/testify/mock"
)
type BaseRepositoryMock struct {
mock.Mock
}
func (m *BaseRepositoryMock) GetDialector() string {
args := m.Called()
return args.Get(0).(string)
}
func (m *BaseRepositoryMock) GetTableDDLMysql(s string) (string, error) {
args := m.Called(s)
return args.Get(0).(string), args.Error(1)
}
func (m *BaseRepositoryMock) GetTableDDLSqlite(s string) (string, error) {
args := m.Called(s)
return args.Get(0).(string), args.Error(1)
}
func (m *BaseRepositoryMock) VacuumOrOptimize() {
}

View File

@@ -0,0 +1,68 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type DurationRepositoryMock struct {
BaseRepositoryMock
mock.Mock
}
func (m *DurationRepositoryMock) InsertBatch(d []*models.Duration) error {
args := m.Called(d)
return args.Error(0)
}
func (m *DurationRepositoryMock) GetAll() ([]*models.Duration, error) {
args := m.Called()
return args.Get(0).([]*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) GetAllWithin(t time.Time, t2 time.Time, u *models.User) ([]*models.Duration, error) {
args := m.Called(t, t2, u)
return args.Get(0).([]*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) GetAllWithinByFilters(t time.Time, t2 time.Time, u *models.User, m2 map[string][]string) ([]*models.Duration, error) {
args := m.Called(t, t2, u, m2)
return args.Get(0).([]*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) StreamAllBatched(i int) (chan []*models.Duration, error) {
args := m.Called(i)
return args.Get(0).(chan []*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) StreamByUserBatched(u *models.User, i int) (chan []*models.Duration, error) {
args := m.Called(u, i)
return args.Get(0).(chan []*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) GetLatestByUser(u *models.User) (*models.Duration, error) {
args := m.Called(u)
return args.Get(0).(*models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) StreamAllWithin(t time.Time, t2 time.Time, u *models.User) (chan *models.Duration, error) {
args := m.Called(t, t2, u)
return args.Get(0).(chan *models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) StreamAllWithinByFilters(t time.Time, t2 time.Time, u *models.User, m2 map[string][]string) (chan *models.Duration, error) {
args := m.Called(t, t2, u, m2)
return args.Get(0).(chan *models.Duration), args.Error(1)
}
func (m *DurationRepositoryMock) DeleteByUser(u *models.User) error {
args := m.Called(u)
return args.Error(0)
}
func (m *DurationRepositoryMock) DeleteByUserBefore(u *models.User, t time.Time) error {
args := m.Called(u, t)
return args.Error(0)
}

View File

@@ -10,7 +10,19 @@ 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)
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User, f *models.Filters, d *time.Duration, b bool) (models.Durations, error) {
args := m.Called(time, time2, user, f, d, b)
return args.Get(0).(models.Durations), args.Error(1)
}
func (m *DurationServiceMock) Regenerate(u *models.User, b bool) {
m.Called(u, b)
}
func (m *DurationServiceMock) RegenerateAll() {
}
func (m *DurationServiceMock) DeleteByUser(u *models.User) error {
args := m.Called(u)
return args.Error(0)
}

View File

@@ -1,10 +1,11 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"github.com/stretchr/testify/mock"
"time"
)
type HeartbeatServiceMock struct {
@@ -41,16 +42,46 @@ func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, use
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) StreamAllWithin(t time.Time, t2 time.Time, u *models.User) (chan *models.Heartbeat, error) {
args := m.Called(t, t2, u)
return args.Get(0).(chan *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) StreamAllWithinByFilters(t time.Time, t2 time.Time, user *models.User, filters *models.Filters) (chan *models.Heartbeat, error) {
args := m.Called(t, t2, user, filters)
return args.Get(0).(chan *models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstAll() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) GetLastAll() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUser(u *models.User) (time.Time, error) {
args := m.Called(u)
return args.Get(0).(time.Time), args.Error(1)
}
func (m *HeartbeatServiceMock) GetLastByUser(u *models.User) (time.Time, error) {
args := m.Called(u)
return args.Get(0).(time.Time), args.Error(1)
}
func (m *HeartbeatServiceMock) GetRangeByUser(u *models.User) (*models.RangeByUser, error) {
args := m.Called(u)
return args.Get(0).(*models.RangeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
args := m.Called(user)
return args.Get(0).(*models.Heartbeat), args.Error(1)
@@ -90,3 +121,8 @@ func (m *HeartbeatServiceMock) GetUserProjectStats(u *models.User, t, t2 time.Ti
args := m.Called(u, t, t2, p, b)
return args.Get(0).([]*models.ProjectStats), args.Error(1)
}
func (m *HeartbeatServiceMock) GetUserAgentsByUser(u *models.User) ([]*models.UserAgent, error) {
args := m.Called(u)
return args.Get(0).([]*models.UserAgent), args.Error(0)
}

View File

@@ -33,3 +33,8 @@ func (m *KeyValueServiceMock) DeleteString(s string) error {
args := m.Called(s)
return args.Error(0)
}
func (m *KeyValueServiceMock) ReplaceKeySuffix(s1, s2 string) error {
args := m.Called(s1, s2)
return args.Error(0)
}

View File

@@ -0,0 +1,35 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type LanguageMappingServiceMock struct {
mock.Mock
}
func (l *LanguageMappingServiceMock) GetById(u uint) (*models.LanguageMapping, error) {
args := l.Called(u)
return args.Get(0).(*models.LanguageMapping), args.Error(1)
}
func (l *LanguageMappingServiceMock) GetByUser(s string) ([]*models.LanguageMapping, error) {
args := l.Called(s)
return args.Get(0).([]*models.LanguageMapping), args.Error(1)
}
func (l *LanguageMappingServiceMock) ResolveByUser(s string) (map[string]string, error) {
args := l.Called(s)
return args.Get(0).(map[string]string), args.Error(1)
}
func (l *LanguageMappingServiceMock) Create(m *models.LanguageMapping) (*models.LanguageMapping, error) {
args := l.Called(m)
return args.Get(0).(*models.LanguageMapping), args.Error(1)
}
func (l *LanguageMappingServiceMock) Delete(m *models.LanguageMapping) error {
args := l.Called(m)
return args.Error(0)
}

View File

@@ -7,6 +7,7 @@ import (
)
type SummaryRepositoryMock struct {
BaseRepositoryMock
mock.Mock
}

View File

@@ -11,18 +11,18 @@ type SummaryServiceMock struct {
mock.Mock
}
func (m *SummaryServiceMock) Aliased(t time.Time, t2 time.Time, u *models.User, r types.SummaryRetriever, f *models.Filters, b bool) (*models.Summary, error) {
args := m.Called(t, t2, u, r, f)
func (m *SummaryServiceMock) Aliased(t time.Time, t2 time.Time, u *models.User, r types.SummaryRetriever, f *models.Filters, d *time.Duration, b bool) (*models.Summary, error) {
args := m.Called(t, t2, u, r, f, d, b)
return args.Get(0).(*models.Summary), args.Error(1)
}
func (m *SummaryServiceMock) Retrieve(t time.Time, t2 time.Time, u *models.User, f *models.Filters) (*models.Summary, error) {
args := m.Called(t, t2, u, f)
func (m *SummaryServiceMock) Retrieve(t time.Time, t2 time.Time, u *models.User, f *models.Filters, d *time.Duration) (*models.Summary, error) {
args := m.Called(t, t2, u, d, f)
return args.Get(0).(*models.Summary), args.Error(1)
}
func (m *SummaryServiceMock) Summarize(t time.Time, t2 time.Time, u *models.User, f *models.Filters) (*models.Summary, error) {
args := m.Called(t, t2, u, f)
func (m *SummaryServiceMock) Summarize(t time.Time, t2 time.Time, u *models.User, f *models.Filters, d *time.Duration) (*models.Summary, error) {
args := m.Called(t, t2, u, d, f)
return args.Get(0).(*models.Summary), args.Error(1)
}

View File

@@ -11,6 +11,9 @@ type UserServiceMock struct {
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
args := m.Called(s)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
@@ -29,6 +32,14 @@ func (m *UserServiceMock) GetUserByResetToken(s string) (*models.User, error) {
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByOidc(s1, s2 string) (*models.User, error) {
args := m.Called(s1, s2)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
@@ -74,6 +85,11 @@ func (m *UserServiceMock) Count() (int64, error) {
return int64(args.Int(0)), args.Error(1)
}
func (m *UserServiceMock) CountCurrentlyOnline() (int, error) {
args := m.Called()
return args.Int(0), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
args := m.Called(signup, isAdmin)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
@@ -109,6 +125,11 @@ func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, e
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ChangeUserId(user *models.User, s1 string) (*models.User, error) {
args := m.Called(user, s1)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

@@ -1,5 +1,7 @@
package models
import "strings"
// 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
@@ -16,7 +18,10 @@ type Alias struct {
}
func (a *Alias) IsValid() bool {
return a.Key != "" && a.Value != "" && a.validateType()
return a.Key != "" &&
a.Value != "" &&
a.validateType() &&
a.validateWildcard()
}
func (a *Alias) validateType() bool {
@@ -27,3 +32,13 @@ func (a *Alias) validateType() bool {
}
return false
}
func (a *Alias) validateWildcard() bool {
if !strings.Contains(a.Value, "*") && !strings.Contains(a.Value, "?") {
return true
}
v := a.Value
v = strings.ReplaceAll(v, "*", "")
v = strings.ReplaceAll(v, "?", "")
return len(v) >= 3 // don't allow "*" or "a*" or sth.
}

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