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

Compare commits

...

105 Commits
2.5.1 ... 2.6.1

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

TODO: The cookie still needs to be set on the client
with a `Set-Cookie` response header.
2022-11-05 19:30:42 +01:00
ba81c07345 Display persistent summary interval into the front-end time picker 2022-11-05 19:23:39 +01:00
9586dbf781 fix: make intervals robust to daylight saving time shift 2022-10-31 23:24:54 +01:00
c8ea1a503f Merge pull request #424 from f0x52/postgres-dsn
Add postgres DSN config option
2022-10-31 19:22:03 +01:00
f0x
ebbc21f0b1 add postgres DSN config option 2022-10-31 18:07:16 +01:00
6e5bc38e5e fix: index migration for sqlite 2022-10-28 10:32:47 +02:00
9424c49760 fix: composite index on heartbeats table 2022-10-28 09:54:11 +02:00
efd6ba36e3 fix: errors during leaderboard generation 2022-10-20 08:33:12 +02:00
b1d7f87095 chore: add maximum default leaderboard length 2022-10-19 18:28:30 +02:00
ffbcfc7467 fix: cache key 2022-10-19 17:23:40 +02:00
41f6db8f34 feat(wip): leaderboard pagination (resolve #417) [ci-skip] 2022-10-16 19:38:43 +02:00
8a21be4306 fix: ignore rank column in migrations 2022-10-16 18:59:00 +02:00
31ca4a1e02 chore: logging 2022-10-16 17:42:32 +02:00
7cab2b0be7 chore: add clarification on relaying to other wakapi instance (resolve #420) [skip-ci] 2022-10-15 11:08:44 +02:00
777997c883 fix: swagger ui (resolve #421) 2022-10-14 12:00:56 +02:00
132 changed files with 4889 additions and 2180 deletions

View File

@ -12,7 +12,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
@ -22,7 +22,7 @@ jobs:
run: go get
- name: Unit Tests
run: go test ./... -run ./...
run: CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -run ./... # skip scripts package, because not actually a package
- name: API Tests
run: |
@ -39,7 +39,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
go-version: ^1.19
- name: Check out code into the Go module directory
uses: actions/checkout@v2
@ -90,7 +90,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
@ -101,3 +101,24 @@ jobs:
- name: Build
run: go build -v .
migration:
name: Migration tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres, mysql, mariadb]
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- run: ./testing/run_api_tests.sh ${{ matrix.db }} --migration

View File

@ -34,7 +34,7 @@ jobs:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
go-version: ^1.19
id: go
- name: Check out code into the Go module directory

5
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
FROM golang:1.18-alpine AS build-env
FROM golang:1.19-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 && \

View File

@ -36,7 +36,7 @@ Installation instructions can be found below and in the [Wiki](https://github.co
## 🚀 Features
*100 % free and open-source
*Free and open-source
* ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges
@ -109,7 +109,7 @@ $ ./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
@ -137,17 +137,22 @@ You can specify configuration options either via a config file (default: `config
| YAML key / Env. variable | Default | Description |
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
| `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.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.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_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
| `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.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) |
| `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 (leave blank to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank 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) |
@ -160,6 +165,7 @@ You can specify configuration options either via a config file (default: `config
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `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 |
@ -302,12 +308,13 @@ However, if you want to expose your wakapi instance to the public anyway, you ne
### 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).
#### How to run
```bash
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./...
$ CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -json -coverprofile=coverage/coverage.out ./... -run ./...
```
### API tests

View File

@ -1,9 +1,12 @@
env: production
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
listen_socket: # leave blank to disable unix sockets
listen_socket_mode: 0666 # permission mode to create unix socket with
timeout_sec: 30 # request timeout
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
@ -12,15 +15,20 @@ server:
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
leaderboard_generation_time: '06:00;18:00' # time at which to run daily aggregation batch jobs
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
leaderboard_generation_time: '0 0 6 * * *,0 0 18 * * *' # times at which to re-calculate the leaderboard
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)
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
data_retention_months: -1 # maximum retention period on months for user data (heartbeats) (-1 for infinity)
custom_languages:
vue: Vue
jsx: JSX
tsx: TSX
cjs: JavaScript
ipynb: Python
svelte: Svelte
# url template for user avatar images (to be used with services like gravatar or dicebear)
@ -31,6 +39,7 @@ app:
db:
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
socket: # alternative to db.host (leave blank when using sqlite3)
user: # leave blank when using sqlite3
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)
@ -38,7 +47,7 @@ db:
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)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security:
password_salt: # change this
@ -54,6 +63,15 @@ sentry:
sample_rate: 0.75 # probability of tracing a request
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments
subscriptions:
enabled: false
expiry_notifications: true
stripe_api_key:
stripe_secret_key:
stripe_endpoint_secret:
standard_price_id:
mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
@ -71,7 +89,4 @@ mail:
mailwhale:
url:
client_id:
client_secret:
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
client_secret:

View File

@ -4,10 +4,11 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/robfig/cron/v3"
"io/ioutil"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -15,9 +16,8 @@ import (
"github.com/gorilla/securecookie"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/data"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
uuid "github.com/satori/go.uuid"
"gorm.io/gorm"
)
const (
@ -27,10 +27,14 @@ const (
SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3"
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
KeyNewsbox = "newsbox"
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
KeyFirstHeartbeat = "first_heartbeat"
KeySubscriptionNotificationSent = "sub_reminder"
KeyNewsbox = "newsbox"
SessionKeyDefault = "default"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
@ -65,15 +69,19 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"06:00;18:00" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"0 0 6 * * *,0 0 18 * * *" env:"WAKAPI_LEADERBOARD_GENERATION_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"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
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"`
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
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
SupportContact string `yaml:"support_contact" default:"hostmaster@wakapi.dev" env:"WAKAPI_SUPPORT_CONTACT"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
@ -87,10 +95,12 @@ type securityConfig struct {
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
SessionKey []byte `yaml:"-"`
}
type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"`
Socket string `env:"WAKAPI_DB_SOCKET"`
Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
@ -98,21 +108,33 @@ type dbConfig struct {
Dialect string `yaml:"-"`
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"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
ListenSocketMode uint32 `yaml:"listen_socket_mode" default:"0666" env:"WAKAPI_LISTEN_SOCKET_MODE"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type subscriptionsConfig struct {
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
ExpiryNotifications bool `yaml:"expiry_notifications" default:"true" env:"WAKAPI_SUBSCRIPTIONS_EXPIRY_NOTIFICATIONS"`
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"`
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
StandardPrice string `yaml:"-"`
}
type sentryConfig struct {
@ -154,6 +176,7 @@ type Config struct {
Security securityConfig
Db dbConfig
Server serverConfig
Subscriptions subscriptionsConfig
Sentry sentryConfig
Mail mailConfig
}
@ -174,7 +197,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
SameSite: http.SameSiteLaxMode,
}
}
@ -186,68 +209,97 @@ func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
}
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect {
default:
return func(db *gorm.DB) error {
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
}
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false)
return utils.CloneStringMap(c.CustomLanguages, false)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.Colors["languages"], true)
return utils.CloneStringMap(c.Colors["languages"], true)
}
func (c *appConfig) GetEditorColors() map[string]string {
return cloneStringMap(c.Colors["editors"], true)
return utils.CloneStringMap(c.Colors["editors"], true)
}
func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true)
return utils.CloneStringMap(c.Colors["operating_systems"], true)
}
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
s := strings.Split(c.ReportTimeWeekly, ",")[0]
return parseWeekday(s)
func (c *appConfig) GetAggregationTimeCron() string {
if strings.Contains(c.AggregationTime, ":") {
// old gocron format, e.g. "15:04"
timeParts := strings.Split(c.AggregationTime, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
return utils.CronPadToSecondly(c.AggregationTime)
}
func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
func (c *appConfig) GetWeeklyReportCron() string {
if strings.Contains(c.ReportTimeWeekly, ",") {
// old gocron format, e.g. "fri,18:00"
split := strings.Split(c.ReportTimeWeekly, ",")
weekday := utils.ParseWeekday(split[0])
timeParts := strings.Split(split[1], ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
}
return utils.CronPadToSecondly(c.ReportTimeWeekly)
}
func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
crons := []string{}
var parse func(string) string
if strings.Contains(c.LeaderboardGenerationTime, ":") {
// old gocron format, e.g. "15:04"
parse = func(s string) string {
timeParts := strings.Split(s, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
} else {
parse = func(s string) string {
return utils.CronPadToSecondly(s)
}
}
for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") {
crons = append(crons, parse(strings.TrimSpace(s)))
}
return crons
}
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
@ -302,13 +354,6 @@ func readColors() map[string]map[string]string {
return colors
}
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
}
return *cFlag
}
func resolveDbDialect(dbType string) string {
if dbType == "cockroach" {
return "postgres"
@ -322,35 +367,6 @@ func resolveDbDialect(dbType string) string {
return dbType
}
func findString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}
func parseWeekday(s string) time.Weekday {
switch strings.ToLower(s) {
case "mon", strings.ToLower(time.Monday.String()):
return time.Monday
case "tue", strings.ToLower(time.Tuesday.String()):
return time.Tuesday
case "wed", strings.ToLower(time.Wednesday.String()):
return time.Wednesday
case "thu", strings.ToLower(time.Thursday.String()):
return time.Thursday
case "fri", strings.ToLower(time.Friday.String()):
return time.Friday
case "sat", strings.ToLower(time.Saturday.String()):
return time.Saturday
case "sun", strings.ToLower(time.Sunday.String()):
return time.Sunday
}
return time.Monday
}
func Set(config *Config) {
cfg = config
}
@ -364,7 +380,7 @@ func Load(version string) *Config {
flag.Parse()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
logbuch.Fatal("failed to read config: %v", err)
}
@ -383,6 +399,7 @@ func Load(version string) *Config {
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
config.Security.SessionKey = securecookie.GenerateRandomKey(32)
if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
@ -399,8 +416,18 @@ func Load(version string) *Config {
initSentry(config.Sentry, config.IsDev())
}
if config.App.DataRetentionMonths <= 0 {
logbuch.Info("disabling data retention policy, keeping data forever")
} else {
dataRetentionWarning := fmt.Sprintf("⚠️ data retention policy will cause user data older than %d months to be deleted", config.App.DataRetentionMonths)
if config.Subscriptions.Enabled {
dataRetentionWarning += " (except for users with active subscriptions)"
}
logbuch.Warn(dataRetentionWarning)
}
// some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
if config.Server.ListenIpV4 == "-" && config.Server.ListenIpV6 == "-" && config.Server.ListenSocket == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
}
if config.Db.MaxConn <= 0 {
@ -410,19 +437,38 @@ func Load(version string) *Config {
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
config.Db.MaxConn = 1
}
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
}
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
logbuch.Fatal("invalid interval set for report_time_weekly")
}
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
logbuch.Fatal("invalid duration set for heartbeat_max_age")
}
cronParser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
if _, err := cronParser.Parse(config.App.GetWeeklyReportCron()); err != nil {
logbuch.Fatal("invalid cron expression for report_time_weekly")
}
if _, err := cronParser.Parse(config.App.GetAggregationTimeCron()); err != nil {
logbuch.Fatal("invalid cron expression for aggregation_time")
}
for _, c := range config.App.GetLeaderboardGenerationTimeCron() {
if _, err := cronParser.Parse(c); err != nil {
logbuch.Fatal("invalid cron expression for leaderboard_generation_time")
}
}
// deprecation notices
if strings.Contains(config.App.AggregationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'aggregation_time', please change it to a valid cron expression")
}
if strings.Contains(config.App.ReportTimeWeekly, ":") {
logbuch.Warn("you're using deprecated syntax for 'report_time_weekly', please change it to a valid cron expression")
}
if strings.Contains(config.App.LeaderboardGenerationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'leaderboard_generation_time', please change it to a semicolon-separated list if valid cron expressions")
}
Set(config)
return Get()
}

View File

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

View File

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

35
config/db_opts.go Normal file
View File

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

85
config/jobqueue.go Normal file
View File

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

View File

@ -3,7 +3,6 @@ package config
import (
"github.com/emvi/logbuch"
"github.com/getsentry/sentry-go"
"github.com/muety/wakapi/models"
"io"
"net/http"
"os"
@ -89,8 +88,8 @@ func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
hub := h.(*sentry.Hub)
hub.Scope().SetRequest(l.req)
if u := getPrincipal(l.req); u != nil {
hub.Scope().SetUser(sentry.User{ID: u.ID})
if uid := getPrincipal(l.req); uid != "" {
hub.Scope().SetUser(sentry.User{ID: uid})
}
hub.CaptureEvent(event)
return
@ -133,8 +132,8 @@ func initSentry(config sentryConfig, debug bool) {
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 u := getPrincipal(req); u != nil {
event.User.ID = u.ID
if uid := getPrincipal(req); uid != "" {
event.User.ID = uid
}
}
}
@ -145,12 +144,14 @@ func initSentry(config sentryConfig, debug bool) {
}
}
func getPrincipal(r *http.Request) *models.User {
type principalGetter interface {
GetPrincipal() *models.User
// returns a user id
func getPrincipal(r *http.Request) string {
type principalIdentityGetter interface {
GetPrincipalIdentity() string
}
if p := r.Context().Value("principal"); p != nil {
return p.(principalGetter).GetPrincipal()
return p.(principalIdentityGetter).GetPrincipalIdentity()
}
return nil
return ""
}

14
config/session.go Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

75
go.mod
View File

@ -1,51 +1,55 @@
module github.com/muety/wakapi
go 1.18
go 1.19
require (
codeberg.org/Codeberg/avatars v1.0.0
github.com/duke-git/lancet/v2 v2.1.6
github.com/duke-git/lancet/v2 v2.1.13
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-smtp v0.15.0
github.com/emersion/go-smtp v0.16.0
github.com/emvi/logbuch v1.2.0
github.com/getsentry/sentry-go v0.14.0
github.com/glebarez/sqlite v1.5.0
github.com/go-co-op/gocron v1.17.0
github.com/getsentry/sentry-go v0.15.0
github.com/glebarez/sqlite v1.6.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/hashicorp/golang-lru v0.5.4
github.com/jinzhu/configor v1.2.1
github.com/leandro-lugaresi/hub v1.1.1
github.com/lpar/gzipped/v2 v2.1.0
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
github.com/narqo/go-badge v0.0.0-20221212191103-ba83bed45a1a
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.0
github.com/stretchr/testify v1.8.1
github.com/stripe/stripe-go/v74 v74.5.0
github.com/swaggo/http-swagger v1.3.3
github.com/swaggo/swag v1.8.6
github.com/swaggo/swag v1.8.9
go.uber.org/atomic v1.10.0
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
gorm.io/driver/mysql v1.4.1
gorm.io/driver/postgres v1.4.4
gorm.io/driver/sqlite v1.4.2
gorm.io/gorm v1.24.0
golang.org/x/crypto v0.5.0
golang.org/x/sync v0.1.0
gorm.io/driver/mysql v1.4.5
gorm.io/driver/postgres v1.4.6
gorm.io/driver/sqlite v1.4.4
gorm.io/gorm v1.24.3
)
require (
github.com/BurntSushi/toml v1.2.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/glebarez/go-sqlite v1.19.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.7 // indirect
github.com/glebarez/go-sqlite v1.20.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.8 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
@ -53,30 +57,31 @@ require (
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.13.0 // indirect
github.com/jackc/pgx/v4 v4.17.2 // indirect
github.com/jackc/pgx/v5 v5.2.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.12 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/swaggo/files v1.0.0 // indirect
golang.org/x/exp v0.0.0-20230116083435-1de6713980de // indirect
golang.org/x/image v0.3.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/tools v0.5.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.20.3 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.19.1 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.20.2 // indirect
)

230
go.sum
View File

@ -1,12 +1,15 @@
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@ -16,58 +19,71 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/duke-git/lancet/v2 v2.1.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/duke-git/lancet/v2 v2.1.13 h1:KOCCVrfh4pjuwl6td5MQ4OqvV73qFdoGxv20HWmyPaM=
github.com/duke-git/lancet/v2 v2.1.13/go.mod h1:hNcc06mV7qr+crH/0nP+rlC3TB0Q9g5OrVnO8/TGD4c=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
github.com/getsentry/sentry-go v0.14.0 h1:rlOBkuFZRKKdUnKO+0U3JclRDQKlRu5vVQtkWSQvC70=
github.com/getsentry/sentry-go v0.14.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
github.com/getsentry/sentry-go v0.17.0 h1:UustVWnOoDFHBS7IJUB2QK/nB5pap748ZEp0swnQJak=
github.com/getsentry/sentry-go v0.17.0/go.mod h1:B82dxtBvxG0KaPD8/hfSV+VcHD+Lg/xUS4JuQn1P4cM=
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
github.com/glebarez/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
github.com/glebarez/go-sqlite v1.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8=
github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak=
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/glebarez/sqlite v1.6.0 h1:ZpvDLv4zBi2cuuQPitRiVz/5Uh6sXa5d8eBu0xNTpAo=
github.com/glebarez/sqlite v1.6.0/go.mod h1:6D6zPU/HTrFlYmVDKqBJlmQvma90P6r7sRRdkUUZOYk=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU=
github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -79,8 +95,11 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@ -114,22 +133,29 @@ github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.13.0 h1:XkIc7A+1BmZD19bB2NxrtjJweHxQ9agqvM+9URc68Cg=
github.com/jackc/pgtype v1.13.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -147,6 +173,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@ -173,20 +202,25 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/narqo/go-badge v0.0.0-20221212191103-ba83bed45a1a h1:G6Kjw+HNpJUZY1bfBkd8XOZ7nuDWmXLaJukeiM2Xv7o=
github.com/narqo/go-badge v0.0.0-20221212191103-ba83bed45a1a/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
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/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@ -195,6 +229,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qq
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.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
@ -210,6 +245,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -219,13 +256,24 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.3.0 h1:8ymGwZvMnpWMCRNomc9dVGcJ5j8L/ubwhQvpIpcmcOA=
github.com/stripe/stripe-go/v74 v74.3.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
github.com/swaggo/swag v1.8.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/swaggo/swag v1.8.9 h1:kHtaBe/Ob9AZzAANfcn5c6RyCke9gG9QpH0jky0I/sA=
github.com/swaggo/swag v1.8.9/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@ -249,34 +297,47 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/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-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -287,25 +348,38 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -316,19 +390,22 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -338,73 +415,72 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic=
gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
gorm.io/driver/postgres v1.4.4 h1:zt1fxJ+C+ajparn0SteEnkoPg0BQ6wOWXEQ99bteAmw=
gorm.io/driver/postgres v1.4.4/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
gorm.io/driver/sqlite v1.4.2 h1:F6vYJcmR4Cnh0ErLyoY8JSfabBGyR0epIGuhgHJuNws=
gorm.io/driver/sqlite v1.4.2/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
gorm.io/driver/mysql v1.4.5 h1:u1lytId4+o9dDaNcPCFzNv7h6wvmc92UjNk3z8enSBU=
gorm.io/driver/mysql v1.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.20.3 h1:BodaDPuUse7taQchAClMmbE/yZp3T2ZBiwCDFyBLEXw=
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.2 h1:9AaVzJH1Yf0u9iOZRjjuvqxLoGqybqVFbAUC5rvi9u8=
modernc.org/sqlite v1.20.2/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

View File

@ -1,9 +1,8 @@
package utils
package helpers
import (
"errors"
"fmt"
"github.com/muety/wakapi/config"
"regexp"
"time"
)
@ -41,22 +40,10 @@ func FormatDateHuman(date time.Time) string {
return date.Format("Mon, 02 Jan 2006")
}
func Add(i, j int) int {
return i + j
}
func ParseUserAgent(ua string) (string, string, error) {
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
groups := re.FindAllStringSubmatch(ua, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string")
}
return groups[0][1], groups[0][2], nil
}
func SubSlice[T any](slice []T, from, to uint) []T {
if int(to) > len(slice) {
to = uint(len(slice))
}
return slice[from:int(to)]
func FmtWakatimeDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
return fmt.Sprintf("%d hrs %d mins", h, m)
}

4
helpers/helpers.go Normal file
View File

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

30
helpers/http.go Normal file
View File

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

View File

@ -1,78 +1,13 @@
package utils
package helpers
import (
"errors"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
func ParseInterval(interval string) (*models.IntervalKey, error) {
for _, i := range models.AllIntervals {
if i.HasAlias(interval) {
return i, nil
}
}
return nil, errors.New("not a valid interval")
}
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz)
return from, to
}
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
return err, time.Time{}, time.Time{}
}
return ResolveIntervalTZ(parsed, tz)
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
now := time.Now().In(tz)
to = now
switch interval {
case models.IntervalToday:
from = BeginOfToday(tz)
case models.IntervalYesterday:
from = BeginOfToday(tz).Add(-24 * time.Hour)
to = BeginOfToday(tz)
case models.IntervalThisWeek:
from = BeginOfThisWeek(tz)
case models.IntervalLastWeek:
from = BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = BeginOfThisWeek(tz)
case models.IntervalThisMonth:
from = BeginOfThisMonth(tz)
case models.IntervalLastMonth:
from = BeginOfThisMonth(tz).AddDate(0, -1, 0)
to = BeginOfThisMonth(tz)
case models.IntervalThisYear:
from = BeginOfThisYear(tz)
case models.IntervalPast7Days:
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = BeginOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:
from = now.AddDate(0, 0, -30)
case models.IntervalPast6Months:
from = now.AddDate(0, -6, 0)
case models.IntervalPast12Months:
from = now.AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:
err = errors.New("invalid interval")
}
return err, from, to
}
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := extractUser(r)
params := r.URL.Query()
@ -144,3 +79,69 @@ func extractUser(r *http.Request) *models.User {
}
return nil
}
func ParseInterval(interval string) (*models.IntervalKey, error) {
for _, i := range models.AllIntervals {
if i.HasAlias(interval) {
return i, nil
}
}
return nil, errors.New("not a valid interval")
}
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz)
return from, to
}
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
return err, time.Time{}, time.Time{}
}
return ResolveIntervalTZ(parsed, tz)
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
now := time.Now().In(tz)
to = now
switch interval {
case models.IntervalToday:
from = utils.BeginOfToday(tz)
case models.IntervalYesterday:
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
to = utils.BeginOfToday(tz)
case models.IntervalThisWeek:
from = utils.BeginOfThisWeek(tz)
case models.IntervalLastWeek:
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = utils.BeginOfThisWeek(tz)
case models.IntervalThisMonth:
from = utils.BeginOfThisMonth(tz)
case models.IntervalLastMonth:
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
to = utils.BeginOfThisMonth(tz)
case models.IntervalThisYear:
from = utils.BeginOfThisYear(tz)
case models.IntervalPast7Days:
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:
from = now.AddDate(0, 0, -30)
case models.IntervalPast6Months:
from = now.AddDate(0, -6, 0)
case models.IntervalPast12Months:
from = now.AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:
err = errors.New("invalid interval")
}
return err, from, to
}

67
main.go
View File

@ -2,7 +2,7 @@ package main
import (
"embed"
"github.com/muety/wakapi/static/docs"
"github.com/lpar/gzipped/v2"
"io/fs"
"log"
"net"
@ -11,11 +11,12 @@ import (
"strconv"
"time"
"github.com/muety/wakapi/static/docs"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/lpar/gzipped/v2"
"github.com/swaggo/http-swagger"
httpSwagger "github.com/swaggo/http-swagger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@ -35,6 +36,8 @@ import (
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
_ "github.com/muety/wakapi/static/docs"
)
// Embed version.txt
@ -79,6 +82,7 @@ var (
keyValueService services.IKeyValueService
reportService services.IReportService
diagnosticsService services.IDiagnosticsService
housekeepingService services.IHousekeepingService
miscService services.IMiscService
)
@ -128,14 +132,12 @@ func main() {
// Connect to database
var err error
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
logbuch.Info("starting with %s database", 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")
}
if config.Db.IsSQLite() {
db.Exec("PRAGMA foreign_keys = ON;")
}
if config.IsDev() {
db = db.Debug()
@ -180,13 +182,15 @@ func main() {
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
// Schedule background tasks
go aggregationService.Schedule()
go leaderboardService.ScheduleDefault()
go miscService.ScheduleCountTotalTime()
go leaderboardService.Schedule()
go reportService.Schedule()
go housekeepingService.Schedule()
go miscService.Schedule()
routes.Init()
@ -210,8 +214,9 @@ func main() {
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
summaryHandler := routes.NewSummaryHandler(summaryService, userService, keyValueService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService)
@ -249,6 +254,7 @@ func main() {
summaryHandler.RegisterRoutes(rootRouter)
leaderboardHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
subscriptionHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter)
// API route registrations
@ -273,15 +279,16 @@ func main() {
embeddedStatic, _ := fs.Sub(staticFiles, "static")
static := conf.ChooseFS("static", embeddedStatic)
assetsFileServer := gzipped.FileServer(fsutils.NewExistsHttpFS(
fsutils.NewExistsFS(static).WithCache(!config.IsDev()),
))
staticFileServer := http.FileServer(http.FS(
fsutils.NeuteredFileSystem{FS: static},
))
assetsStaticFs := fsutils.NewExistsHttpFS(fsutils.NewExistsFS(static).WithCache(!config.IsDev()))
assetsFileServer := http.FileServer(assetsStaticFs)
if !config.IsDev() {
assetsFileServer = gzipped.FileServer(assetsStaticFs)
}
staticFileServer := http.FileServer(http.FS(fsutils.NeuteredFileSystem{FS: static}))
router.PathPrefix("/contribute.json").Handler(staticFileServer)
router.PathPrefix("/assets").Handler(assetsFileServer)
router.Path("/swagger-ui").Handler(http.RedirectHandler("swagger-ui/", http.StatusMovedPermanently)) // https://github.com/swaggo/http-swagger/issues/44
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
// Listen HTTP
@ -292,7 +299,7 @@ func listen(handler http.Handler) {
var s4, s6, sSocket *http.Server
// IPv4
if config.Server.ListenIpV4 != "" {
if config.Server.ListenIpV4 != "-" && config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{
Handler: handler,
@ -303,7 +310,7 @@ func listen(handler http.Handler) {
}
// IPv6
if config.Server.ListenIpV6 != "" {
if config.Server.ListenIpV6 != "-" && config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{
Handler: handler,
@ -314,10 +321,10 @@ func listen(handler http.Handler) {
}
// UNIX domain socket
if config.Server.ListenSocket != "" {
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)
logbuch.Info("👉 Removing unix socket %s", config.Server.ListenSocket)
if err := os.Remove(config.Server.ListenSocket); err != nil {
logbuch.Fatal(err.Error())
}
@ -331,7 +338,7 @@ func listen(handler http.Handler) {
if config.UseTLS() {
if s4 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
@ -339,7 +346,7 @@ func listen(handler http.Handler) {
}()
}
if s6 != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
@ -347,12 +354,15 @@ func listen(handler http.Handler) {
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.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)
}
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
@ -360,7 +370,7 @@ func listen(handler http.Handler) {
}
} else {
if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
@ -368,7 +378,7 @@ func listen(handler http.Handler) {
}()
}
if s6 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
@ -376,12 +386,15 @@ func listen(handler http.Handler) {
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.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)
}
if err := sSocket.Serve(unixListener); err != nil {
logbuch.Fatal(err.Error())
}

View File

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

View File

@ -160,17 +160,23 @@ func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
newData := make([]interface{}, 0, len(heartbeats))
for i, hb := range heartbeats {
hb = hb.Hashed()
process := func(heartbeat *models.Heartbeat, rawData interface{}) {
heartbeat = heartbeat.Hashed()
// we didn't see this particular heartbeat before
if _, found := m.hashCache.Get(hb.Hash); !found {
m.hashCache.SetDefault(hb.Hash, true)
newData = append(newData, rawData.([]interface{})[i])
continue
if _, found := m.hashCache.Get(heartbeat.Hash); !found {
m.hashCache.SetDefault(heartbeat.Hash, true)
newData = append(newData, rawData)
}
}
if _, isList := rawData.([]interface{}); isList {
for i, hb := range heartbeats {
process(hb, rawData.([]interface{})[i])
}
} else if len(heartbeats) > 0 {
process(heartbeats[0], rawData.(interface{}))
}
if len(newData) == 0 {
return errors.New("no new heartbeats to relay")
}

View File

@ -20,6 +20,13 @@ 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()),

View File

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

View File

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

View File

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

View File

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

View File

@ -10,13 +10,13 @@ type HeartbeatServiceMock struct {
mock.Mock
}
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
args := m.Called(heartbeat)
func (m *HeartbeatServiceMock) Insert(h *models.Heartbeat) error {
args := m.Called(h)
return args.Error(0)
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats)
func (m *HeartbeatServiceMock) InsertBatch(h []*models.Heartbeat) error {
args := m.Called(h)
return args.Error(0)
}
@ -74,3 +74,8 @@ func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
args := m.Called(u)
return args.Error(0)
}
func (m *HeartbeatServiceMock) DeleteByUserBefore(u *models.User, t time.Time) error {
args := m.Called(u, t)
return args.Error(0)
}

View File

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

View File

@ -54,6 +54,11 @@ func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByStripeCustomerId(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1)
@ -107,3 +112,7 @@ func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, e
func (m *UserServiceMock) FlushCache() {
m.Called()
}
func (m *UserServiceMock) FlushUserCache(s string) {
m.Called(s)
}

View File

@ -1,8 +1,8 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
)
// https://shields.io/endpoint
@ -23,7 +23,7 @@ func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
return &BadgeData{
SchemaVersion: 1,
Label: defaultLabel,
Message: utils.FmtWakatimeDuration(summary.TotalTime()),
Message: helpers.FmtWakatimeDuration(summary.TotalTime()),
Color: defaultColor,
}
}

View File

@ -1,8 +1,9 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://wakatime.com/developers#all_time_since_today
@ -28,11 +29,19 @@ type AllTimeRange struct {
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
total := summary.TotalTime()
tzName, _ := summary.FromTime.T().Zone()
return &AllTimeViewModel{
Data: &AllTimeData{
TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total),
Text: helpers.FmtWakatimeDuration(total),
IsUpToDate: true,
Range: &AllTimeRange{
End: summary.ToTime.T().Format(time.RFC3339),
EndDate: helpers.FormatDate(summary.ToTime.T()),
Start: summary.FromTime.T().Format(time.RFC3339),
StartDate: helpers.FormatDate(summary.FromTime.T()),
Timezone: tzName,
},
},
}
}

View File

@ -2,8 +2,8 @@ package v1
import (
"fmt"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math"
"sync"
"time"
@ -13,9 +13,17 @@ import (
// https://pastr.de/v/736450
type SummariesViewModel struct {
Data []*SummariesData `json:"data"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
Data []*SummariesData `json:"data"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
CumulativeTotal *SummariesCumulativeTotal `json:"cummulative_total"` // typo is intended
}
type SummariesCumulativeTotal struct {
Decimal string `json:"decimal"`
Digital string `json:"digital"`
Seconds float64 `json:"seconds"`
Text string `json:"text"`
}
type SummariesData struct {
@ -73,10 +81,23 @@ func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
}
}
var totalTime time.Duration
for _, s := range summaries {
totalTime += s.TotalTime()
}
totalHrs, totalMins, totalSecs := totalTime.Hours(), (totalTime - time.Duration(totalTime.Hours())*time.Hour).Minutes(), totalTime.Seconds()
return &SummariesViewModel{
Data: data,
End: maxDate,
Start: minDate,
CumulativeTotal: &SummariesCumulativeTotal{
Decimal: fmt.Sprintf("%.2f", totalHrs),
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
Seconds: totalSecs,
Text: helpers.FmtWakatimeDuration(totalTime),
},
}
}
@ -98,7 +119,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs,
Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total),
Text: helpers.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
},
Range: &SummariesRange{
@ -180,7 +201,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEn
Name: e.Key,
Percent: percentage,
Seconds: secs,
Text: utils.FmtWakatimeDuration(total),
Text: helpers.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
}
}

View File

@ -23,7 +23,7 @@ type Heartbeat struct {
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`

View File

@ -64,3 +64,42 @@ func TestHeartbeat_GetKey(t *testing.T) {
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
}
func TestHeartbeat_Hashed(t *testing.T) {
var sut1, sut2 *Heartbeat
// same hash if only non-hashed fields are different
sut1 = &Heartbeat{Entity: "file1", Editor: "vscode", Time: CustomTime(time.Unix(1673810732, 0))}
sut2 = &Heartbeat{Entity: "file1", Editor: "goland", Time: CustomTime(time.Unix(1673810732, 0))}
assert.Equal(t, sut1.Hashed().Hash, sut2.Hashed().Hash)
// different hash if time is different
sut1 = &Heartbeat{Entity: "file1", Editor: "vscode", Time: CustomTime(time.Unix(1673810732, 0))}
sut2 = &Heartbeat{Entity: "file1", Editor: "goland", Time: CustomTime(time.Unix(1673810733, 0))}
assert.NotEqual(t, sut1.Hashed().Hash, sut2.Hashed().Hash)
// different hash if any other hashed field is different
sut1 = &Heartbeat{Entity: "file1", Editor: "vscode", Time: CustomTime(time.Unix(1673810732, 0))}
sut2 = &Heartbeat{Entity: "file2", Editor: "goland", Time: CustomTime(time.Unix(1673810732, 0))}
assert.NotEqual(t, sut1.Hashed().Hash, sut2.Hashed().Hash)
}
func TestHeartbeat_Hashed_NoCollision(t *testing.T) {
hashes := map[string]bool{}
for i := 0; i < 2500; i++ {
sut := &Heartbeat{
UserID: "gopher",
Entity: "~/dev/wakapi",
Type: "file",
Category: "coding",
Project: "wakapi",
Branch: "master",
Language: "go",
IsWrite: false,
Time: CustomTime(time.Unix(1673810732+int64(i), 0)),
}
assert.NotContains(t, hashes, sut.Hashed().Hash)
hashes[sut.Hash] = true
}
}

18
models/init_test.go Normal file
View File

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

View File

@ -11,7 +11,6 @@ type LeaderboardItem struct {
ID uint `json:"-" gorm:"primary_key; size:32"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
Rank uint `json:"rank" gorm:"->"`
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
@ -19,16 +18,45 @@ type LeaderboardItem struct {
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
type Leaderboard []*LeaderboardItem
// https://github.com/go-gorm/gorm/issues/5789
// https://github.com/go-gorm/gorm/issues/5284#issuecomment-1107775806
type LeaderboardItemRanked struct {
LeaderboardItem
Rank uint
}
func (l1 *LeaderboardItemRanked) Equals(l2 *LeaderboardItemRanked) bool {
return l1.ID == l2.ID
}
type Leaderboard []*LeaderboardItemRanked
func (l *Leaderboard) Add(item *LeaderboardItemRanked) {
if _, found := slice.Find[*LeaderboardItemRanked](*l, func(i int, item2 *LeaderboardItemRanked) bool {
return item.Equals(item2)
}); !found {
*l = append(*l, item)
}
}
func (l *Leaderboard) AddMany(items []*LeaderboardItemRanked) {
for _, item := range items {
l.Add(item)
}
}
func (l Leaderboard) UserIDs() []string {
return slice.Unique[string](slice.Map[*LeaderboardItem, string](l, func(i int, item *LeaderboardItem) string {
return slice.Unique[string](slice.Map[*LeaderboardItemRanked, string](l, func(i int, item *LeaderboardItemRanked) string {
return item.UserID
}))
}
func (l Leaderboard) HasUser(userId string) bool {
return slice.Contain(l.UserIDs(), userId)
}
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
return slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
return slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
})
}
@ -65,7 +93,7 @@ func (l Leaderboard) TopKeys(by uint8) []string {
}
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
return Leaderboard(slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
return Leaderboard(slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.UserID == userId
})).TopKeys(by)
}

View File

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

View File

@ -5,20 +5,18 @@ import (
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"strconv"
"strings"
"time"
)
const (
UserKey = "user"
ImprintKey = "imprint"
AuthCookieKey = "wakapi_auth"
UserKey = "user"
ImprintKey = "imprint"
AuthCookieKey = "wakapi_auth"
PersistentIntervalKey = "wakapi_summary_interval"
)
type MigrationFunc func(db *gorm.DB) error
type KeyStringValue struct {
Key string `gorm:"primary_key"`
Value string `gorm:"type:text"`

View File

@ -34,7 +34,7 @@ type Summary struct {
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project filter is applied
NumHeartbeats int `json:"-" gorm:"default:0"`
NumHeartbeats int `json:"-"`
}
type SummaryItems []*SummaryItem

View File

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

View File

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

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

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

View File

@ -6,19 +6,18 @@ type Newsbox struct {
}
type HomeViewModel struct {
Success string
Error string
Messages
TotalHours int
TotalUsers int
Newsbox *Newsbox
}
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
s.Success = m
s.SetSuccess(m)
return s
}
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
s.Error = m
s.SetError(m)
return s
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,13 @@
package view
import "github.com/muety/wakapi/models"
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
)
type SummaryViewModel struct {
Messages
*models.Summary
*models.SummaryParams
User *models.User
@ -10,18 +15,26 @@ type SummaryViewModel struct {
EditorColors map[string]string
LanguageColors map[string]string
OSColors map[string]string
Error string
Success string
ApiKey string
RawQuery string
UserFirstData time.Time
}
func (s SummaryViewModel) UserDataExpiring() bool {
cfg := conf.Get()
return cfg.Subscriptions.Enabled &&
cfg.App.DataRetentionMonths > 0 &&
!s.UserFirstData.IsZero() &&
!s.User.HasActiveSubscription() &&
time.Now().AddDate(0, -cfg.App.DataRetentionMonths, 0).After(s.UserFirstData)
}
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
s.Success = m
s.SetSuccess(m)
return s
}
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
s.Error = m
s.SetError(m)
return s
}

View File

@ -9,7 +9,7 @@
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
},
"devDependencies": {
"@iconify/json": "^1.1.444",
"@iconify/json": "^2.1.136",
"@iconify/json-tools": "^1.0.10",
"chokidar-cli": "^3.0.0",
"tailwindcss": "^3.1.8"

View File

@ -170,7 +170,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
return counts, nil
}
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
func (r *HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
var results []string
if err := r.db.
Model(&models.Heartbeat{}).
@ -199,3 +199,13 @@ func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
}
return nil
}
func (r *HeartbeatRepository) DeleteByUserBefore(user *models.User, t time.Time) error {
if err := r.db.
Where("user_id = ?", user.ID).
Where("time <= ?", t.Local()).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

View File

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

View File

@ -33,13 +33,27 @@ func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
return count, err
}
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
func (r *LeaderboardRepository) CountUsers() (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Distinct("user_id").
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
// TODO: distinct by (user, key) to filter out potential duplicates ?
var items []*models.LeaderboardItem
q := r.db.
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
@ -47,13 +61,16 @@ func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalK
return items, nil
}
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
var items []*models.LeaderboardItem
q := r.db.
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("user_id = ?", userId).
Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq).Where("user_id = ?", userId)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
@ -79,3 +96,13 @@ func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *mode
}
return nil
}
func (r *LeaderboardRepository) withPaging(q *gorm.DB, limit, skip int) *gorm.DB {
if limit > 0 {
q = q.Where("\"rank\" <= ?", skip+limit)
}
if skip > 0 {
q = q.Where("\"rank\" > ?", skip)
}
return q
}

View File

@ -16,7 +16,7 @@ FROM information_schema.tables
WHERE table_schema = ?
GROUP BY table_schema`
const sizeTplPostgres = `SELECT pg_database_size('%s');`
const sizeTplPostgres = `SELECT pg_database_size(?);`
const sizeTplSqlite = `
SELECT page_count * page_size as size

View File

@ -31,6 +31,7 @@ type IHeartbeatRepository interface {
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
DeleteByUserBefore(*models.User, time.Time) error
}
type IDiagnosticsRepository interface {
@ -42,6 +43,7 @@ type IKeyValueRepository interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
Search(string) ([]*models.KeyStringValue, error)
}
type ILanguageMappingRepository interface {
@ -66,14 +68,12 @@ type ISummaryRepository interface {
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
DeleteByUserBefore(string, time.Time) error
}
type IUserRepository interface {
GetById(string) (*models.User, error)
FindOne(user models.User) (*models.User, error)
GetByIds([]string) ([]*models.User, error)
GetByApiKey(string) (*models.User, error)
GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
@ -90,8 +90,9 @@ type IUserRepository interface {
type ILeaderboardRepository interface {
InsertBatch([]*models.LeaderboardItem) error
CountAllByUser(string) (int64, error)
CountUsers() (int64, error)
DeleteByUser(string) error
DeleteByUserAndInterval(string, *models.IntervalKey) error
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
}

View File

@ -86,6 +86,16 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
return nil
}
func (r *SummaryRepository) DeleteByUserBefore(userId string, t time.Time) error {
if err := r.db.
Where("user_id = ?", userId).
Where("to_time <= ?", t.Local()).
Delete(models.Summary{}).Error; err != nil {
return err
}
return nil
}
// inplace
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
var items []*models.SummaryItem

View File

@ -15,9 +15,9 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetById(userId string) (*models.User, error) {
func (r *UserRepository) FindOne(attributes models.User) (*models.User, error) {
u := &models.User{}
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
if err := r.db.Where(&attributes).First(u).Error; err != nil {
return u, err
}
return u, nil
@ -34,39 +34,6 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
return users, nil
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
if key == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
if resetToken == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
if email == "" {
return nil, errors.New("invalid input")
}
u := &models.User{}
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
return u, err
}
return u, nil
}
func (r *UserRepository) GetAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.
@ -144,7 +111,7 @@ func (r *UserRepository) Count() (int64, error) {
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
if u, err := r.GetById(user.ID); err == nil && u != nil && u.ID != "" {
if u, err := r.FindOne(models.User{ID: user.ID}); err == nil && u != nil && u.ID != "" {
return u, false, nil
}
@ -176,6 +143,8 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
"subscribed_until": user.SubscribedUntil,
"stripe_customer_id": user.StripeCustomerId,
}
result := r.db.Model(user).Updates(updateMap)

View File

@ -40,7 +40,7 @@ func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
}
if !h.cache.Contains(hash) {
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
h.cache.Add(hash, avatars.MakeAvatar(hash))
}
data, _ := h.cache.Get(hash)

View File

@ -2,14 +2,13 @@ package api
import (
"encoding/json"
"github.com/muety/wakapi/helpers"
"net/http"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
)
type DiagnosticsApiHandler struct {
@ -55,5 +54,5 @@ func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondJSON(w, r, http.StatusCreated, struct{}{})
helpers.RespondJSON(w, r, http.StatusCreated, struct{}{})
}

View File

@ -1,6 +1,7 @@
package api
import (
"github.com/muety/wakapi/helpers"
"net/http"
"github.com/gorilla/mux"
@ -120,7 +121,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
defer func() {}()
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
helpers.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
}
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)

View File

@ -5,13 +5,13 @@ import (
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"runtime"
"sort"
@ -37,6 +37,9 @@ const (
DescAdminTotalUsers = "Total number of registered users."
DescAdminActiveUsers = "Number of active users."
DescJobQueueEnqueued = "Number of jobs currently enqueued"
DescJobQueueTotalFinished = "Total number of processed jobs"
DescMemAllocTotal = "Total number of bytes allocated for heap"
DescMemSysTotal = "Total number of bytes obtained from the OS"
DescGoroutines = "Total number of running goroutines"
@ -126,7 +129,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
return nil, err
}
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
if err != nil {
@ -142,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
// User Metrics
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal,
Value: int64(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats,
Value: int64(heartbeatCount),
@ -164,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
})
for _, p := range summaryToday.Projects {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
@ -173,7 +176,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}
for _, l := range summaryToday.Languages {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
@ -182,7 +185,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}
for _, e := range summaryToday.Editors {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
@ -191,7 +194,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}
for _, o := range summaryToday.OperatingSystems {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
@ -200,7 +203,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}
for _, m := range summaryToday.Machines {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
@ -209,7 +212,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
@ -221,21 +224,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_goroutines_total",
Desc: DescGoroutines,
Value: int64(runtime.NumGoroutine()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_mem_alloc_total",
Desc: DescMemAllocTotal,
Value: int64(memStats.Alloc),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_mem_sys_total",
Desc: DescMemSysTotal,
Value: int64(memStats.Sys),
@ -248,13 +251,30 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
logbuch.Warn("failed to get database size (%v)", err)
}
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_db_total_bytes",
Desc: DescDatabaseSize,
Value: dbSize,
Labels: []mm.Label{},
})
// Miscellaneous
for _, qm := range conf.GetQueueMetrics() {
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_queue_jobs_enqueued",
Value: int64(qm.EnqueuedJobs),
Desc: DescJobQueueEnqueued,
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_queue_jobs_total_finished",
Value: int64(qm.FinishedJobs),
Desc: DescJobQueueTotalFinished,
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
})
}
return &metrics, nil
}
@ -281,28 +301,28 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
return nil, err
}
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime,
Value: int64(totalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats,
Value: totalHeartbeats,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers,
Value: totalUsers,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers,
Value: int64(len(activeUsers)),
@ -318,7 +338,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
}
for _, uc := range userCounts {
metrics = append(metrics, &mm.CounterMetric{
metrics = append(metrics, &mm.GaugeMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats,
Value: uc.Count,

View File

@ -1,6 +1,7 @@
package api
import (
"github.com/muety/wakapi/helpers"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
@ -8,7 +9,6 @@ import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type SummaryApiHandler struct {
@ -58,5 +58,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondJSON(w, r, http.StatusOK, summary)
helpers.RespondJSON(w, r, http.StatusOK, summary)
}

View File

@ -2,6 +2,7 @@ package v1
import (
"fmt"
"github.com/muety/wakapi/helpers"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
"time"
@ -11,7 +12,6 @@ import (
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
)
@ -63,7 +63,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
helpers.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
return
}
@ -83,11 +83,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
vm := v1.NewBadgeDataFrom(summary)
h.cache.SetDefault(cacheKey, vm)
utils.RespondJSON(w, r, http.StatusOK, vm)
helpers.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey, filters *models.Filters) (*models.Summary, error, int) {
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err, http.StatusBadRequest
}

View File

@ -3,12 +3,12 @@ package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
@ -50,7 +50,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
return // response was already sent by util function
}
summary, err, status := h.loadUserSummary(user, utils.ParseSummaryFilters(r))
summary, err, status := h.loadUserSummary(user, helpers.ParseSummaryFilters(r))
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -58,7 +58,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
}
vm := v1.NewAllTimeFrom(summary)
utils.RespondJSON(w, r, http.StatusOK, vm)
helpers.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filters) (*models.Summary, error, int) {

View File

@ -2,6 +2,7 @@ package v1
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/helpers"
"net/http"
"time"
@ -11,7 +12,6 @@ import (
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type HeartbeatsResult struct {
@ -82,5 +82,5 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
End: rangeTo.UTC().Format(time.RFC3339),
Timezone: timezone.String(),
}
utils.RespondJSON(w, r, http.StatusOK, res)
helpers.RespondJSON(w, r, http.StatusOK, res)
}

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"net/http"
"strings"
@ -11,7 +12,6 @@ import (
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type ProjectsHandler struct {
@ -70,5 +70,5 @@ func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
}
vm := &v1.ProjectsViewModel{Data: projects}
utils.RespondJSON(w, r, http.StatusOK, vm)
helpers.RespondJSON(w, r, http.StatusOK, vm)
}

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"net/http"
"time"
@ -10,7 +11,6 @@ import (
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type StatsHandler struct {
@ -79,14 +79,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
rangeParam = (*models.IntervalPast7Days)[0]
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
return
}
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
@ -94,7 +94,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, utils.ParseSummaryFilters(r))
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, helpers.ParseSummaryFilters(r))
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -120,7 +120,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
stats.Data.Machines = nil
}
utils.RespondJSON(w, r, http.StatusOK, stats)
helpers.RespondJSON(w, r, http.StatusOK, stats)
}
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) {

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"net/http"
"time"
@ -11,7 +12,6 @@ import (
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type StatusBarViewModel struct {
@ -65,7 +65,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
rangeParam = (*models.IntervalToday)[0]
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ())
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, user.TZ())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
@ -79,7 +79,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
summariesView := v1.NewSummariesFrom([]*models.Summary{summary})
utils.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
helpers.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
CachedAt: time.Now(),
Data: *summariesView.Data[0],
})

View File

@ -3,6 +3,7 @@ package v1
import (
"errors"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/helpers"
"net/http"
"strings"
"time"
@ -63,12 +64,12 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summaries, err, status := h.loadUserSummaries(r)
summaries, err, status := h.loadUserSummaries(r, user)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -76,11 +77,10 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
}
vm := v1.NewSummariesFrom(summaries)
utils.RespondJSON(w, r, http.StatusOK, vm)
helpers.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
func (h *SummariesHandler) loadUserSummaries(r *http.Request, user *models.User) ([]*models.Summary, error, int) {
params := r.URL.Query()
rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")
@ -94,24 +94,24 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
var start, end time.Time
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
} else if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
// also accept start param to be a range param
start, end = parsedFrom, parsedTo
} else {
// eventually, consider start and end params a date
var err error
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
start, err = helpers.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
end, err = helpers.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
@ -133,7 +133,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
// filtering
filters := utils.ParseSummaryFilters(r)
filters := helpers.ParseSummaryFilters(r)
for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/muety/wakapi/helpers"
"net/http"
"github.com/gorilla/mux"
@ -9,7 +10,6 @@ import (
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type UsersHandler struct {
@ -56,5 +56,5 @@ func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
conf.Log().Request(r).Error("%v", err)
}
utils.RespondJSON(w, r, http.StatusOK, v1.UserViewModel{Data: user})
helpers.RespondJSON(w, r, http.StatusOK, v1.UserViewModel{Data: user})
}

View File

@ -3,11 +3,13 @@ package routes
import (
"encoding/json"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"net/http"
"strconv"
@ -45,10 +47,10 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
return
}
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
func (h *HomeHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.HomeViewModel {
var totalHours int
var totalUsers int
var newsbox view.Newsbox
@ -66,14 +68,15 @@ func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
}
if kv, err := h.keyValueSrvc.GetString(conf.KeyNewsbox); err == nil && kv != nil && kv.Value != "" {
json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox)
if err := json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox); err != nil {
logbuch.Error("failed to decode newsbox message - %v", err)
}
}
return &view.HomeViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
vm := &view.HomeViewModel{
TotalHours: totalHours,
TotalUsers: totalUsers,
Newsbox: &newsbox,
}
return routeutils.WithSessionMessages(vm, r, w)
}

View File

@ -39,8 +39,5 @@ func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
}
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
return &view.ImprintViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
return &view.ImprintViewModel{}
}

View File

@ -9,7 +9,9 @@ import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
@ -37,6 +39,7 @@ func (h *LeaderboardHandler) RegisterRoutes(router *mux.Router) {
r.Use(
middlewares.NewAuthenticateMiddleware(h.userService).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
WithOptionalFor([]string{"/"}).
Handler,
)
@ -47,15 +50,19 @@ func (h *LeaderboardHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r)); err != nil {
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r, w)); err != nil {
logbuch.Error(err.Error())
}
}
func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardViewModel {
func (h *LeaderboardHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.LeaderboardViewModel {
user := middlewares.GetPrincipal(r)
byParam := strings.ToLower(r.URL.Query().Get("by"))
keyParam := strings.ToLower(r.URL.Query().Get("key"))
pageParams := utils.ParsePageParamsWithDefault(r, 1, 100)
// note: pagination is not fully implemented, yet
// count function to get total item / total pages is missing
// and according ui (+ optionally search bar) is missing, too
var err error
var leaderboard models.Leaderboard
@ -63,20 +70,46 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
var topKeys []string
if byParam == "" {
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true)
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, pageParams, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
return &view.LeaderboardViewModel{
Messages: view.Messages{Error: criticalError},
}
}
// regardless of page, always show own rank
if user != nil && !leaderboard.HasUser(user.ID) {
// but only if leaderboard spans multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetByIntervalAndUser(models.IntervalPast7Days, user.ID, true); err == nil && len(l) > 0 {
leaderboard = append(leaderboard, l[0])
}
}
}
} else {
if by, ok := allowedAggregations[byParam]; ok {
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, true)
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, pageParams, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
return &view.LeaderboardViewModel{
Messages: view.Messages{Error: criticalError},
}
}
userLeaderboards := slice.GroupWith[*models.LeaderboardItem, string](leaderboard, func(item *models.LeaderboardItem) string {
// regardless of page, always show own rank
if user != nil {
// but only if leaderboard could, in theory, span multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetAggregatedByIntervalAndUser(models.IntervalPast7Days, user.ID, &by, true); err == nil {
leaderboard.AddMany(l)
} else {
conf.Log().Request(r).Error("error while fetching own aggregated user leaderboard - %v", err)
}
}
}
userLeaderboards := slice.GroupWith[*models.LeaderboardItemRanked, string](leaderboard, func(item *models.LeaderboardItemRanked) string {
return item.UserID
})
userLanguages = map[string][]string{}
@ -93,7 +126,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
leaderboard = leaderboard.TopByKey(by, keyParam)
}
} else {
return &view.LeaderboardViewModel{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)}
return &view.LeaderboardViewModel{
Messages: view.Messages{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)},
}
}
}
@ -102,7 +137,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
apiKey = user.ApiKey
}
return &view.LeaderboardViewModel{
vm := &view.LeaderboardViewModel{
User: user,
By: byParam,
Key: keyParam,
@ -110,7 +145,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
UserLanguages: userLanguages,
TopKeys: topKeys,
ApiKey: apiKey,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
PageParams: pageParams,
}
return routeutils.WithSessionMessages(vm, r, w)
}

View File

@ -5,8 +5,10 @@ import (
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -31,13 +33,21 @@ func NewLoginHandler(userService services.IUserService, mailService services.IMa
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
router.Path("/login").Methods(http.MethodPost).HandlerFunc(h.PostLogin)
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword)
router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword)
router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword)
router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword)
authMiddleware := middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
WithOptionalFor([]string{"/logout"})
logoutRouter := router.PathPrefix("/logout").Subrouter()
logoutRouter.Use(authMiddleware.Handler)
logoutRouter.Path("").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
@ -50,7 +60,7 @@ func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
return
}
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
@ -66,25 +76,25 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("resource not found"))
return
}
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid credentials"))
return
}
@ -92,7 +102,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to encode secure cookie - %v", err)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("internal server error"))
return
}
@ -108,6 +118,9 @@ func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
loadTemplates()
}
if user := middlewares.GetPrincipal(r); user != nil {
h.userSrvc.FlushUserCache(user.ID)
}
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
@ -122,7 +135,7 @@ func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
@ -132,7 +145,7 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if !h.config.IsDev() && !h.config.Security.AllowSignup {
w.WriteHeader(http.StatusForbidden)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("registration is disabled on this server"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("registration is disabled on this server"))
return
}
@ -144,18 +157,18 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid parameters"))
return
}
@ -165,23 +178,24 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to create new user - %v", err)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("user already existing"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
routeutils.SetSuccess(r, w, "account created successfully")
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
}
func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r))
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
@ -193,12 +207,12 @@ func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
token := values.Get("token")
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid or missing token"))
return
}
vm := &view.SetPasswordViewModel{
LoginViewModel: *h.buildViewModel(r),
LoginViewModel: *h.buildViewModel(r, w),
Token: token,
}
@ -213,25 +227,25 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
var setRequest models.SetPasswordRequest
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid token"))
return
}
if !setRequest.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid parameters"))
return
}
@ -240,7 +254,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to set new password - %v", err)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to set new password"))
return
} else {
user.Password = hash
@ -249,11 +263,12 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to save new password - %v", err)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to save new password"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound)
routeutils.SetSuccess(r, w, "password updated successfully")
http.Redirect(w, r, fmt.Sprintf("%s/login", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) {
@ -263,19 +278,19 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
if !h.config.Mail.Enabled {
w.WriteHeader(http.StatusNotImplemented)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server"))
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("mailing is disabled on this server"))
return
}
var resetRequest models.ResetPasswordRequest
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
return
}
@ -283,7 +298,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to generate password reset token - %v", err)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to generate password reset token"))
return
} else {
go func(user *models.User) {
@ -299,16 +314,16 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
conf.Log().Request(r).Warn("password reset requested for unregistered address '%s'", resetRequest.Email)
}
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound)
routeutils.SetSuccess(r, w, "an e-mail was sent to you in case your e-mail address was registered")
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
func (h *LoginHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.LoginViewModel {
numUsers, _ := h.userSrvc.Count()
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
vm := &view.LoginViewModel{
TotalUsers: int(numUsers),
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
}
return routeutils.WithSessionMessages(vm, r, w)
}

View File

@ -1,7 +1,7 @@
package routes
import (
"fmt"
"github.com/muety/wakapi/helpers"
"html/template"
"net/http"
"strings"
@ -24,16 +24,16 @@ func Init() {
func DefaultTemplateFuncs() template.FuncMap {
return template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"datetime": utils.FormatDateTimeHuman,
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"date": helpers.FormatDateHuman,
"datetime": helpers.FormatDateTimeHuman,
"simpledate": helpers.FormatDate,
"simpledatetime": helpers.FormatDateTime,
"duration": helpers.FmtWakatimeDuration,
"floordate": datetime.BeginOfDay,
"ceildate": utils.CeilDate,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"add": add,
"capitalize": utils.Capitalize,
"lower": strings.ToLower,
"toRunes": utils.ToRunes,
@ -104,5 +104,9 @@ func loadTemplates() {
}
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
return config.Get().Server.BasePath + "/"
}
func add(i, j int) int {
return i + j
}

View File

@ -69,7 +69,10 @@ func NewSettingsHandler(
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
@ -79,7 +82,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
@ -89,7 +92,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing form values"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing form values"))
return
}
@ -100,7 +103,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if actionFunc == nil {
logbuch.Warn("failed to dispatch action '%s'", action)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("unknown action requests"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("unknown action requests"))
return
}
@ -113,15 +116,15 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if errorMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError(errorMsg))
return
}
if successMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithSuccess(successMsg))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *SettingsHandler) dispatchAction(action string) action {
@ -178,7 +181,11 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
}
if !payload.IsValid() {
return http.StatusBadRequest, "", "invalid parameters"
return http.StatusBadRequest, "", "invalid parameters - perhaps invalid e-mail address?"
}
if payload.Email == "" && user.HasActiveSubscription() {
return http.StatusBadRequest, "", "cannot unset email while subscription is active"
}
user.Email = payload.Email
@ -282,7 +289,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
var err error
user := middlewares.GetPrincipal(r)
defer h.userSrvc.FlushCache()
defer h.userSrvc.FlushUserCache(user.ID)
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
@ -464,7 +471,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key or endpoint URL invalid?"
}
if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil {
@ -618,8 +625,9 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
}
}(user)
routeutils.SetSuccess(r, w, "Your account will be deleted in a few minutes. Sorry to you go.")
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
return -1, "", ""
}
@ -657,19 +665,19 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) boo
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
logbuch.Info("clearing summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
logbuch.Error("failed to clear summaries: %v", err)
conf.Log().Error("failed to clear summaries: %v", err)
return err
}
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
if err := h.aggregationSrvc.AggregateSummaries(datastructure.NewSet(user.ID)); err != nil {
conf.Log().Error("failed to regenerate summaries: %v", err)
return err
}
return nil
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r)
// mappings
@ -679,7 +687,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
@ -708,7 +716,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
@ -730,17 +738,33 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
ApiKey: user.ApiKey,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
// subscriptions
var subscriptionPrice string
if h.config.Subscriptions.Enabled {
subscriptionPrice = h.config.Subscriptions.StandardPrice
}
// user first data
var firstData time.Time
firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID))
if firstDataKv.Value != "" {
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
}
vm := &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
ApiKey: user.ApiKey,
UserFirstData: firstData,
SubscriptionPrice: subscriptionPrice,
SupportContact: h.config.App.SupportContact,
DataRetentionMonths: h.config.App.DataRetentionMonths,
}
return routeutils.WithSessionMessages(vm, r, w)
}

347
routes/subscription.go Normal file
View File

@ -0,0 +1,347 @@
package routes
import (
"encoding/json"
"errors"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/stripe/stripe-go/v74"
stripePortalSession "github.com/stripe/stripe-go/v74/billingportal/session"
stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session"
stripeCustomer "github.com/stripe/stripe-go/v74/customer"
stripePrice "github.com/stripe/stripe-go/v74/price"
"github.com/stripe/stripe-go/v74/webhook"
"io/ioutil"
"net/http"
"strings"
"time"
)
/*
How to integrate with Stripe?
---
1. Create a plan with recurring payment (https://dashboard.stripe.com/test/products?active=true), copy its ID and save it as 'standard_price_id'
2. Create a webhook (https://dashboard.stripe.com/test/webhooks), with target URL '/subscription/webhook' and events ['customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'checkout.session.completed'], copy the endpoint secret and save it to 'stripe_endpoint_secret'
3. Create a secret API key (https://dashboard.stripe.com/test/apikeys), copy it and save it to 'stripe_secret_key'
4. Copy the publishable API key (https://dashboard.stripe.com/test/apikeys) and save it to 'stripe_api_key'
*/
type SubscriptionHandler struct {
config *conf.Config
userSrvc services.IUserService
mailSrvc services.IMailService
keyValueSrvc services.IKeyValueService
httpClient *http.Client
}
func NewSubscriptionHandler(
userService services.IUserService,
mailService services.IMailService,
keyValueService services.IKeyValueService,
) *SubscriptionHandler {
config := conf.Get()
if config.Subscriptions.Enabled {
stripe.Key = config.Subscriptions.StripeSecretKey
price, err := stripePrice.Get(config.Subscriptions.StandardPriceId, nil)
if err != nil {
logbuch.Fatal("failed to fetch stripe plan details: %v", err)
}
config.Subscriptions.StandardPrice = strings.TrimSpace(fmt.Sprintf("%2.f €", price.UnitAmountDecimal/100.0)) // TODO: respect actual currency
logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice)
}
return &SubscriptionHandler{
config: config,
userSrvc: userService,
mailSrvc: mailService,
keyValueSrvc: keyValueService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// https://stripe.com/docs/billing/quickstart?lang=go
func (h *SubscriptionHandler) RegisterRoutes(router *mux.Router) {
if !h.config.Subscriptions.Enabled {
return
}
subRouterPublic := router.PathPrefix("/subscription").Subrouter()
subRouterPublic.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess)
subRouterPublic.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel)
subRouterPublic.Path("/webhook").Methods(http.MethodPost).HandlerFunc(h.PostWebhook)
subRouterPrivate := subRouterPublic.PathPrefix("").Subrouter()
subRouterPrivate.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler,
)
subRouterPrivate.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout)
subRouterPrivate.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal)
}
func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
if user.Email == "" {
routeutils.SetError(r, w, "missing e-mail address")
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
return
}
if err := r.ParseForm(); err != nil {
routeutils.SetError(r, w, "missing form values")
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
return
}
checkoutParams := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: &h.config.Subscriptions.StandardPriceId,
Quantity: stripe.Int64(1),
},
},
ClientReferenceID: &user.ID,
SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)),
CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cancel", h.config.Server.PublicUrl, h.config.Server.BasePath)),
}
if user.StripeCustomerId != "" {
checkoutParams.Customer = &user.StripeCustomerId
} else {
checkoutParams.CustomerEmail = &user.Email
}
session, err := stripeCheckoutSession.New(checkoutParams)
if err != nil {
conf.Log().Request(r).Error("failed to create stripe checkout session: %v", err)
routeutils.SetError(r, w, "something went wrong")
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
return
}
http.Redirect(w, r, session.URL, http.StatusSeeOther)
}
func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
if user.StripeCustomerId == "" {
routeutils.SetError(r, w, "no subscription found with your e-mail address, please contact us!")
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
return
}
portalParams := &stripe.BillingPortalSessionParams{
Customer: &user.StripeCustomerId,
ReturnURL: &h.config.Server.PublicUrl,
}
session, err := stripePortalSession.New(portalParams)
if err != nil {
conf.Log().Request(r).Error("failed to create stripe portal session: %v", err)
routeutils.SetError(r, w, "something went wrong")
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
return
}
http.Redirect(w, r, session.URL, http.StatusSeeOther)
}
func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request) {
bodyReader := http.MaxBytesReader(w, r.Body, int64(65536))
payload, err := ioutil.ReadAll(bodyReader)
if err != nil {
conf.Log().Request(r).Error("error in stripe webhook request: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
event, err := webhook.ConstructEventWithOptions(payload, r.Header.Get("Stripe-Signature"), h.config.Subscriptions.StripeEndpointSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
conf.Log().Request(r).Error("stripe webhook signature verification failed: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
switch event.Type {
case "customer.subscription.deleted",
"customer.subscription.updated",
"customer.subscription.created":
// example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k
subscription, err := h.parseSubscriptionEvent(w, r, event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
logbuch.Info("received stripe subscription event of type '%s' for subscription '%s' (customer '%s').", event.Type, subscription.ID, subscription.Customer.ID)
// first, try to get user by associated customer id (requires checkout.session.completed event to have been processed before)
user, err := h.userSrvc.GetUserByStripeCustomerId(subscription.Customer.ID)
if err != nil {
conf.Log().Request(r).Warn("failed to find user with stripe customer id '%s' to update their subscription (status '%s')", subscription.Customer.ID, subscription.Status)
// second, resolve customer and try to get user by email
customer, err := stripeCustomer.Get(subscription.Customer.ID, nil)
if err != nil {
conf.Log().Request(r).Error("failed to fetch stripe customer with id '%s', %v", subscription.Customer.ID, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
u, err := h.userSrvc.GetUserByEmail(customer.Email)
if err != nil {
conf.Log().Request(r).Error("failed to get user with email '%s' as stripe customer '%s' for processing event for subscription %s, %v", customer.Email, subscription.Customer.ID, subscription.ID, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
user = u
}
if err := h.handleSubscriptionEvent(subscription, user); err != nil {
conf.Log().Request(r).Error("failed to handle subscription event %s (%s) for user %s, %v", event.ID, event.Type, user.ID, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
case "checkout.session.completed":
// example payload: https://pastr.de/p/d01iniw9naq9hkmvyqtxin2w
checkoutSession, err := h.parseCheckoutSessionEvent(w, r, event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
logbuch.Info("received stripe checkout session event of type '%s' for session '%s' (customer '%s' with email '%s').", event.Type, checkoutSession.ID, checkoutSession.Customer.ID, checkoutSession.CustomerEmail)
user, err := h.userSrvc.GetUserById(checkoutSession.ClientReferenceID)
if err != nil {
conf.Log().Request(r).Error("failed to find user with id '%s' to update associated stripe customer (%s)", user.ID, checkoutSession.Customer.ID)
w.WriteHeader(http.StatusInternalServerError)
return
}
if user.StripeCustomerId == "" {
user.StripeCustomerId = checkoutSession.Customer.ID
if _, err := h.userSrvc.Update(user); err != nil {
conf.Log().Request(r).Error("failed to update stripe customer id (%s) for user '%s', %v", checkoutSession.Customer.ID, user.ID, err)
} else {
logbuch.Info("associated user '%s' with stripe customer '%s'", user.ID, checkoutSession.Customer.ID)
}
} else if user.StripeCustomerId != checkoutSession.Customer.ID {
conf.Log().Request(r).Error("invalid state: tried to associate user '%s' with stripe customer '%s', but '%s' already assigned", user.ID, checkoutSession.Customer.ID, user.StripeCustomerId)
}
default:
logbuch.Warn("got stripe event '%s' with no handler defined", event.Type)
}
w.WriteHeader(http.StatusOK)
}
func (h *SubscriptionHandler) GetCheckoutSuccess(w http.ResponseWriter, r *http.Request) {
routeutils.SetSuccess(r, w, "you have successfully subscribed to Wakapi!")
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
}
func (h *SubscriptionHandler) GetCheckoutCancel(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
}
func (h *SubscriptionHandler) handleSubscriptionEvent(subscription *stripe.Subscription, user *models.User) error {
var hasSubscribed bool
switch subscription.Status {
case "active":
until := models.CustomTime(time.Unix(subscription.CurrentPeriodEnd, 0))
if user.SubscribedUntil == nil || !user.SubscribedUntil.T().Equal(until.T()) {
hasSubscribed = true
user.SubscribedUntil = &until
logbuch.Info("user %s got active subscription %s until %v", user.ID, subscription.ID, user.SubscribedUntil)
}
if cancelAt := time.Unix(subscription.CancelAt, 0); !cancelAt.IsZero() && cancelAt.After(time.Now()) {
logbuch.Info("user %s chose to cancel subscription %s by %v", user.ID, subscription.ID, cancelAt)
}
case "canceled", "unpaid":
user.SubscribedUntil = nil
logbuch.Info("user %s's subscription %s got canceled, because of status update to '%s'", user.ID, subscription.ID, subscription.Status)
default:
logbuch.Info("got subscription (%s) status update to '%s' for user '%s'", subscription.ID, subscription.Status, user.ID)
return nil
}
_, err := h.userSrvc.Update(user)
if err == nil && hasSubscribed {
go h.clearSubscriptionNotificationStatus(user.ID)
}
return err
}
func (h *SubscriptionHandler) parseSubscriptionEvent(w http.ResponseWriter, r *http.Request, event stripe.Event) (*stripe.Subscription, error) {
var subscription stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
conf.Log().Request(r).Error("failed to parse stripe webhook payload: %v", err)
w.WriteHeader(http.StatusBadRequest)
return nil, err
}
return &subscription, nil
}
func (h *SubscriptionHandler) parseCheckoutSessionEvent(w http.ResponseWriter, r *http.Request, event stripe.Event) (*stripe.CheckoutSession, error) {
var checkoutSession stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &checkoutSession); err != nil {
conf.Log().Request(r).Error("failed to parse stripe webhook payload: %v", err)
w.WriteHeader(http.StatusBadRequest)
return nil, err
}
return &checkoutSession, nil
}
func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.Customer, error) {
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf(`email:"%s"`, email),
},
}
results := stripeCustomer.Search(params)
if err := results.Err(); err != nil {
return nil, err
}
if results.Next() {
return results.Customer(), nil
} else {
return nil, errors.New("no customer found with given criteria")
}
}
func (h *SubscriptionHandler) clearSubscriptionNotificationStatus(userId string) {
key := fmt.Sprintf("%s_%s", conf.KeySubscriptionNotificationSent, userId)
if err := h.keyValueSrvc.DeleteString(key); err != nil {
logbuch.Warn("failed to delete '%s', %v", key, err)
}
}

View File

@ -1,37 +1,48 @@
package routes
import (
"fmt"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type SummaryHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
keyValueSrvc services.IKeyValueService
}
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService, keyValueService services.IKeyValueService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
summarySrvc: summaryService,
userSrvc: userService,
keyValueSrvc: keyValueService,
config: conf.Get(),
}
}
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r1 := router.PathPrefix("/summary").Subrouter()
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler)
r1.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r2 := router.PathPrefix("/summary").Subrouter()
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler)
r2.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
@ -43,43 +54,58 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
rawQuery := r.URL.RawQuery
q := r.URL.Query()
if q.Get("interval") == "" && q.Get("from") == "" {
// If the PersistentIntervalKey cookie is set, redirect to the correct summary page
if intervalCookie, _ := r.Cookie(models.PersistentIntervalKey); intervalCookie != nil {
redirectAddress := fmt.Sprintf("%s/summary?interval=%s", h.config.Server.BasePath, intervalCookie.Value)
http.Redirect(w, r, redirectAddress, http.StatusFound)
}
q.Set("interval", "today")
r.URL.RawQuery = q.Encode()
} else if q.Get("interval") != "" {
// Send a Set-Cookie header to persist the interval
headerValue := fmt.Sprintf("%s=%s", models.PersistentIntervalKey, q.Get("interval"))
w.Header().Add("Set-Cookie", headerValue)
}
summaryParams, _ := utils.ParseSummaryParams(r)
summaryParams, _ := helpers.ParseSummaryParams(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
conf.Log().Request(r).Error("failed to load summary - %v", err)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError(err.Error()))
return
}
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError("unauthorized"))
return
}
// user first data
var firstData time.Time
firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID))
if firstDataKv.Value != "" {
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
}
vm := view.SummaryViewModel{
Summary: summary,
SummaryParams: summaryParams,
User: user,
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
EditorColors: su.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
LanguageColors: su.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
OSColors: su.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
UserFirstData: firstData,
}
templates[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
func (h *SummaryHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SummaryViewModel {
return su.WithSessionMessages(&view.SummaryViewModel{}, r, w)
}

View File

@ -2,11 +2,10 @@ package utils
import (
"errors"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
"time"
)
const (
@ -32,18 +31,18 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI
var intervalKey = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
if i, err := utils.ParseInterval(groups[1]); err == nil {
if i, err := helpers.ParseInterval(groups[1]); err == nil {
intervalKey = i
}
}
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
_, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
interval := &models.KeyedInterval{
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
Key: intervalKey,
}
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
// negative value means no limit
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
return nil, nil, errors.New("requested time range too broad")

33
routes/utils/messages.go Normal file
View File

@ -0,0 +1,33 @@
package utils
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models/view"
"net/http"
)
func SetError(r *http.Request, w http.ResponseWriter, message string) {
setMessage(r, w, message, "error")
}
func SetSuccess(r *http.Request, w http.ResponseWriter, message string) {
setMessage(r, w, message, "success")
}
func WithSessionMessages[T view.BasicViewModel](vm T, r *http.Request, w http.ResponseWriter) T {
session, _ := conf.GetSessionStore().Get(r, conf.SessionKeyDefault)
if errors := session.Flashes("error"); len(errors) > 0 {
vm.SetError(errors[0].(string))
}
if successes := session.Flashes("success"); len(successes) > 0 {
vm.SetSuccess(successes[0].(string))
}
session.Save(r, w)
return vm
}
func setMessage(r *http.Request, w http.ResponseWriter, message, key string) {
session, _ := conf.GetSessionStore().Get(r, conf.SessionKeyDefault)
session.AddFlash(message, key)
session.Save(r, w)
}

View File

@ -1,14 +1,15 @@
package utils
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
summaryParams, err := helpers.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
}
@ -38,3 +39,13 @@ func LoadUserSummaryByParams(ss services.ISummaryService, params *models.Summary
return summary, nil, http.StatusOK
}
func FilterColors(all map[string]string, haystack models.SummaryItems) map[string]string {
subset := make(map[string]string)
for _, item := range haystack {
if c, ok := all[strings.ToLower(item.Key)]; ok {
subset[strings.ToLower(item.Key)] = c
}
}
return subset
}

View File

@ -13,34 +13,34 @@ import (
// CheckEffectiveUser extracts the requested user from a URL (like '/users/{user}'), compares it with the currently authorized user and writes an HTTP error if they differ.
// Fallback can be used to manually set a value for '{user}' if none is present.
func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService services.IUserService, fallback string) (*models.User, error) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if vars["user"] == "" {
vars["user"] = fallback
}
authorizedUser = middlewares.GetPrincipal(r)
if authorizedUser != nil {
if vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
}
requestedUser, err := userService.GetUserById(vars["user"])
if err != nil {
err := errors.New("user not found")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(err.Error()))
return nil, err
}
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID && !authorizedUser.IsAdmin {
respondError := func(code int, text string) (*models.User, error) {
err := errors.New(conf.ErrUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(err.Error()))
return nil, err
}
var vars = mux.Vars(r)
if vars["user"] == "" {
vars["user"] = fallback
}
authorizedUser := middlewares.GetPrincipal(r)
if authorizedUser == nil {
return respondError(http.StatusUnauthorized, conf.ErrUnauthorized)
} else if vars["user"] == "current" {
return authorizedUser, nil
}
if authorizedUser.ID != vars["user"] && !authorizedUser.IsAdmin {
return respondError(http.StatusUnauthorized, conf.ErrUnauthorized)
}
requestedUser, err := userService.GetUserById(vars["user"])
if err != nil {
return respondError(http.StatusNotFound, "user not found")
}
return requestedUser, nil
}

View File

@ -0,0 +1,83 @@
package utils
import (
"context"
"fmt"
"github.com/gorilla/mux"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)
func TestCheckEffectiveUser_Current(t *testing.T) {
// request current as normal user -> return myself
r, w, userServiceMock := mockUserAwareRequest("current", "user1")
user, err := CheckEffectiveUser(w, r, userServiceMock, "current")
assert.Nil(t, err)
assert.Equal(t, "user1", user.ID)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 0)
}
func TestCheckEffectiveUser_Other(t *testing.T) {
// request someone else as admin -> return someone else
r, w, userServiceMock := mockUserAwareRequest("user2", "admin")
user, err := CheckEffectiveUser(w, r, userServiceMock, "current")
assert.Nil(t, err)
assert.Equal(t, "user2", user.ID)
userServiceMock.AssertCalled(t, "GetUserById", "user2")
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 1)
}
func TestCheckEffectiveUser_FallbackUnauthorized(t *testing.T) {
// request someone else as non-admin -> error
r, w, userServiceMock := mockUserAwareRequest("user2", "user1")
user, err := CheckEffectiveUser(w, r, userServiceMock, "current")
assert.NotNil(t, err)
assert.Nil(t, user)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 0)
}
func TestCheckEffectiveUser_FallbackEmpty(t *testing.T) {
// request none -> return myself
r, w, userServiceMock := mockUserAwareRequest("", "user1")
user, err := CheckEffectiveUser(w, r, userServiceMock, "current")
assert.Nil(t, err)
assert.Equal(t, "user1", user.ID)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 0)
}
func TestCheckEffectiveUser_FallbackUnauthenticated(t *testing.T) {
// request anyone without authentication -> error
r, w, userServiceMock := mockUserAwareRequest("user1", "")
user, err := CheckEffectiveUser(w, r, userServiceMock, "current")
assert.NotNil(t, err)
assert.Nil(t, user)
userServiceMock.AssertNumberOfCalls(t, "GetUserById", 0)
}
func mockUserAwareRequest(requestedUser, authorizedUser string) (*http.Request, http.ResponseWriter, *mocks.UserServiceMock) {
testUser := models.User{
ID: authorizedUser,
IsAdmin: authorizedUser == "admin",
}
testPrincipal := middlewares.PrincipalContainer{}
if authorizedUser != "" {
testPrincipal.SetPrincipal(&testUser)
}
r := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:3000/api/%s/data", requestedUser), nil)
r = mux.SetURLVars(r, map[string]string{"user": requestedUser})
r = r.WithContext(context.WithValue(r.Context(), "principal", &testPrincipal))
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", "user1").Return(&models.User{ID: "user1"}, nil)
userServiceMock.On("GetUserById", "user2").Return(&models.User{ID: "user2"}, nil)
userServiceMock.On("GetUserById", "admin").Return(&models.User{ID: "admin"}, nil)
return r, httptest.NewRecorder(), userServiceMock
}

View File

@ -10,6 +10,7 @@
const fs = require('fs')
const path = require('path')
const { Collection } = require('@iconify/json-tools')
const { locate } = require("@iconify/json");
let icons = [
'fxemoji:key',
@ -70,7 +71,13 @@ let icons = [
'mdi:language-rust',
'mdi:language-swift',
'mdi:language-typescript',
'mdi:language-markdown',
'mdi:vuejs',
'mdi:react',
'mdi:code-json',
'mdi:bash',
'twemoji:frowning-face',
'ci:dot-03-m'
]
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
@ -102,7 +109,7 @@ icons.forEach(icon => {
let code = ''
Object.keys(filtered).forEach(prefix => {
let collection = new Collection()
if (!collection.loadIconifyCollection(prefix)) {
if (!collection.loadFromFile(locate(prefix))) {
console.error('Error loading collection', prefix)
return
}

View File

@ -0,0 +1,90 @@
#!/bin/python
# Setup:
# pip install requests tqdm
import argparse
import base64
import csv
import datetime
import logging
import os.path
import sys
from typing import Tuple, Any, Dict, List, Generator
from urllib.parse import urlparse
from tqdm import tqdm
import requests
# globals
http: requests.Session = requests.Session()
api_url: str = 'https://wakapi.dev/api'
Heartbeats = List[Dict[str, Any]]
model_keys: List[str] = ['branch', 'category', 'entity', 'is_write', 'language', 'project', 'time', 'type', 'user_id', 'machine_name_id', 'user_agent_id', 'created_at']
def fetch_total_range(min_date: datetime.date, max_date: datetime.date) -> Tuple[datetime.date, datetime.date]:
r = http.get(f'{api_url}/compat/wakatime/v1/users/current/all_time_since_today')
r.raise_for_status()
data = r.json()
start_date: datetime.date = datetime.date.fromisoformat(data['data']['range']['start_date'])
end_date: datetime.date = datetime.date.fromisoformat(data['data']['range']['end_date'])
return max([start_date, min_date]), min([end_date, max_date])
def fetch_all_heartbeats(start: datetime.date, end: datetime.date) -> Generator[Heartbeats, None, None]:
date_range: List[datetime.date] = [start + datetime.timedelta(days=x) for x in range(0, (end - start).days + 2)]
for date in tqdm(date_range):
yield fetch_heartbeats(date)
def fetch_heartbeats(date: datetime.date) -> Heartbeats:
r = http.get(f'{api_url}/compat/wakatime/v1/users/current/heartbeats', params=dict(date=date.isoformat()))
r.raise_for_status()
return r.json()['data']
def run(from_date: datetime.date, to_date: datetime.date, out_file: str):
total_range: Tuple[datetime.date, datetime.date] = fetch_total_range(from_date, to_date)
with open(out_file, 'w') as f:
w: csv.DictWriter = csv.DictWriter(f, model_keys, extrasaction='ignore')
w.writeheader()
for heartbeats in fetch_all_heartbeats(*total_range):
for hb in heartbeats:
w.writerow(hb)
def parse_args():
parser = argparse.ArgumentParser(description='Script to download raw heartbeats from Wakapi API')
parser.add_argument('--api_key', required=True, help='Wakapi API key')
parser.add_argument('--url', required=False, default=api_url, help='Wakapi instance API URL (without trailing slash)')
parser.add_argument('--from', default='1970-01-01', type=datetime.date.fromisoformat, help='Date range start')
parser.add_argument('--to', default=datetime.date.today().isoformat(), type=datetime.date.fromisoformat, help='Date range end')
parser.add_argument('--output', '-o', default='wakapi_heartbeats.csv', help='CSV file to save heartbeats to')
return parser.parse_args()
def init_http(api_key: str):
global api_url
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
http.headers.update({'Authorization': f'Basic {encoded_key}'})
api_url = args.url
if __name__ == '__main__':
args = parse_args()
if os.path.exists(args.output):
logging.error('output file already existing, please delete or choose a different path')
sys.exit(1)
if 'wakatime.com' in urlparse(args.url):
logging.warning('warning: this script is not perfectly compatible with wakatime')
init_http(args.api_key)
run(getattr(args, 'from'), getattr(args, 'to'), args.output)

55
scripts/email_checker.go Normal file
View File

@ -0,0 +1,55 @@
package main
// Usage example:
// cat emails.txt go run email_checker.go > result.txt
import (
"bufio"
"fmt"
"log"
"net"
"os"
"regexp"
"strings"
)
const MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"
var mailRegex *regexp.Regexp
func init() {
mailRegex = regexp.MustCompile(MailPattern)
}
func CheckEmailMX(email string) bool {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return false
}
records, err := net.LookupMX(parts[1])
return len(records) > 0 && err == nil
}
func ValidateEmail(email string) bool {
return mailRegex.Match([]byte(email)) && CheckEmailMX(email)
}
func main() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
email := scanner.Text()
if email == "" {
return
}
if ValidateEmail(email) {
fmt.Printf("[+] %s\n", email)
} else {
fmt.Printf("[-] %s\n", email)
}
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}

58
scripts/send_mail.py Normal file
View File

@ -0,0 +1,58 @@
import argparse
import time
import requests
from tqdm import tqdm
from typing import List, Tuple, Any, Dict
http: requests.Session = requests.Session()
def read_recipients(file_path: str) -> List[str]:
with open(file_path, 'r') as f:
return [r.strip() for r in f.readlines()]
def read_mail_content(file_path: str) -> Tuple[str, bool]:
with open(file_path, 'r') as f:
content = f.read()
return content, file_path.lower().endswith('.html')
def send(recipient: str, subject: str, content: str, is_html: bool = False, base_url: str = 'https://mailwhale.dev'):
payload: Dict[str, Any] = {
'to': [recipient],
'subject': subject,
}
if is_html:
payload['html'] = content
else:
payload['text'] = content
r = http.post(f'{base_url}/api/mail', json=payload)
r.raise_for_status()
def run(args):
content, is_html = read_mail_content(args.content)
for recipient in tqdm(read_recipients(args.recipients)):
send(recipient, args.subject, content, is_html, args.mw_url)
time.sleep(args.throttle)
def parse_arguments():
parser = argparse.ArgumentParser(description='Script to send mass mail to a list of recipients through MailWhale')
parser.add_argument('-r', '--recipients', required=True, type=str, help='path to line-separated file containing list of recipient addresses')
parser.add_argument('-c', '--content', required=True, type=str, help='path to text- or html file containing the mail content')
parser.add_argument('-s', '--subject', required=True, type=str, help='mail subject')
parser.add_argument('--mw_client_id', required=True, type=str, help='mailwhale client id')
parser.add_argument('--mw_client_secret', required=True, type=str, help='mailwhale client secret')
parser.add_argument('--mw_url', default='https://mailwhale.dev', type=str, help='mailwhale base url')
parser.add_argument('-t', '--throttle', default=5, type=int, help='seconds to wait between every mail')
return parser.parse_args()
if __name__ == '__main__':
args = parse_arguments()
http.auth = (args.mw_client_id, args.mw_client_secret)
run(args)

View File

@ -4,12 +4,11 @@ import (
"errors"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"runtime"
"sync"
"time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
@ -25,6 +24,8 @@ type AggregationService struct {
summaryService ISummaryService
heartbeatService IHeartbeatService
inProgress datastructure.Set[string]
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -34,6 +35,8 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
summaryService: summaryService,
heartbeatService: heartbeatService,
inProgress: datastructure.NewSet[string](),
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
}
}
@ -45,58 +48,23 @@ type AggregationJob struct {
// Schedule a job to (re-)generate summaries every day shortly after midnight
func (srv *AggregationService) Schedule() {
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
s.StartBlocking()
logbuch.Info("scheduling summary aggregation")
if _, err := srv.queueDefault.DispatchCron(func() {
if err := srv.AggregateSummaries(datastructure.NewSet[string]()); err != nil {
config.Log().Error("failed to generate summaries, %v", err)
}
}, srv.config.App.GetAggregationTimeCron()); err != nil {
config.Log().Error("failed to schedule summary generation, %v", err)
}
}
func (srv *AggregationService) Run(userIds datastructure.Set[string]) error {
func (srv *AggregationService) AggregateSummaries(userIds datastructure.Set[string]) error {
if err := srv.lockUsers(userIds); err != nil {
return err
}
defer srv.unlockUsers(userIds)
jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.summaryWorker(jobs, summaries)
}
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
go srv.persistWorker(summaries)
}
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
return srv.trigger(jobs, userIds)
}
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
} else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary
}
}
}
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil {
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds datastructure.Set[string]) error {
logbuch.Info("generating summaries")
// Get a map from user ids to the time of their latest summary or nil if none exists yet
@ -119,6 +87,20 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
firstUserHeartbeatLookup[e.User] = e.Time
}
// Dispatch summary generation jobs
jobs := make(chan *AggregationJob)
defer close(jobs)
go func() {
for jobRef := range jobs {
job := *jobRef
if err := srv.queueWorkers.Dispatch(func() {
srv.process(job)
}); err != nil {
config.Log().Error("failed to dispatch summary generation job for user '%s'", job.UserID)
}
}
}()
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
@ -141,24 +123,15 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
return nil
}
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
if srv.inProgress.Contain(uid) {
return errors.New("aggregation already in progress for at least of the request users")
func (srv *AggregationService) process(job AggregationJob) {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
} else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
if err := srv.summaryService.Insert(summary); err != nil {
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
}
}
srv.inProgress = srv.inProgress.Union(userIds)
return nil
}
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
srv.inProgress.Delete(uid)
}
}
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
@ -189,6 +162,26 @@ func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob
}
}
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
if srv.inProgress.Contain(uid) {
return errors.New("aggregation already in progress for at least of the request users")
}
}
srv.inProgress = srv.inProgress.Union(userIds)
return nil
}
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
srv.inProgress.Delete(uid)
}
}
func getStartOfToday() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location())

View File

@ -192,6 +192,11 @@ func (srv *HeartbeatService) DeleteByUser(user *models.User) error {
return srv.repository.DeleteByUser(user)
}
func (srv *HeartbeatService) DeleteByUserBefore(user *models.User, t time.Time) error {
go srv.cache.Flush()
return srv.repository.DeleteByUserBefore(user, t)
}
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
if err != nil {

86
services/housekeeping.go Normal file
View File

@ -0,0 +1,86 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
)
type HousekeepingService struct {
config *config.Config
userSrvc IUserService
heartbeatSrvc IHeartbeatService
summarySrvc ISummaryService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewHousekeepingService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService) *HousekeepingService {
return &HousekeepingService{
config: config.Get(),
userSrvc: userService,
heartbeatSrvc: heartbeatService,
summarySrvc: summaryService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueHousekeeping),
}
}
func (s *HousekeepingService) Schedule() {
if s.config.App.DataRetentionMonths <= 0 {
return
}
logbuch.Info("scheduling data cleanup")
_, err := s.queueDefault.DispatchCron(func() {
// fetch all users
users, err := s.userSrvc.GetAll()
if err != nil {
config.Log().Error("failed to get users for data cleanup, %v", err)
return
}
// schedule jobs
for _, u := range users {
// don't clean data for subscribed users or when they otherwise have unlimited data access
if u.MinDataAge().IsZero() {
continue
}
user := *u
s.queueWorkers.Dispatch(func() {
if err := s.CleanUserDataBefore(&user, user.MinDataAge()); err != nil {
config.Log().Error("failed to clear old user data for '%s'", user.ID)
}
})
}
}, s.config.App.DataCleanupTime)
if err != nil {
config.Log().Error("failed to dispatch data cleanup jobs, %v", err)
}
}
func (s *HousekeepingService) CleanUserDataBefore(user *models.User, before time.Time) error {
logbuch.Warn("cleaning up user data for '%s' older than %v", user.ID, before)
if s.config.App.DataCleanupDryRun {
logbuch.Info("skipping actual data deletion for '%v', because this is just a dry run", user.ID)
return nil
}
// clear old heartbeats
if err := s.heartbeatSrvc.DeleteByUserBefore(user, before); err != nil {
return err
}
// clear old summaries
logbuch.Info("clearing summaries for user '%s' older than %v", user.ID, before)
if err := s.summarySrvc.DeleteByUserBefore(user.ID, before); err != nil {
return err
}
return nil
}

View File

@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
"time"
"github.com/emvi/logbuch"
@ -29,19 +31,25 @@ const (
)
type WakatimeHeartbeatImporter struct {
ApiKey string
ApiKey string
httpClient *http.Client
queue *artifex.Dispatcher
}
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
return &WakatimeHeartbeatImporter{
ApiKey: apiKey,
ApiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
queue: config.GetQueue(config.QueueImports),
}
}
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
out := make(chan *models.Heartbeat)
go func(user *models.User, out chan *models.Heartbeat) {
process := func(user *models.User, minFrom time.Time, maxTo time.Time, out chan *models.Heartbeat) {
logbuch.Info("running wakatime import for user '%s'", user.ID)
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
startDate, endDate, err := w.fetchRange(baseUrl)
@ -57,14 +65,20 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
endDate = maxTo
}
userAgents, err := w.fetchUserAgents(baseUrl)
if err != nil {
userAgents := map[string]*wakatime.UserAgentEntry{}
if data, err := w.fetchUserAgents(baseUrl); err == nil {
userAgents = data
} else if strings.Contains(baseUrl, "wakatime.com") {
// when importing from wakatime, resolving user agents is mandatorily required
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return
}
machinesNames, err := w.fetchMachineNames(baseUrl)
if err != nil {
machinesNames := map[string]*wakatime.MachineEntry{}
if data, err := w.fetchMachineNames(baseUrl); err == nil {
machinesNames = data
} else if strings.Contains(baseUrl, "wakatime.com") {
// when importing from wakatime, resolving machine names is mandatorily required
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return
}
@ -88,7 +102,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
if err != nil {
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err)
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - %v", d, user.ID, err)
}
for _, h := range heartbeats {
@ -100,7 +114,18 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
}
}(d)
}
}(user, out)
}
if minDataAge := user.MinDataAge(); minFrom.Before(minDataAge) {
logbuch.Info("wakatime data import for user '%s' capped to [%v, &v]", user.ID, minDataAge, maxTo)
}
logbuch.Info("scheduling wakatime import for user '%s' (interval [%v, %v])", user.ID, minFrom, maxTo)
if err := w.queue.Dispatch(func() {
process(user, minFrom, maxTo, out)
}); err != nil {
config.Log().Error("failed to dispatch wakatime import job for user '%s', %v", user.ID, err)
}
return out
}
@ -112,8 +137,6 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
if err != nil {
return nil, err
@ -123,12 +146,13 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
q.Add("date", day)
req.URL.RawQuery = q.Encode()
res, err := httpClient.Do(w.withHeaders(req))
res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
} else if res.StatusCode >= 400 {
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
}
defer res.Body.Close()
var heartbeatsData wakatime.HeartbeatsViewModel
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
@ -141,8 +165,6 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
// https://wakatime.com/api/v1/users/current/all_time_since_today
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
notime := time.Time{}
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
@ -150,7 +172,7 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
return notime, notime, err
}
res, err := httpClient.Do(w.withHeaders(req))
res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil {
return notime, notime, err
}
@ -177,8 +199,6 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
// https://wakatime.com/api/v1/users/current/user_agents
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
userAgents := make(map[string]*wakatime.UserAgentEntry)
for page := 1; ; page++ {
@ -188,10 +208,11 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
defer res.Body.Close()
var userAgentsData wakatime.UserAgentsViewModel
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
@ -228,6 +249,7 @@ func (w *WakatimeHeartbeatImporter) fetchMachineNames(baseUrl string) (map[strin
if err != nil {
return nil, err
}
defer res.Body.Close()
var machineData wakatime.MachineViewModel
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
@ -259,9 +281,17 @@ func mapHeartbeat(
) *models.Heartbeat {
ua := userAgents[entry.UserAgentId]
if ua == nil {
ua = &wakatime.UserAgentEntry{
Editor: "unknown",
Os: "unknown",
// try to parse id as an actual user agent string (as returned by wakapi)
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
ua = &wakatime.UserAgentEntry{
Editor: editor,
Os: opSys,
}
} else {
ua = &wakatime.UserAgentEntry{
Editor: "unknown",
Os: "unknown",
}
}
}

View File

@ -22,6 +22,10 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
return srv.repository.GetString(key)
}
func (srv *KeyValueService) GetByPrefix(prefix string) ([]*models.KeyStringValue, error) {
return srv.repository.Search(prefix + "%")
}
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
kv, err := srv.repository.GetString(key)
if err != nil {

View File

@ -2,14 +2,16 @@ package services
import (
"github.com/emvi/logbuch"
"github.com/go-co-op/gocron"
"github.com/leandro-lugaresi/hub"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"reflect"
"strconv"
"strings"
"time"
)
@ -21,6 +23,8 @@ type LeaderboardService struct {
repository repositories.ILeaderboardRepository
summaryService ISummaryService
userService IUserService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
@ -31,6 +35,8 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
repository: leaderboardRepo,
summaryService: summaryService,
userService: userService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
}
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
@ -47,7 +53,7 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
if user.PublicLeaderboard && !exists {
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
srv.Run([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
srv.ComputeLeaderboard([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
} else if !user.PublicLeaderboard && exists {
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
if err := srv.repository.DeleteByUser(user.ID); err != nil {
@ -61,23 +67,26 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
return srv
}
func (srv *LeaderboardService) ScheduleDefault() {
runAllUsers := func(interval *models.IntervalKey, by []uint8) {
func (srv *LeaderboardService) Schedule() {
logbuch.Info("scheduling leaderboard generation")
generate := func() {
users, err := srv.userService.GetAllByLeaderboard(true)
if err != nil {
config.Log().Error("failed to get users for leaderboard generation - %v", err)
return
}
srv.Run(users, interval, by)
srv.ComputeLeaderboard(users, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.LeaderboardGenerationTime).Do(runAllUsers, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
s.StartBlocking()
for _, cronExp := range srv.config.App.GetLeaderboardGenerationTimeCron() {
if _, err := srv.queueDefault.DispatchCron(generate, cronExp); err != nil {
config.Log().Error("failed to schedule leaderboard generation (%s), %v", cronExp, err)
}
}
}
func (srv *LeaderboardService) Run(users []*models.User, interval *models.IntervalKey, by []uint8) error {
func (srv *LeaderboardService) ComputeLeaderboard(users []*models.User, interval *models.IntervalKey, by []uint8) error {
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
for _, user := range users {
@ -125,25 +134,41 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
return count > 0, err
}
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
func (srv *LeaderboardService) CountUsers() (int64, error) {
// check cache
cacheKey := srv.getHash(interval, by)
cacheKey := "count_total"
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItem), nil
return cacheResult.(int64), nil
}
items, err := srv.repository.GetAllAggregatedByInterval(interval, by)
count, err := srv.repository.CountUsers()
if err != nil {
srv.cache.SetDefault(cacheKey, count)
}
return count, err
}
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *utils.PageParams, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, pageParams, resolveUsers)
}
func (srv *LeaderboardService) GetByIntervalAndUser(interval *models.IntervalKey, userId string, resolveUser bool) (models.Leaderboard, error) {
return srv.GetAggregatedByIntervalAndUser(interval, userId, nil, resolveUser)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, pageParams *utils.PageParams, resolveUsers bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, "", pageParams)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAllAggregatedByInterval(interval, by, pageParams.Limit(), pageParams.Offset())
if err != nil {
return nil, err
}
if resolveUsers {
a := models.Leaderboard(items).UserIDs()
println(a)
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
if err != nil {
config.Log().Error("failed to resolve users for leaderboard item - %v", err)
@ -160,8 +185,35 @@ func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.Interval
return items, nil
}
func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.IntervalKey, userId string, by *uint8, resolveUser bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, userId, nil)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAggregatedByUserAndInterval(userId, interval, by, 0, 0)
if err != nil {
return nil, err
}
if resolveUser {
u, err := srv.userService.GetUserById(userId)
if err != nil {
config.Log().Error("failed to resolve user for leaderboard item - %v", err)
} else {
for _, item := range items {
item.User = u
}
}
}
srv.cache.SetDefault(cacheKey, items)
return items, nil
}
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err
}
@ -180,7 +232,7 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model
}
func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) {
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err
}
@ -208,10 +260,13 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
return items, nil
}
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string {
k := strings.Join(*interval, "__")
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *utils.PageParams) string {
k := strings.Join(*interval, "__") + "__" + user
if by != nil && !reflect.ValueOf(by).IsNil() {
k += "__" + models.GetEntityColumn(*by)
}
if pageParams != nil {
k += "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
}
return k
}

View File

@ -3,6 +3,7 @@ package mail
import (
"bytes"
"fmt"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
@ -18,10 +19,12 @@ const (
tplNameImportNotification = "import_finished"
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
tplNameReport = "report"
tplNameSubscriptionNotification = "subscription_expiring"
subjectPasswordReset = "Wakapi - Password Reset"
subjectImportNotification = "Wakapi - Data Import Finished"
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
subjectReport = "Wakapi - Report from %s"
subjectSubscriptionNotification = "Wakapi - Subscription expiring / expired"
)
type SendingService interface {
@ -115,7 +118,25 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: fmt.Sprintf(subjectReport, utils.FormatDateHuman(time.Now().In(recipient.TZ()))),
Subject: fmt.Sprintf(subjectReport, helpers.FormatDateHuman(time.Now().In(recipient.TZ()))),
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
}
func (m *MailService) SendSubscriptionNotification(recipient *models.User, hasExpired bool) error {
tpl, err := m.getSubscriptionNotificationTemplate(SubscriptionNotificationTplData{
PublicUrl: m.config.Server.PublicUrl,
DataRetentionMonths: m.config.App.DataRetentionMonths,
HasExpired: hasExpired,
})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectSubscriptionNotification,
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
@ -153,6 +174,14 @@ func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, erro
return &rendered, nil
}
func (m *MailService) getSubscriptionNotificationTemplate(data SubscriptionNotificationTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := m.templates[m.fmtName(tplNameSubscriptionNotification)].Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func (m *MailService) fmtName(name string) string {
return fmt.Sprintf("%s.tpl.html", name)
}

View File

@ -42,7 +42,12 @@ func (s *SMTPSendingService) Send(mail *models.Mail) error {
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(nil); err != nil {
return err
errCode := err.(*smtp.SMTPError).Code
if errCode == 503 {
// TLS already active
} else {
return err
}
}
}
if s.auth != nil {

View File

@ -20,3 +20,9 @@ type WakatimeFailureNotificationNotificationTplData struct {
type ReportTplData struct {
Report *models.Report
}
type SubscriptionNotificationTplData struct {
PublicUrl string
HasExpired bool
DataRetentionMonths int
}

View File

@ -1,104 +1,269 @@
package services
import (
"fmt"
"github.com/duke-git/lancet/v2/slice"
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"runtime"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"strconv"
"strings"
"sync"
"time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
const (
countUsersEvery = 1 * time.Hour
computeOldestDataEvery = 6 * time.Hour
notifyExpiringSubscriptionsEvery = 12 * time.Hour
)
const (
notifyBeforeSubscriptionExpiry = 7 * 24 * time.Hour
)
var countLock = sync.Mutex{}
var firstDataLock = sync.Mutex{}
type MiscService struct {
config *config.Config
userService IUserService
summaryService ISummaryService
keyValueService IKeyValueService
config *config.Config
userService IUserService
heartbeatService IHeartbeatService
summaryService ISummaryService
keyValueService IKeyValueService
mailService IMailService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
queueMails *artifex.Dispatcher
}
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
func NewMiscService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService, keyValueService IKeyValueService, mailService IMailService) *MiscService {
return &MiscService{
config: config.Get(),
userService: userService,
summaryService: summaryService,
keyValueService: keyValueService,
config: config.Get(),
userService: userService,
heartbeatService: heartbeatService,
summaryService: summaryService,
keyValueService: keyValueService,
mailService: mailService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
queueMails: config.GetQueue(config.QueueMails),
}
}
type CountTotalTimeJob struct {
UserID string
NumJobs int
}
type CountTotalTimeResult struct {
UserId string
Total time.Duration
}
func (srv *MiscService) ScheduleCountTotalTime() {
s := gocron.NewScheduler(time.Local)
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime)
s.StartBlocking()
}
func (srv *MiscService) runCountTotalTime() error {
users, err := srv.userService.GetAll()
if err != nil {
return err
func (srv *MiscService) Schedule() {
logbuch.Info("scheduling total time counting")
if _, err := srv.queueDefault.DispatchEvery(srv.CountTotalTime, countUsersEvery); err != nil {
config.Log().Error("failed to schedule user counting jobs, %v", err)
}
jobs := make(chan *CountTotalTimeJob, len(users))
results := make(chan *CountTotalTimeResult, len(users))
logbuch.Info("scheduling first data computing")
if _, err := srv.queueDefault.DispatchEvery(srv.ComputeOldestHeartbeats, computeOldestDataEvery); err != nil {
config.Log().Error("failed to schedule first data computing jobs, %v", err)
}
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
if srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
logbuch.Info("scheduling subscription notifications")
if _, err := srv.queueDefault.DispatchEvery(srv.NotifyExpiringSubscription, notifyExpiringSubscriptionsEvery); err != nil {
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
}
}
close(jobs)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results)
// run once initially for a fresh instance
if !srv.existsUsersTotalTime() {
if err := srv.queueDefault.Dispatch(srv.CountTotalTime); err != nil {
config.Log().Error("failed to dispatch user counting jobs, %v", err)
}
}
if !srv.existsUsersFirstData() {
if err := srv.queueDefault.Dispatch(srv.ComputeOldestHeartbeats); err != nil {
config.Log().Error("failed to dispatch first data computing jobs, %v", err)
}
}
if !srv.existsSubscriptionNotifications() && srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
if err := srv.queueDefault.Dispatch(srv.NotifyExpiringSubscription); err != nil {
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
}
}
}
func (srv *MiscService) CountTotalTime() {
logbuch.Info("counting users total time")
if ok := countLock.TryLock(); !ok {
config.Log().Warn("couldn't acquire lock for counting users total time, job is still pending")
}
defer countLock.Unlock()
users, err := srv.userService.GetAll()
if err != nil {
config.Log().Error("failed to fetch users for time counting, %v", err)
return
}
var totalTime = atomic.NewDuration(0)
var pendingJobs sync.WaitGroup
pendingJobs.Add(len(users))
for _, u := range users {
user := *u
if err := srv.queueWorkers.Dispatch(func() {
defer pendingJobs.Done()
totalTime.Add(srv.countUserTotalTime(user.ID))
}); err != nil {
config.Log().Error("failed to enqueue counting job for user '%s'", user.ID)
pendingJobs.Done()
}
}
// persist
var i int
var total time.Duration
for i = 0; i < len(users); i++ {
result := <-results
total += result.Total
}
close(results)
go func(wg *sync.WaitGroup) {
if !utils.WaitTimeout(&pendingJobs, 2*countUsersEvery) {
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: totalTime.Load().String(),
}); err != nil {
config.Log().Error("failed to save total time count: %v", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
logbuch.Error("failed to save total time count: %v", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(i),
}); err != nil {
logbuch.Error("failed to save total users count: %v", err)
}
return nil
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(len(users)),
}); err != nil {
config.Log().Error("failed to save total users count: %v", err)
}
} else {
config.Log().Error("waiting for user counting jobs timed out")
}
}(&pendingJobs)
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil {
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
} else {
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
func (srv *MiscService) ComputeOldestHeartbeats() {
logbuch.Info("computing users' first data")
if err := srv.queueWorkers.Dispatch(func() {
if ok := firstDataLock.TryLock(); !ok {
config.Log().Warn("couldn't acquire lock for computing users' first data, job is still pending")
return
}
defer firstDataLock.Unlock()
results, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
config.Log().Error("failed to compute users' first data, %v", err)
return
}
for _, entry := range results {
if entry.Time.T().IsZero() {
continue
}
kvKey := fmt.Sprintf("%s_%s", config.KeyFirstHeartbeat, entry.User)
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: kvKey,
Value: entry.Time.T().Format(time.RFC822Z),
}); err != nil {
config.Log().Error("failed to save user's first heartbeat time: %v", err)
}
}
}); err != nil {
config.Log().Error("failed to enqueue computing first data for user, %v", err)
}
}
// NotifyExpiringSubscription sends a reminder e-mail to all users, notifying them if their subscription has expired or is about to, given these conditions:
// - Data cleanup is enabled on the server (non-zero retention time)
// - Subscriptions are enabled on the server (aka. users can do something about their old data getting cleaned up)
// - User has an e-mail address configured
// - User's subscription has expired or is about to expire soon
// - The user has gotten no such e-mail before recently
// Note: only one mail will be sent for either "expired" or "about to expire" state.
func (srv *MiscService) NotifyExpiringSubscription() {
if srv.config.App.DataRetentionMonths <= 0 || !srv.config.Subscriptions.Enabled {
return
}
logbuch.Info("notifying users about soon to expire subscriptions")
users, err := srv.userService.GetAll()
if err != nil {
config.Log().Error("failed to fetch users for subscription notifications, %v", err)
return
}
var subscriptionReminders map[string][]*models.KeyStringValue
if result, err := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent); err == nil {
subscriptionReminders = slice.GroupWith[*models.KeyStringValue, string](result, func(kv *models.KeyStringValue) string {
return strings.TrimPrefix(kv.Key, config.KeySubscriptionNotificationSent+"_")
})
} else {
config.Log().Error("failed to fetch key-values for subscription notifications, %v", err)
return
}
for _, u := range users {
if u.HasActiveSubscription() && u.Email == "" {
config.Log().Warn("invalid state: user '%s' has active subscription but no e-mail address set", u.ID)
}
// skip users without e-mail address
// skip users who already received a notification before
// skip users who either never had a subscription before or intentionally deleted it
if _, ok := subscriptionReminders[u.ID]; ok || u.Email == "" || u.SubscribedUntil == nil {
continue
}
expired, expiredSince := u.SubscriptionExpiredSince()
if expired || (expiredSince < 0 && expiredSince*-1 <= notifyBeforeSubscriptionExpiry) {
srv.sendSubscriptionNotificationScheduled(u, expired)
}
}
}
func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false)
if err != nil {
config.Log().Error("failed to count total for user %s: %v", userId, err)
return 0
}
return result.TotalTime()
}
func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User, hasExpired bool) {
u := *user
srv.queueMails.Dispatch(func() {
logbuch.Info("sending subscription expiry notification mail to %s (expired: %v)", u.ID, hasExpired)
defer time.Sleep(10 * time.Second)
if err := srv.mailService.SendSubscriptionNotification(&u, hasExpired); err != nil {
config.Log().Error("failed to send subscription notification mail to user '%s', %v", u.ID, err)
return
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: fmt.Sprintf("%s_%s", config.KeySubscriptionNotificationSent, u.ID),
Value: time.Now().Format(time.RFC822Z),
}); err != nil {
config.Log().Error("failed to update subscription notification status key-value for user %s, %v", u.ID, err)
}
})
}
func (srv *MiscService) existsUsersTotalTime() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime)
return len(results) > 0
}
func (srv *MiscService) existsUsersFirstData() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat)
return len(results) > 0
}
func (srv *MiscService) existsSubscriptionNotifications() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent)
return len(results) > 0
}

View File

@ -1,21 +1,21 @@
package services
import (
"github.com/duke-git/lancet/v2/slice"
"github.com/emvi/logbuch"
"github.com/go-co-op/gocron"
"github.com/leandro-lugaresi/hub"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"math/rand"
"sync"
"time"
)
var reportLock = sync.Mutex{}
// delay between evey report generation task (to throttle email sending frequency)
const reportDelay = 10 * time.Second
// range for random offset to add / subtract when scheduling a new job
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
const offsetIntervalMin = 15
// past time range to cover in the report
const reportRange = 7 * 24 * time.Hour
type ReportService struct {
config *config.Config
@ -23,8 +23,9 @@ type ReportService struct {
summaryService ISummaryService
userService IUserService
mailService IMailService
scheduler *gocron.Scheduler
rand *rand.Rand
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
@ -34,80 +35,67 @@ func NewReportService(summaryService ISummaryService, userService IUserService,
summaryService: summaryService,
userService: userService,
mailService: mailService,
scheduler: gocron.NewScheduler(time.Local),
rand: rand.New(rand.NewSource(time.Now().Unix())),
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueReports),
}
srv.scheduler.StartAsync()
sub := srv.eventBus.Subscribe(0, config.EventUserUpdate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
srv.SyncSchedule(m.Fields[config.FieldPayload].(*models.User))
}
}(&sub)
return srv
}
func (srv *ReportService) Schedule() {
logbuch.Info("initializing report service")
logbuch.Info("scheduling report generation")
users, err := srv.userService.GetAllByReports(true)
if err != nil {
config.Log().Fatal("%v", err)
}
scheduleUserReport := func(u *models.User) {
if err := srv.queueWorkers.Dispatch(func() {
t0 := time.Now()
logbuch.Info("scheduling reports for %d users", len(users))
for _, u := range users {
srv.SyncSchedule(u)
}
}
if err := srv.SendReport(u, reportRange); err != nil {
config.Log().Error("failed to generate report for '%s', %v", u.ID, err)
}
// SyncSchedule syncs the currently active schedulers with the user's wish about whether or not to receive reports.
// Returns whether a scheduler is active after this operation has run.
func (srv *ReportService) SyncSchedule(u *models.User) bool {
reportLock.Lock()
defer reportLock.Unlock()
// unschedule
if !u.ReportsWeekly {
_ = srv.scheduler.RemoveByTag(u.ID)
logbuch.Info("disabled scheduled reports for user %s", u.ID)
return false
}
// schedule
if job := srv.getJobByTag(u.ID); job == nil && u.ReportsWeekly {
t, _ := time.ParseInLocation("15:04", srv.config.App.GetWeeklyReportTime(), u.TZ())
t = t.Add(time.Duration(srv.rand.Intn(offsetIntervalMin*60)) * time.Second)
if job, err := srv.scheduler.
SingletonMode().
Every(1).
Week().
Weekday(srv.config.App.GetWeeklyReportDay()).
At(t).
Tag(u.ID).
Do(srv.Run, u, 7*24*time.Hour); err != nil {
config.Log().Error("failed to schedule report job for user '%s' - %v", u.ID, err)
} else {
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
// make the job take at least reportDelay seconds
if diff := reportDelay - time.Now().Sub(t0); diff > 0 {
logbuch.Debug("waiting for %v before sending next report", diff)
time.Sleep(diff)
}
}); err != nil {
config.Log().Error("failed to dispatch report generation job for user '%s', %v", u.ID, err)
}
}
return u.ReportsWeekly
_, err := srv.queueDefault.DispatchCron(func() {
// fetch all users with reports enabled
users, err := srv.userService.GetAllByReports(true)
if err != nil {
config.Log().Error("failed to get users for report generation, %v", err)
return
}
// filter users who have their email set
users = slice.Filter[*models.User](users, func(i int, u *models.User) bool {
return u.Email != ""
})
// schedule jobs, throttled by one job per x seconds
logbuch.Info("scheduling report generation for %d users", len(users))
for _, u := range users {
scheduleUserReport(u)
}
}, srv.config.App.GetWeeklyReportCron())
if err != nil {
config.Log().Error("failed to dispatch report generation jobs, %v", err)
}
}
func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
func (srv *ReportService) SendReport(user *models.User, duration time.Duration) error {
if user.Email == "" {
logbuch.Warn("not generating report for '%s' as no e-mail address is set")
return nil
}
if !srv.SyncSchedule(user) {
logbuch.Info("reports for user '%s' were turned off in the meanwhile since last report job ran")
return nil
}
logbuch.Info("generating report for '%s'", user.ID)
end := time.Now().In(user.TZ())
start := time.Now().Add(-1 * duration)
@ -126,21 +114,10 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
}
if err := srv.mailService.SendReport(user, report); err != nil {
config.Log().Error("failed to send report for '%s' - %v", user.ID, err)
config.Log().Error("failed to send report for '%s', %v", user.ID, err)
return err
}
logbuch.Info("sent report to user '%s'", user.ID)
return nil
}
func (srv *ReportService) getJobByTag(tag string) *gocron.Job {
for _, j := range srv.scheduler.Jobs() {
for _, t := range j.Tags() {
if t == tag {
return j
}
}
}
return nil
}

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