mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
89 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
75cc071222 | ||
![]() |
e8310cfa69 | ||
![]() |
746608c062 | ||
![]() |
a1444bca8c | ||
![]() |
ef5b49ebd8 | ||
![]() |
fb5b2f52c7 | ||
![]() |
a49abfe0de | ||
![]() |
cf5a515952 | ||
![]() |
c1f1b05fa8 | ||
![]() |
9166c98df7 | ||
![]() |
bfd2832846 | ||
![]() |
814e74a41e | ||
![]() |
f755275309 | ||
![]() |
731598fa38 | ||
![]() |
8e521741f8 | ||
![]() |
3aac5e9062 | ||
![]() |
50c54685ec | ||
![]() |
dc0bcbe65d | ||
![]() |
bafbc34706 | ||
![]() |
8ca1404f8b | ||
![]() |
195755581b | ||
![]() |
8a94fef06b | ||
![]() |
ebcf87ea93 | ||
![]() |
0e83ab02fa | ||
![]() |
05ea05cdf4 | ||
![]() |
f39ecc46bd | ||
![]() |
333c1b5dd0 | ||
![]() |
52d45d4644 | ||
![]() |
f46f24f0be | ||
![]() |
9cce0ac2e1 | ||
![]() |
497046d0a4 | ||
![]() |
03af194385 | ||
![]() |
ad704cef5c | ||
![]() |
cd5c511474 | ||
![]() |
8a26e24081 | ||
![]() |
db6dde32cd | ||
![]() |
394215e53b | ||
![]() |
27586f3a54 | ||
![]() |
9e9e9fbef9 | ||
![]() |
bc9132f84d | ||
![]() |
e7b6a87153 | ||
![]() |
0a2cba647c | ||
![]() |
f5395e36ad | ||
![]() |
9f38246fe2 | ||
![]() |
5242df2b7d | ||
![]() |
10648d66ad | ||
![]() |
97fab3e109 | ||
![]() |
0f3b41c2dd | ||
![]() |
5ae7527b7b | ||
![]() |
2db065d47a | ||
![]() |
0e5c5a56d2 | ||
![]() |
a4b89d3a69 | ||
![]() |
aab9e98ebd | ||
![]() |
d4945c982f | ||
![]() |
964405f349 | ||
![]() |
21f6809f05 | ||
![]() |
c5fda02900 | ||
![]() |
10e432c185 | ||
![]() |
f121112d09 | ||
![]() |
c13fc96a16 | ||
![]() |
61f13fce20 | ||
![]() |
99e50b1062 | ||
![]() |
4ce75c2acb | ||
![]() |
fcca881cfc | ||
![]() |
e2ef54152d | ||
![]() |
ebe1836ac6 | ||
![]() |
b1a12a5759 | ||
![]() |
ae407fffca | ||
![]() |
94e0d06e5d | ||
![]() |
088bd17803 | ||
![]() |
2976203ecc | ||
![]() |
e75bd94531 | ||
![]() |
4cc8c21f67 | ||
![]() |
f182b804bb | ||
![]() |
e89ce076fd | ||
![]() |
ba81c07345 | ||
![]() |
9586dbf781 | ||
![]() |
c8ea1a503f | ||
![]() |
ebbc21f0b1 | ||
![]() |
6e5bc38e5e | ||
![]() |
9424c49760 | ||
![]() |
efd6ba36e3 | ||
![]() |
b1d7f87095 | ||
![]() |
ffbcfc7467 | ||
![]() |
41f6db8f34 | ||
![]() |
8a21be4306 | ||
![]() |
31ca4a1e02 | ||
![]() |
7cab2b0be7 | ||
![]() |
777997c883 |
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: ^1.18
|
go-version: ^1.19
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
run: go get
|
run: go get
|
||||||
|
|
||||||
- name: Unit Tests
|
- 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
|
- name: API Tests
|
||||||
run: |
|
run: |
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: ^1.18
|
go-version: ^1.19
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: ^1.18
|
go-version: ^1.19
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
@@ -101,3 +101,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v .
|
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
|
||||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Set up Go 1.x
|
- name: Set up Go 1.x
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: ^1.18
|
go-version: ^1.19
|
||||||
id: go
|
id: go
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,9 +5,10 @@ wakapi
|
|||||||
build
|
build
|
||||||
*.exe
|
*.exe
|
||||||
*.db
|
*.db
|
||||||
|
*.zip
|
||||||
config*.yml
|
config*.yml
|
||||||
!config.default.yml
|
!config.default.yml
|
||||||
!testing/config.testing.yml
|
!testing/config.*.yml
|
||||||
pkged.go
|
pkged.go
|
||||||
package-lock.json
|
package-lock.json
|
||||||
node_modules
|
node_modules
|
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.18-alpine AS build-env
|
FROM golang:1.19-alpine AS build-env
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||||
|
13
README.md
13
README.md
@@ -36,7 +36,7 @@ Installation instructions can be found below and in the [Wiki](https://github.co
|
|||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
* ✅ 100 % free and open-source
|
* ✅ Free and open-source
|
||||||
* ✅ Built by developers for developers
|
* ✅ Built by developers for developers
|
||||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||||
* ✅ Badges
|
* ✅ Badges
|
||||||
@@ -137,13 +137,17 @@ You can specify configuration options either via a config file (default: `config
|
|||||||
| YAML key / Env. variable | Default | Description |
|
| YAML key / Env. variable | Default | Description |
|
||||||
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
| `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.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` | `fri,18:00` | Week day and time at which to send e-mail reports |
|
| `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 * * 7` | 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.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.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.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.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.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.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_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_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||||
@@ -160,6 +164,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` |
|
| `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.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
|
||||||
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
|
| `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.user` /<br> `WAKAPI_DB_USER` | - | Database user |
|
||||||
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||||
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||||
@@ -307,7 +312,7 @@ Unit tests are supposed to test business logic on a fine-grained level. They are
|
|||||||
#### How to run
|
#### How to run
|
||||||
|
|
||||||
```bash
|
```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
|
### API tests
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
env: production
|
env: production
|
||||||
|
quick_start: false # whether to skip initial tasks on application startup, like summary generation
|
||||||
|
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
|
||||||
|
|
||||||
server:
|
server:
|
||||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||||
@@ -12,15 +14,20 @@ server:
|
|||||||
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
||||||
|
|
||||||
app:
|
app:
|
||||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
|
||||||
leaderboard_generation_time: '06:00;18:00' # 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: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
|
report_time_weekly: '0 0 18 * * 5' # time at which to fan out weekly reports (extended cron)
|
||||||
|
data_cleanup_time: '0 0 6 * * 7' # time at which to run old data cleanup (if enabled through data_retention_months)
|
||||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
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
|
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)
|
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
|
||||||
|
data_retention_months: -1 # maximum retention period on months for user data (heartbeats) (-1 for infinity)
|
||||||
custom_languages:
|
custom_languages:
|
||||||
vue: Vue
|
vue: Vue
|
||||||
jsx: JSX
|
jsx: JSX
|
||||||
|
tsx: TSX
|
||||||
|
cjs: JavaScript
|
||||||
|
ipynb: Python
|
||||||
svelte: Svelte
|
svelte: Svelte
|
||||||
|
|
||||||
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||||
@@ -31,6 +38,7 @@ app:
|
|||||||
db:
|
db:
|
||||||
host: # leave blank when using sqlite3
|
host: # leave blank when using sqlite3
|
||||||
port: # leave blank when using sqlite3
|
port: # leave blank when using sqlite3
|
||||||
|
socket: # alternative to db.host (leave blank when using sqlite3)
|
||||||
user: # leave blank when using sqlite3
|
user: # leave blank when using sqlite3
|
||||||
password: # leave blank when using sqlite3
|
password: # leave blank when using sqlite3
|
||||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||||
@@ -38,7 +46,7 @@ db:
|
|||||||
charset: utf8mb4 # only used for mysql connections
|
charset: utf8mb4 # only used for mysql connections
|
||||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||||
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||||
|
|
||||||
security:
|
security:
|
||||||
password_salt: # change this
|
password_salt: # change this
|
||||||
@@ -54,6 +62,15 @@ sentry:
|
|||||||
sample_rate: 0.75 # probability of tracing a request
|
sample_rate: 0.75 # probability of tracing a request
|
||||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||||
|
|
||||||
|
# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments
|
||||||
|
subscriptions:
|
||||||
|
enabled: false
|
||||||
|
expiry_notifications: true
|
||||||
|
stripe_api_key:
|
||||||
|
stripe_secret_key:
|
||||||
|
stripe_endpoint_secret:
|
||||||
|
standard_price_id:
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
||||||
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
||||||
@@ -72,6 +89,3 @@ mail:
|
|||||||
url:
|
url:
|
||||||
client_id:
|
client_id:
|
||||||
client_secret:
|
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
|
|
244
config/config.go
244
config/config.go
@@ -4,10 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,9 +16,8 @@ import (
|
|||||||
"github.com/gorilla/securecookie"
|
"github.com/gorilla/securecookie"
|
||||||
"github.com/jinzhu/configor"
|
"github.com/jinzhu/configor"
|
||||||
"github.com/muety/wakapi/data"
|
"github.com/muety/wakapi/data"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/utils"
|
||||||
uuid "github.com/satori/go.uuid"
|
uuid "github.com/satori/go.uuid"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -30,8 +30,12 @@ const (
|
|||||||
KeyLatestTotalTime = "latest_total_time"
|
KeyLatestTotalTime = "latest_total_time"
|
||||||
KeyLatestTotalUsers = "latest_total_users"
|
KeyLatestTotalUsers = "latest_total_users"
|
||||||
KeyLastImportImport = "last_import"
|
KeyLastImportImport = "last_import"
|
||||||
|
KeyFirstHeartbeat = "first_heartbeat"
|
||||||
|
KeySubscriptionNotificationSent = "sub_reminder"
|
||||||
KeyNewsbox = "newsbox"
|
KeyNewsbox = "newsbox"
|
||||||
|
|
||||||
|
SessionKeyDefault = "default"
|
||||||
|
|
||||||
SimpleDateFormat = "2006-01-02"
|
SimpleDateFormat = "2006-01-02"
|
||||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
@@ -65,15 +69,18 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
|||||||
var env string
|
var env string
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
|
||||||
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"06:00;18:00" env:"WAKAPI_LEADERBOARD_GENERATION_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:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
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 * * 7" env:"WAKAPI_DATA_CLEANUP_TIME"`
|
||||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
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"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||||
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
|
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
|
||||||
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||||
|
DataRetentionMonths int `yaml:"data_retention_months" default:"-1" env:"WAKAPI_DATA_RETENTION_MONTHS"`
|
||||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
|
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"`
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
@@ -87,10 +94,12 @@ type securityConfig struct {
|
|||||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||||
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
|
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
|
||||||
SecureCookie *securecookie.SecureCookie `yaml:"-"`
|
SecureCookie *securecookie.SecureCookie `yaml:"-"`
|
||||||
|
SessionKey []byte `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type dbConfig struct {
|
type dbConfig struct {
|
||||||
Host string `env:"WAKAPI_DB_HOST"`
|
Host string `env:"WAKAPI_DB_HOST"`
|
||||||
|
Socket string `env:"WAKAPI_DB_SOCKET"`
|
||||||
Port uint `env:"WAKAPI_DB_PORT"`
|
Port uint `env:"WAKAPI_DB_PORT"`
|
||||||
User string `env:"WAKAPI_DB_USER"`
|
User string `env:"WAKAPI_DB_USER"`
|
||||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||||
@@ -98,6 +107,7 @@ type dbConfig struct {
|
|||||||
Dialect string `yaml:"-"`
|
Dialect string `yaml:"-"`
|
||||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||||
|
DSN string `yaml:"DSN" default:"" env:"WAKAPI_DB_DSN"`
|
||||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||||
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||||
@@ -115,6 +125,16 @@ type serverConfig struct {
|
|||||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type subscriptionsConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
|
||||||
|
ExpiryNotifications bool `yaml:"expiry_notifications" default:"true" env:"WAKAPI_SUBSCRIPTIONS_EXPIRY_NOTIFICATIONS"`
|
||||||
|
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
|
||||||
|
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
|
||||||
|
StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"`
|
||||||
|
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
|
||||||
|
StandardPrice string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type sentryConfig struct {
|
type sentryConfig struct {
|
||||||
Dsn string `env:"WAKAPI_SENTRY_DSN"`
|
Dsn string `env:"WAKAPI_SENTRY_DSN"`
|
||||||
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
|
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
|
||||||
@@ -154,6 +174,7 @@ type Config struct {
|
|||||||
Security securityConfig
|
Security securityConfig
|
||||||
Db dbConfig
|
Db dbConfig
|
||||||
Server serverConfig
|
Server serverConfig
|
||||||
|
Subscriptions subscriptionsConfig
|
||||||
Sentry sentryConfig
|
Sentry sentryConfig
|
||||||
Mail mailConfig
|
Mail mailConfig
|
||||||
}
|
}
|
||||||
@@ -174,7 +195,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
|
|||||||
MaxAge: maxAge,
|
MaxAge: maxAge,
|
||||||
Secure: !c.Security.InsecureCookies,
|
Secure: !c.Security.InsecureCookies,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,68 +207,97 @@ func (c *Config) UseTLS() bool {
|
|||||||
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
|
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
|
||||||
switch dbDialect {
|
|
||||||
default:
|
|
||||||
return func(db *gorm.DB) error {
|
|
||||||
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||||
return cloneStringMap(c.CustomLanguages, false)
|
return utils.CloneStringMap(c.CustomLanguages, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) GetLanguageColors() map[string]string {
|
func (c *appConfig) GetLanguageColors() map[string]string {
|
||||||
return cloneStringMap(c.Colors["languages"], true)
|
return utils.CloneStringMap(c.Colors["languages"], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) GetEditorColors() map[string]string {
|
func (c *appConfig) GetEditorColors() map[string]string {
|
||||||
return cloneStringMap(c.Colors["editors"], true)
|
return utils.CloneStringMap(c.Colors["editors"], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) GetOSColors() map[string]string {
|
func (c *appConfig) GetOSColors() map[string]string {
|
||||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
return utils.CloneStringMap(c.Colors["operating_systems"], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
|
func (c *appConfig) GetAggregationTimeCron() string {
|
||||||
s := strings.Split(c.ReportTimeWeekly, ",")[0]
|
if strings.Contains(c.AggregationTime, ":") {
|
||||||
return parseWeekday(s)
|
// old gocron format, e.g. "15:04"
|
||||||
|
timeParts := strings.Split(c.AggregationTime, ":")
|
||||||
|
h, err := strconv.Atoi(timeParts[0])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := strconv.Atoi(timeParts[1])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("0 %d %d * * *", m, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.CronPadToSecondly(c.AggregationTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) GetWeeklyReportTime() string {
|
func (c *appConfig) GetWeeklyReportCron() string {
|
||||||
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
if strings.Contains(c.ReportTimeWeekly, ",") {
|
||||||
|
// old gocron format, e.g. "fri,18:00"
|
||||||
|
split := strings.Split(c.ReportTimeWeekly, ",")
|
||||||
|
weekday := utils.ParseWeekday(split[0])
|
||||||
|
timeParts := strings.Split(split[1], ":")
|
||||||
|
|
||||||
|
h, err := strconv.Atoi(timeParts[0])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := strconv.Atoi(timeParts[1])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.CronPadToSecondly(c.ReportTimeWeekly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
|
||||||
|
crons := []string{}
|
||||||
|
|
||||||
|
var parse func(string) string
|
||||||
|
|
||||||
|
if strings.Contains(c.LeaderboardGenerationTime, ":") {
|
||||||
|
// old gocron format, e.g. "15:04"
|
||||||
|
parse = func(s string) string {
|
||||||
|
timeParts := strings.Split(s, ":")
|
||||||
|
h, err := strconv.Atoi(timeParts[0])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := strconv.Atoi(timeParts[1])
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("0 %d %d * * *", m, h)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parse = func(s string) string {
|
||||||
|
return utils.CronPadToSecondly(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") {
|
||||||
|
crons = append(crons, parse(strings.TrimSpace(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return crons
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
|
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
|
||||||
@@ -302,13 +352,6 @@ func readColors() map[string]map[string]string {
|
|||||||
return colors
|
return colors
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustReadConfigLocation() string {
|
|
||||||
if _, err := os.Stat(*cFlag); err != nil {
|
|
||||||
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
|
|
||||||
}
|
|
||||||
return *cFlag
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDbDialect(dbType string) string {
|
func resolveDbDialect(dbType string) string {
|
||||||
if dbType == "cockroach" {
|
if dbType == "cockroach" {
|
||||||
return "postgres"
|
return "postgres"
|
||||||
@@ -322,35 +365,6 @@ func resolveDbDialect(dbType string) string {
|
|||||||
return dbType
|
return dbType
|
||||||
}
|
}
|
||||||
|
|
||||||
func findString(needle string, haystack []string, defaultVal string) string {
|
|
||||||
for _, s := range haystack {
|
|
||||||
if s == needle {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseWeekday(s string) time.Weekday {
|
|
||||||
switch strings.ToLower(s) {
|
|
||||||
case "mon", strings.ToLower(time.Monday.String()):
|
|
||||||
return time.Monday
|
|
||||||
case "tue", strings.ToLower(time.Tuesday.String()):
|
|
||||||
return time.Tuesday
|
|
||||||
case "wed", strings.ToLower(time.Wednesday.String()):
|
|
||||||
return time.Wednesday
|
|
||||||
case "thu", strings.ToLower(time.Thursday.String()):
|
|
||||||
return time.Thursday
|
|
||||||
case "fri", strings.ToLower(time.Friday.String()):
|
|
||||||
return time.Friday
|
|
||||||
case "sat", strings.ToLower(time.Saturday.String()):
|
|
||||||
return time.Saturday
|
|
||||||
case "sun", strings.ToLower(time.Sunday.String()):
|
|
||||||
return time.Sunday
|
|
||||||
}
|
|
||||||
return time.Monday
|
|
||||||
}
|
|
||||||
|
|
||||||
func Set(config *Config) {
|
func Set(config *Config) {
|
||||||
cfg = config
|
cfg = config
|
||||||
}
|
}
|
||||||
@@ -364,7 +378,7 @@ func Load(version string) *Config {
|
|||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
|
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
|
||||||
logbuch.Fatal("failed to read config: %v", err)
|
logbuch.Fatal("failed to read config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +397,7 @@ func Load(version string) *Config {
|
|||||||
securecookie.GenerateRandomKey(64),
|
securecookie.GenerateRandomKey(64),
|
||||||
securecookie.GenerateRandomKey(32),
|
securecookie.GenerateRandomKey(32),
|
||||||
)
|
)
|
||||||
|
config.Security.SessionKey = securecookie.GenerateRandomKey(32)
|
||||||
|
|
||||||
if strings.HasSuffix(config.Server.BasePath, "/") {
|
if strings.HasSuffix(config.Server.BasePath, "/") {
|
||||||
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
|
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
|
||||||
@@ -399,8 +414,18 @@ func Load(version string) *Config {
|
|||||||
initSentry(config.Sentry, config.IsDev())
|
initSentry(config.Sentry, config.IsDev())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.App.DataRetentionMonths <= 0 {
|
||||||
|
logbuch.Info("disabling data retention policy, keeping data forever")
|
||||||
|
} else {
|
||||||
|
dataRetentionWarning := fmt.Sprintf("⚠️ data retention policy will cause user data older than %d months to be deleted", config.App.DataRetentionMonths)
|
||||||
|
if config.Subscriptions.Enabled {
|
||||||
|
dataRetentionWarning += " (except for users with active subscriptions)"
|
||||||
|
}
|
||||||
|
logbuch.Warn(dataRetentionWarning)
|
||||||
|
}
|
||||||
|
|
||||||
// some validation checks
|
// some validation checks
|
||||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
|
if config.Server.ListenIpV4 == "-" && config.Server.ListenIpV6 == "-" && config.Server.ListenSocket == "" {
|
||||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
|
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
|
||||||
}
|
}
|
||||||
if config.Db.MaxConn <= 0 {
|
if config.Db.MaxConn <= 0 {
|
||||||
@@ -410,19 +435,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
|
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
|
||||||
config.Db.MaxConn = 1
|
config.Db.MaxConn = 1
|
||||||
}
|
}
|
||||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
|
||||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||||
}
|
}
|
||||||
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
|
|
||||||
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 {
|
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
|
||||||
logbuch.Fatal("invalid duration set for heartbeat_max_age")
|
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)
|
Set(config)
|
||||||
return Get()
|
return Get()
|
||||||
}
|
}
|
||||||
|
@@ -2,8 +2,9 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_IsDev(t *testing.T) {
|
func TestConfig_IsDev(t *testing.T) {
|
||||||
@@ -37,6 +38,28 @@ func Test_mysqlConnectionString(t *testing.T) {
|
|||||||
), mysqlConnectionString(c))
|
), mysqlConnectionString(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_mysqlConnectionStringSocket(t *testing.T) {
|
||||||
|
c := &dbConfig{
|
||||||
|
Socket: "/var/run/mysql.sock",
|
||||||
|
Port: 9999,
|
||||||
|
User: "test_user",
|
||||||
|
Password: "test_password",
|
||||||
|
Name: "test_name",
|
||||||
|
Dialect: "mysql",
|
||||||
|
Charset: "utf8mb4",
|
||||||
|
MaxConn: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, fmt.Sprintf(
|
||||||
|
"%s:%s@unix(%s)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||||
|
c.User,
|
||||||
|
c.Password,
|
||||||
|
c.Socket,
|
||||||
|
c.Name,
|
||||||
|
"Local",
|
||||||
|
), mysqlConnectionString(c))
|
||||||
|
}
|
||||||
|
|
||||||
func Test_postgresConnectionString(t *testing.T) {
|
func Test_postgresConnectionString(t *testing.T) {
|
||||||
c := &dbConfig{
|
c := &dbConfig{
|
||||||
Host: "test_host",
|
Host: "test_host",
|
||||||
|
16
config/db.go
16
config/db.go
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
@@ -54,11 +55,16 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mysqlConnectionString(config *dbConfig) string {
|
func mysqlConnectionString(config *dbConfig) string {
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
host := fmt.Sprintf("tcp(%s:%d)", config.Host, config.Port)
|
||||||
|
|
||||||
|
if config.Socket != "" {
|
||||||
|
host = fmt.Sprintf("unix(%s)", config.Socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||||
config.User,
|
config.User,
|
||||||
config.Password,
|
config.Password,
|
||||||
config.Host,
|
host,
|
||||||
config.Port,
|
|
||||||
config.Name,
|
config.Name,
|
||||||
config.Charset,
|
config.Charset,
|
||||||
"Local",
|
"Local",
|
||||||
@@ -66,6 +72,10 @@ func mysqlConnectionString(config *dbConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func postgresConnectionString(config *dbConfig) string {
|
func postgresConnectionString(config *dbConfig) string {
|
||||||
|
if len(config.DSN) > 0 {
|
||||||
|
return config.DSN
|
||||||
|
}
|
||||||
|
|
||||||
sslmode := "disable"
|
sslmode := "disable"
|
||||||
if config.Ssl {
|
if config.Ssl {
|
||||||
sslmode = "require"
|
sslmode = "require"
|
||||||
|
35
config/db_opts.go
Normal file
35
config/db_opts.go
Normal 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
85
config/jobqueue.go
Normal 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))
|
||||||
|
}
|
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/muety/wakapi/models"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -89,8 +88,8 @@ func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
|
|||||||
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
||||||
hub := h.(*sentry.Hub)
|
hub := h.(*sentry.Hub)
|
||||||
hub.Scope().SetRequest(l.req)
|
hub.Scope().SetRequest(l.req)
|
||||||
if u := getPrincipal(l.req); u != nil {
|
if uid := getPrincipal(l.req); uid != "" {
|
||||||
hub.Scope().SetUser(sentry.User{ID: u.ID})
|
hub.Scope().SetUser(sentry.User{ID: uid})
|
||||||
}
|
}
|
||||||
hub.CaptureEvent(event)
|
hub.CaptureEvent(event)
|
||||||
return
|
return
|
||||||
@@ -133,8 +132,8 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||||
if hint.Context != nil {
|
if hint.Context != nil {
|
||||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||||
if u := getPrincipal(req); u != nil {
|
if uid := getPrincipal(req); uid != "" {
|
||||||
event.User.ID = u.ID
|
event.User.ID = uid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,12 +144,14 @@ func initSentry(config sentryConfig, debug bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPrincipal(r *http.Request) *models.User {
|
// returns a user id
|
||||||
type principalGetter interface {
|
func getPrincipal(r *http.Request) string {
|
||||||
GetPrincipal() *models.User
|
type principalIdentityGetter interface {
|
||||||
|
GetPrincipalIdentity() string
|
||||||
}
|
}
|
||||||
|
|
||||||
if p := r.Context().Value("principal"); p != nil {
|
if p := r.Context().Value("principal"); p != nil {
|
||||||
return p.(principalGetter).GetPrincipal()
|
return p.(principalIdentityGetter).GetPrincipalIdentity()
|
||||||
}
|
}
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
14
config/session.go
Normal file
14
config/session.go
Normal 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
|
||||||
|
}
|
@@ -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
44
go.mod
44
go.mod
@@ -1,16 +1,15 @@
|
|||||||
module github.com/muety/wakapi
|
module github.com/muety/wakapi
|
||||||
|
|
||||||
go 1.18
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
codeberg.org/Codeberg/avatars v1.0.0
|
codeberg.org/Codeberg/avatars v1.0.0
|
||||||
github.com/duke-git/lancet/v2 v2.1.6
|
github.com/duke-git/lancet/v2 v2.1.10
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/emvi/logbuch v1.2.0
|
github.com/emvi/logbuch v1.2.0
|
||||||
github.com/getsentry/sentry-go v0.14.0
|
github.com/getsentry/sentry-go v0.15.0
|
||||||
github.com/glebarez/sqlite v1.5.0
|
github.com/glebarez/sqlite v1.5.0
|
||||||
github.com/go-co-op/gocron v1.17.0
|
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/schema v1.2.0
|
github.com/gorilla/schema v1.2.0
|
||||||
@@ -20,27 +19,29 @@ require (
|
|||||||
github.com/leandro-lugaresi/hub v1.1.1
|
github.com/leandro-lugaresi/hub v1.1.1
|
||||||
github.com/lpar/gzipped/v2 v2.1.0
|
github.com/lpar/gzipped/v2 v2.1.0
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||||
|
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
|
||||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
|
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/satori/go.uuid v1.2.0
|
github.com/satori/go.uuid v1.2.0
|
||||||
github.com/stretchr/testify v1.8.0
|
github.com/stretchr/testify v1.8.0
|
||||||
github.com/swaggo/http-swagger v1.3.3
|
github.com/swaggo/http-swagger v1.3.3
|
||||||
github.com/swaggo/swag v1.8.6
|
github.com/swaggo/swag v1.8.8
|
||||||
go.uber.org/atomic v1.10.0
|
go.uber.org/atomic v1.10.0
|
||||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
|
golang.org/x/crypto v0.3.0
|
||||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
golang.org/x/sync v0.1.0
|
||||||
gorm.io/driver/mysql v1.4.1
|
gorm.io/driver/mysql v1.4.4
|
||||||
gorm.io/driver/postgres v1.4.4
|
gorm.io/driver/postgres v1.4.5
|
||||||
gorm.io/driver/sqlite v1.4.2
|
gorm.io/driver/sqlite v1.4.3
|
||||||
gorm.io/gorm v1.24.0
|
gorm.io/gorm v1.24.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.19.1 // indirect
|
github.com/glebarez/go-sqlite v1.19.5 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||||
github.com/go-openapi/spec v0.20.7 // indirect
|
github.com/go-openapi/spec v0.20.7 // indirect
|
||||||
@@ -48,6 +49,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/gorilla/sessions v1.2.1 // indirect
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
github.com/jackc/pgconn v1.13.0 // indirect
|
github.com/jackc/pgconn v1.13.0 // indirect
|
||||||
github.com/jackc/pgio v1.0.0 // indirect
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
@@ -65,18 +67,18 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // 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/stretchr/objx v0.4.0 // indirect
|
||||||
|
github.com/stripe/stripe-go/v74 v74.3.0 // indirect
|
||||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
||||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
|
golang.org/x/image v0.1.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
|
golang.org/x/net v0.2.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
|
golang.org/x/sys v0.2.0 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
golang.org/x/text v0.4.0 // indirect
|
||||||
golang.org/x/tools v0.1.12 // indirect
|
golang.org/x/tools v0.3.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.20.3 // indirect
|
modernc.org/libc v1.21.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.4.0 // indirect
|
modernc.org/memory v1.4.0 // indirect
|
||||||
modernc.org/sqlite v1.19.1 // indirect
|
modernc.org/sqlite v1.20.0 // indirect
|
||||||
)
|
)
|
||||||
|
149
go.sum
149
go.sum
@@ -1,12 +1,15 @@
|
|||||||
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
||||||
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
|
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
||||||
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
|
github.com/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 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
@@ -16,8 +19,8 @@ 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
|
github.com/duke-git/lancet/v2 v2.1.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
|
||||||
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
@@ -29,21 +32,13 @@ github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrb
|
|||||||
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
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/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.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
|
||||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
|
||||||
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/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
|
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/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
|
||||||
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
|
github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
|
||||||
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
|
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/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/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
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-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-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
@@ -66,8 +61,9 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
|
|||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.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 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/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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -79,9 +75,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/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/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 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
@@ -102,7 +100,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
|
|||||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
|
||||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||||
@@ -173,20 +170,21 @@ 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.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 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
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-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
|
||||||
|
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/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 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-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
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.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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
@@ -219,13 +217,16 @@ 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.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 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/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/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
|
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 v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||||
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
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/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.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
|
||||||
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
|
github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
@@ -249,34 +250,35 @@ 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-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-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-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-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.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
|
||||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
|
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
|
||||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
|
||||||
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/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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-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-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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -287,25 +289,29 @@ 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-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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
|
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/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/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||||
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
@@ -316,14 +322,14 @@ 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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
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.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/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -338,73 +344,58 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
|
gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
|
||||||
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
|
||||||
gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic=
|
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
|
||||||
gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
|
||||||
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
|
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||||
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||||
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/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
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.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=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
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.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.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
modernc.org/cc/v3 v3.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/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||||
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
|
||||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
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.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.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/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
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.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
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.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.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.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||||
|
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||||
|
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
|
||||||
|
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
modernc.org/mathutil v1.4.1/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 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
|
||||||
modernc.org/memory v1.2.0/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.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/opt v0.1.3/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.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||||
|
modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
|
||||||
|
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
|
||||||
|
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
|
||||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
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.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.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
modernc.org/token v1.0.1/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.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||||
|
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
package utils
|
package helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"regexp"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,22 +40,10 @@ func FormatDateHuman(date time.Time) string {
|
|||||||
return date.Format("Mon, 02 Jan 2006")
|
return date.Format("Mon, 02 Jan 2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Add(i, j int) int {
|
func FmtWakatimeDuration(d time.Duration) string {
|
||||||
return i + j
|
d = d.Round(time.Minute)
|
||||||
}
|
h := d / time.Hour
|
||||||
|
d -= h * time.Hour
|
||||||
func ParseUserAgent(ua string) (string, string, error) {
|
m := d / time.Minute
|
||||||
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
return fmt.Sprintf("%d hrs %d mins", h, m)
|
||||||
groups := re.FindAllStringSubmatch(ua, -1)
|
|
||||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
|
||||||
return "", "", errors.New("failed to parse user agent string")
|
|
||||||
}
|
|
||||||
return groups[0][1], groups[0][2], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SubSlice[T any](slice []T, from, to uint) []T {
|
|
||||||
if int(to) > len(slice) {
|
|
||||||
to = uint(len(slice))
|
|
||||||
}
|
|
||||||
return slice[from:int(to)]
|
|
||||||
}
|
}
|
4
helpers/helpers.go
Normal file
4
helpers/helpers.go
Normal 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
30
helpers/http.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,78 +1,13 @@
|
|||||||
package utils
|
package helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func 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) {
|
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||||
user := extractUser(r)
|
user := extractUser(r)
|
||||||
params := r.URL.Query()
|
params := r.URL.Query()
|
||||||
@@ -144,3 +79,69 @@ func extractUser(r *http.Request) *models.User {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||||
|
for _, i := range models.AllIntervals {
|
||||||
|
if i.HasAlias(interval) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("not a valid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||||
|
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
parsed, err := ParseInterval(interval)
|
||||||
|
if err != nil {
|
||||||
|
return err, time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
return ResolveIntervalTZ(parsed, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
now := time.Now().In(tz)
|
||||||
|
to = now
|
||||||
|
|
||||||
|
switch interval {
|
||||||
|
case models.IntervalToday:
|
||||||
|
from = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
||||||
|
to = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalThisWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalLastWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalThisMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalLastMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
||||||
|
to = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalThisYear:
|
||||||
|
from = utils.BeginOfThisYear(tz)
|
||||||
|
case models.IntervalPast7Days:
|
||||||
|
from = now.AddDate(0, 0, -7)
|
||||||
|
case models.IntervalPast7DaysYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
||||||
|
case models.IntervalPast14Days:
|
||||||
|
from = now.AddDate(0, 0, -14)
|
||||||
|
case models.IntervalPast30Days:
|
||||||
|
from = now.AddDate(0, 0, -30)
|
||||||
|
case models.IntervalPast6Months:
|
||||||
|
from = now.AddDate(0, -6, 0)
|
||||||
|
case models.IntervalPast12Months:
|
||||||
|
from = now.AddDate(0, -12, 0)
|
||||||
|
case models.IntervalAny:
|
||||||
|
from = time.Time{}
|
||||||
|
default:
|
||||||
|
err = errors.New("invalid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
return err, from, to
|
||||||
|
}
|
45
main.go
45
main.go
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/muety/wakapi/static/docs"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -11,11 +10,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/muety/wakapi/static/docs"
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/lpar/gzipped/v2"
|
"github.com/lpar/gzipped/v2"
|
||||||
"github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
|
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
@@ -35,6 +36,8 @@ import (
|
|||||||
_ "gorm.io/driver/sqlite"
|
_ "gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
_ "github.com/muety/wakapi/static/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Embed version.txt
|
// Embed version.txt
|
||||||
@@ -79,6 +82,7 @@ var (
|
|||||||
keyValueService services.IKeyValueService
|
keyValueService services.IKeyValueService
|
||||||
reportService services.IReportService
|
reportService services.IReportService
|
||||||
diagnosticsService services.IDiagnosticsService
|
diagnosticsService services.IDiagnosticsService
|
||||||
|
housekeepingService services.IHousekeepingService
|
||||||
miscService services.IMiscService
|
miscService services.IMiscService
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,14 +132,12 @@ func main() {
|
|||||||
|
|
||||||
// Connect to database
|
// Connect to database
|
||||||
var err error
|
var err error
|
||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
logbuch.Info("starting with %s database", config.Db.Dialect)
|
||||||
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}, conf.GetWakapiDBOpts(&config.Db))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error(err.Error())
|
logbuch.Error(err.Error())
|
||||||
logbuch.Fatal("could not open database")
|
logbuch.Fatal("could not open database")
|
||||||
}
|
}
|
||||||
if config.Db.IsSQLite() {
|
|
||||||
db.Exec("PRAGMA foreign_keys = ON;")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
db = db.Debug()
|
db = db.Debug()
|
||||||
@@ -180,13 +182,15 @@ func main() {
|
|||||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||||
reportService = services.NewReportService(summaryService, userService, mailService)
|
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||||
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
|
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
|
||||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
|
||||||
|
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
|
||||||
|
|
||||||
// Schedule background tasks
|
// Schedule background tasks
|
||||||
go aggregationService.Schedule()
|
go aggregationService.Schedule()
|
||||||
go leaderboardService.ScheduleDefault()
|
go leaderboardService.Schedule()
|
||||||
go miscService.ScheduleCountTotalTime()
|
|
||||||
go reportService.Schedule()
|
go reportService.Schedule()
|
||||||
|
go housekeepingService.Schedule()
|
||||||
|
go miscService.Schedule()
|
||||||
|
|
||||||
routes.Init()
|
routes.Init()
|
||||||
|
|
||||||
@@ -212,6 +216,7 @@ func main() {
|
|||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||||
|
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
|
||||||
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
@@ -249,6 +254,7 @@ func main() {
|
|||||||
summaryHandler.RegisterRoutes(rootRouter)
|
summaryHandler.RegisterRoutes(rootRouter)
|
||||||
leaderboardHandler.RegisterRoutes(rootRouter)
|
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||||
settingsHandler.RegisterRoutes(rootRouter)
|
settingsHandler.RegisterRoutes(rootRouter)
|
||||||
|
subscriptionHandler.RegisterRoutes(rootRouter)
|
||||||
relayHandler.RegisterRoutes(rootRouter)
|
relayHandler.RegisterRoutes(rootRouter)
|
||||||
|
|
||||||
// API route registrations
|
// API route registrations
|
||||||
@@ -282,6 +288,7 @@ func main() {
|
|||||||
|
|
||||||
router.PathPrefix("/contribute.json").Handler(staticFileServer)
|
router.PathPrefix("/contribute.json").Handler(staticFileServer)
|
||||||
router.PathPrefix("/assets").Handler(assetsFileServer)
|
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)
|
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
|
||||||
|
|
||||||
// Listen HTTP
|
// Listen HTTP
|
||||||
@@ -292,7 +299,7 @@ func listen(handler http.Handler) {
|
|||||||
var s4, s6, sSocket *http.Server
|
var s4, s6, sSocket *http.Server
|
||||||
|
|
||||||
// IPv4
|
// IPv4
|
||||||
if config.Server.ListenIpV4 != "" {
|
if config.Server.ListenIpV4 != "-" && config.Server.ListenIpV4 != "" {
|
||||||
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||||
s4 = &http.Server{
|
s4 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
@@ -303,7 +310,7 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IPv6
|
// IPv6
|
||||||
if config.Server.ListenIpV6 != "" {
|
if config.Server.ListenIpV6 != "-" && config.Server.ListenIpV6 != "" {
|
||||||
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
|
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
|
||||||
s6 = &http.Server{
|
s6 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
@@ -314,10 +321,10 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UNIX domain socket
|
// UNIX domain socket
|
||||||
if config.Server.ListenSocket != "" {
|
if config.Server.ListenSocket != "-" && config.Server.ListenSocket != "" {
|
||||||
// Remove if exists
|
// Remove if exists
|
||||||
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
|
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
|
||||||
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket)
|
logbuch.Info("👉 Removing unix socket %s", config.Server.ListenSocket)
|
||||||
if err := os.Remove(config.Server.ListenSocket); err != nil {
|
if err := os.Remove(config.Server.ListenSocket); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
@@ -331,7 +338,7 @@ func listen(handler http.Handler) {
|
|||||||
|
|
||||||
if config.UseTLS() {
|
if config.UseTLS() {
|
||||||
if s4 != nil {
|
if s4 != nil {
|
||||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
|
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s4.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
@@ -339,7 +346,7 @@ func listen(handler http.Handler) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if s6 != nil {
|
if s6 != nil {
|
||||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
|
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s6.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
@@ -347,7 +354,7 @@ func listen(handler http.Handler) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if sSocket != nil {
|
if sSocket != nil {
|
||||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
||||||
go func() {
|
go func() {
|
||||||
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -360,7 +367,7 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if s4 != nil {
|
if s4 != nil {
|
||||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := s4.ListenAndServe(); err != nil {
|
if err := s4.ListenAndServe(); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
@@ -368,7 +375,7 @@ func listen(handler http.Handler) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if s6 != nil {
|
if s6 != nil {
|
||||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
|
logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := s6.ListenAndServe(); err != nil {
|
if err := s6.ListenAndServe(); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
@@ -376,7 +383,7 @@ func listen(handler http.Handler) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if sSocket != nil {
|
if sSocket != nil {
|
||||||
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
||||||
go func() {
|
go func() {
|
||||||
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@@ -2,6 +2,7 @@ package middlewares
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type AuthenticateMiddleware struct {
|
|||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
optionalForPaths []string
|
optionalForPaths []string
|
||||||
redirectTarget string // optional
|
redirectTarget string // optional
|
||||||
|
redirectErrorMessage string // optional
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
|
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
|
||||||
@@ -45,6 +47,11 @@ func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMi
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *AuthenticateMiddleware {
|
||||||
|
m.redirectErrorMessage = message
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
m.ServeHTTP(w, r, h.ServeHTTP)
|
m.ServeHTTP(w, r, h.ServeHTTP)
|
||||||
@@ -72,6 +79,11 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
|||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.Write([]byte(conf.ErrUnauthorized))
|
w.Write([]byte(conf.ErrUnauthorized))
|
||||||
} else {
|
} else {
|
||||||
|
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.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey))
|
||||||
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
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) {
|
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
username, err := helpers.ExtractCookieAuth(r, m.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@@ -160,17 +160,23 @@ func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
|
|||||||
|
|
||||||
newData := make([]interface{}, 0, len(heartbeats))
|
newData := make([]interface{}, 0, len(heartbeats))
|
||||||
|
|
||||||
for i, hb := range heartbeats {
|
process := func(heartbeat *models.Heartbeat, rawData interface{}) {
|
||||||
hb = hb.Hashed()
|
heartbeat = heartbeat.Hashed()
|
||||||
|
|
||||||
// we didn't see this particular heartbeat before
|
// we didn't see this particular heartbeat before
|
||||||
if _, found := m.hashCache.Get(hb.Hash); !found {
|
if _, found := m.hashCache.Get(heartbeat.Hash); !found {
|
||||||
m.hashCache.SetDefault(hb.Hash, true)
|
m.hashCache.SetDefault(heartbeat.Hash, true)
|
||||||
newData = append(newData, rawData.([]interface{})[i])
|
newData = append(newData, rawData)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if len(newData) == 0 {
|
||||||
return errors.New("no new heartbeats to relay")
|
return errors.New("no new heartbeats to relay")
|
||||||
}
|
}
|
||||||
|
@@ -20,6 +20,10 @@ func (c *PrincipalContainer) GetPrincipal() *models.User {
|
|||||||
return c.principal
|
return c.principal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *PrincipalContainer) GetPrincipalIdentity() string {
|
||||||
|
return c.principal.Identity()
|
||||||
|
}
|
||||||
|
|
||||||
// This middleware is a bit of a dirty workaround to the fact that a http.Request's context
|
// This middleware is a bit of a dirty workaround to the fact that a http.Request's context
|
||||||
// does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a
|
// does not allow to pass values from an inner to an outer middleware. Calling WithContext() on a
|
||||||
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
|
// request shallow-copies the whole request itself and therefore, in a chain of handler1(handler2()),
|
||||||
|
35
migrations/20221016_drop_rank_column.go
Normal file
35
migrations/20221016_drop_rank_column.go
Normal 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)
|
||||||
|
}
|
71
migrations/20221028_fix_heartbeats_time_user_idx.go
Normal file
71
migrations/20221028_fix_heartbeats_time_user_idx.go
Normal 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)
|
||||||
|
}
|
@@ -3,11 +3,14 @@ package migrations
|
|||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type gormMigrationFunc func(db *gorm.DB) error
|
||||||
|
|
||||||
type migrationFunc struct {
|
type migrationFunc struct {
|
||||||
f func(db *gorm.DB, cfg *config.Config) error
|
f func(db *gorm.DB, cfg *config.Config) error
|
||||||
name string
|
name string
|
||||||
@@ -20,6 +23,45 @@ var (
|
|||||||
postMigrations migrationFuncs
|
postMigrations migrationFuncs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func GetMigrationFunc(cfg *config.Config) gormMigrationFunc {
|
||||||
|
switch cfg.Db.Dialect {
|
||||||
|
default:
|
||||||
|
return func(db *gorm.DB) error {
|
||||||
|
if err := db.AutoMigrate(&models.User{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Alias{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Summary{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func registerPreMigration(f migrationFunc) {
|
func registerPreMigration(f migrationFunc) {
|
||||||
preMigrations = append(preMigrations, f)
|
preMigrations = append(preMigrations, f)
|
||||||
}
|
}
|
||||||
@@ -35,7 +77,7 @@ func Run(db *gorm.DB, cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
|
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
|
||||||
if err := cfg.GetMigrationFunc(cfg.Db.Dialect)(db); err != nil {
|
if err := GetMigrationFunc(cfg)(db); err != nil {
|
||||||
logbuch.Fatal(err.Error())
|
logbuch.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,13 +10,13 @@ type HeartbeatServiceMock struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
|
func (m *HeartbeatServiceMock) Insert(h *models.Heartbeat) error {
|
||||||
args := m.Called(heartbeat)
|
args := m.Called(h)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
|
func (m *HeartbeatServiceMock) InsertBatch(h []*models.Heartbeat) error {
|
||||||
args := m.Called(heartbeats)
|
args := m.Called(h)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,3 +74,8 @@ func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
|
|||||||
args := m.Called(u)
|
args := m.Called(u)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *HeartbeatServiceMock) DeleteByUserBefore(u *models.User, t time.Time) error {
|
||||||
|
args := m.Called(u, t)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
@@ -10,8 +10,8 @@ type SummaryRepositoryMock struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
func (m *SummaryRepositoryMock) Insert(s *models.Summary) error {
|
||||||
args := m.Called(summary)
|
args := m.Called(s)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
|
|||||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
func (m *SummaryRepositoryMock) GetByUserWithin(u *models.User, t1 time.Time, t2 time.Time) ([]*models.Summary, error) {
|
||||||
args := m.Called(user, time, time2)
|
args := m.Called(u, t1, t2)
|
||||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,3 +34,8 @@ func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
|
|||||||
args := m.Called(s)
|
args := m.Called(s)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *SummaryRepositoryMock) DeleteByUserBefore(s string, t time.Time) error {
|
||||||
|
args := m.Called(s, t)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
@@ -54,6 +54,11 @@ func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
|
|||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) GetUserByStripeCustomerId(s string) (*models.User, error) {
|
||||||
|
args := m.Called(s)
|
||||||
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
|
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
|
||||||
args := m.Called(b)
|
args := m.Called(b)
|
||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
@@ -107,3 +112,7 @@ func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, e
|
|||||||
func (m *UserServiceMock) FlushCache() {
|
func (m *UserServiceMock) FlushCache() {
|
||||||
m.Called()
|
m.Called()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) FlushUserCache(s string) {
|
||||||
|
m.Called(s)
|
||||||
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://shields.io/endpoint
|
// https://shields.io/endpoint
|
||||||
@@ -23,7 +23,7 @@ func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
|
|||||||
return &BadgeData{
|
return &BadgeData{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
Label: defaultLabel,
|
Label: defaultLabel,
|
||||||
Message: utils.FmtWakatimeDuration(summary.TotalTime()),
|
Message: helpers.FmtWakatimeDuration(summary.TotalTime()),
|
||||||
Color: defaultColor,
|
Color: defaultColor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://wakatime.com/developers#all_time_since_today
|
// https://wakatime.com/developers#all_time_since_today
|
||||||
@@ -28,11 +29,19 @@ type AllTimeRange struct {
|
|||||||
|
|
||||||
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
|
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
|
||||||
total := summary.TotalTime()
|
total := summary.TotalTime()
|
||||||
|
tzName, _ := summary.FromTime.T().Zone()
|
||||||
return &AllTimeViewModel{
|
return &AllTimeViewModel{
|
||||||
Data: &AllTimeData{
|
Data: &AllTimeData{
|
||||||
TotalSeconds: float32(total.Seconds()),
|
TotalSeconds: float32(total.Seconds()),
|
||||||
Text: utils.FmtWakatimeDuration(total),
|
Text: helpers.FmtWakatimeDuration(total),
|
||||||
IsUpToDate: true,
|
IsUpToDate: true,
|
||||||
|
Range: &AllTimeRange{
|
||||||
|
End: summary.ToTime.T().Format(time.RFC3339),
|
||||||
|
EndDate: helpers.FormatDate(summary.ToTime.T()),
|
||||||
|
Start: summary.FromTime.T().Format(time.RFC3339),
|
||||||
|
StartDate: helpers.FormatDate(summary.FromTime.T()),
|
||||||
|
Timezone: tzName,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,8 +2,8 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +16,14 @@ type SummariesViewModel struct {
|
|||||||
Data []*SummariesData `json:"data"`
|
Data []*SummariesData `json:"data"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
|
CumulativeTotal *SummariesCumulativeTotal `json:"cummulative_total"` // typo is intended
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummariesCumulativeTotal struct {
|
||||||
|
Decimal string `json:"decimal"`
|
||||||
|
Digital string `json:"digital"`
|
||||||
|
Seconds float64 `json:"seconds"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummariesData struct {
|
type SummariesData struct {
|
||||||
@@ -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{
|
return &SummariesViewModel{
|
||||||
Data: data,
|
Data: data,
|
||||||
End: maxDate,
|
End: maxDate,
|
||||||
Start: minDate,
|
Start: minDate,
|
||||||
|
CumulativeTotal: &SummariesCumulativeTotal{
|
||||||
|
Decimal: fmt.Sprintf("%.2f", totalHrs),
|
||||||
|
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
|
||||||
|
Seconds: totalSecs,
|
||||||
|
Text: helpers.FmtWakatimeDuration(totalTime),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +119,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||||
Hours: totalHrs,
|
Hours: totalHrs,
|
||||||
Minutes: totalMins,
|
Minutes: totalMins,
|
||||||
Text: utils.FmtWakatimeDuration(total),
|
Text: helpers.FmtWakatimeDuration(total),
|
||||||
TotalSeconds: total.Seconds(),
|
TotalSeconds: total.Seconds(),
|
||||||
},
|
},
|
||||||
Range: &SummariesRange{
|
Range: &SummariesRange{
|
||||||
@@ -180,7 +201,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEn
|
|||||||
Name: e.Key,
|
Name: e.Key,
|
||||||
Percent: percentage,
|
Percent: percentage,
|
||||||
Seconds: secs,
|
Seconds: secs,
|
||||||
Text: utils.FmtWakatimeDuration(total),
|
Text: helpers.FmtWakatimeDuration(total),
|
||||||
TotalSeconds: total.Seconds(),
|
TotalSeconds: total.Seconds(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
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
|
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)"`
|
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"`
|
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||||
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||||
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||||
|
18
models/init_test.go
Normal file
18
models/init_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -11,7 +11,6 @@ type LeaderboardItem struct {
|
|||||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
|
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"`
|
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
|
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
|
||||||
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
|
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"`
|
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 {
|
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
|
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 {
|
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)
|
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 {
|
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
|
return item.UserID == userId
|
||||||
})).TopKeys(by)
|
})).TopKeys(by)
|
||||||
}
|
}
|
||||||
|
22
models/metrics/gauge_metric.go
Normal file
22
models/metrics/gauge_metric.go
Normal 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)
|
||||||
|
}
|
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gorm.io/gorm"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,10 +14,9 @@ const (
|
|||||||
UserKey = "user"
|
UserKey = "user"
|
||||||
ImprintKey = "imprint"
|
ImprintKey = "imprint"
|
||||||
AuthCookieKey = "wakapi_auth"
|
AuthCookieKey = "wakapi_auth"
|
||||||
|
PersistentIntervalKey = "wakapi_summary_interval"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MigrationFunc func(db *gorm.DB) error
|
|
||||||
|
|
||||||
type KeyStringValue struct {
|
type KeyStringValue struct {
|
||||||
Key string `gorm:"primary_key"`
|
Key string `gorm:"primary_key"`
|
||||||
Value string `gorm:"type:text"`
|
Value string `gorm:"type:text"`
|
||||||
|
@@ -34,7 +34,7 @@ type Summary struct {
|
|||||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||||
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project filter is applied
|
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
|
type SummaryItems []*SummaryItem
|
||||||
|
@@ -3,6 +3,8 @@ package models
|
|||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,7 +22,7 @@ type User struct {
|
|||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
ShareDataMaxDays int `json:"-" gorm:"default:0"`
|
ShareDataMaxDays int `json:"-"`
|
||||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
@@ -34,6 +36,8 @@ type User struct {
|
|||||||
ResetToken string `json:"-"`
|
ResetToken string `json:"-"`
|
||||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
|
StripeCustomerId string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login struct {
|
type Login struct {
|
||||||
@@ -82,6 +86,10 @@ type CountByUser struct {
|
|||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) Identity() string {
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
func (u *User) TZ() *time.Location {
|
func (u *User) TZ() *time.Location {
|
||||||
if u.Location == "" {
|
if u.Location == "" {
|
||||||
u.Location = "Local"
|
u.Location = "Local"
|
||||||
@@ -120,6 +128,40 @@ func (u *User) WakaTimeURL(fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasActiveSubscription returns true if subscriptions are enabled on the server and the user has got one
|
||||||
|
func (u *User) HasActiveSubscription() bool {
|
||||||
|
return conf.Get().Subscriptions.Enabled && u.HasActiveSubscriptionStrict()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) HasActiveSubscriptionStrict() bool {
|
||||||
|
return u.SubscribedUntil != nil && u.SubscribedUntil.T().After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionExpiredSince returns if a user's subscription has expiration and the duration since when that happened.
|
||||||
|
// Returns (false, <negative duration>), if subscription hasn't expired, yet.
|
||||||
|
// Returns (false, 0), if subscriptions are not enabled in the first place.
|
||||||
|
// Returns (true, <very long duration>), if the user has never had a subscription.
|
||||||
|
func (u *User) SubscriptionExpiredSince() (bool, time.Duration) {
|
||||||
|
cfg := conf.Get()
|
||||||
|
if !cfg.Subscriptions.Enabled {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
if u.SubscribedUntil == nil {
|
||||||
|
return true, 99 * 365 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
diff := time.Now().Sub(u.SubscribedUntil.T())
|
||||||
|
return diff >= 0, diff
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) MinDataAge() time.Time {
|
||||||
|
retentionMonths := conf.Get().App.DataRetentionMonths
|
||||||
|
if retentionMonths <= 0 || u.HasActiveSubscription() {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
// this is not exactly precise, because of summer / winter time, etc.
|
||||||
|
return time.Now().AddDate(0, -retentionMonths, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
@@ -149,8 +191,9 @@ func ValidatePassword(password string) bool {
|
|||||||
return len(password) >= 6
|
return len(password) >= 6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateEmail checks that, if an email address is given, it has proper syntax and (if not in dev mode) an MX record exists for the domain
|
||||||
func ValidateEmail(email string) bool {
|
func ValidateEmail(email string) bool {
|
||||||
return email == "" || mailRegex.Match([]byte(email))
|
return email == "" || (mailRegex.Match([]byte(email)) && (conf.Get().IsDev() || utils.CheckEmailMX(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateTimezone(tz string) bool {
|
func ValidateTimezone(tz string) bool {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,3 +19,41 @@ func TestUser_TZ(t *testing.T) {
|
|||||||
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||||
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
|
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUser_MinDataAge(t *testing.T) {
|
||||||
|
c := conf.Load("")
|
||||||
|
|
||||||
|
var sut *User
|
||||||
|
|
||||||
|
// test with unlimited retention time / clean-up disabled
|
||||||
|
c.App.DataRetentionMonths = -1
|
||||||
|
c.Subscriptions.Enabled = false
|
||||||
|
sut = &User{}
|
||||||
|
assert.Zero(t, sut.MinDataAge())
|
||||||
|
|
||||||
|
// test with limited retention time / clean-up enabled, and subscriptions disabled
|
||||||
|
c.App.DataRetentionMonths = 1
|
||||||
|
c.Subscriptions.Enabled = false
|
||||||
|
sut = &User{}
|
||||||
|
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||||
|
|
||||||
|
// test with limited retention time, subscriptions enabled, and user hasn't got one
|
||||||
|
c.App.DataRetentionMonths = 1
|
||||||
|
c.Subscriptions.Enabled = true
|
||||||
|
sut = &User{}
|
||||||
|
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||||
|
|
||||||
|
// test with limited retention time, subscriptions disabled, but user still got (an expired) one
|
||||||
|
c.App.DataRetentionMonths = 1
|
||||||
|
c.Subscriptions.Enabled = false
|
||||||
|
until2 := CustomTime(time.Now().AddDate(0, 0, -1))
|
||||||
|
sut = &User{SubscribedUntil: &until2}
|
||||||
|
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||||
|
|
||||||
|
// test with limited retention time, subscriptions enabled, and user has got one
|
||||||
|
c.App.DataRetentionMonths = 1
|
||||||
|
c.Subscriptions.Enabled = true
|
||||||
|
until1 := CustomTime(time.Now().AddDate(0, 1, 0))
|
||||||
|
sut = &User{SubscribedUntil: &until1}
|
||||||
|
assert.Zero(t, sut.MinDataAge())
|
||||||
|
}
|
||||||
|
19
models/view/common.go
Normal file
19
models/view/common.go
Normal 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
|
||||||
|
}
|
@@ -6,19 +6,18 @@ type Newsbox struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HomeViewModel struct {
|
type HomeViewModel struct {
|
||||||
Success string
|
Messages
|
||||||
Error string
|
|
||||||
TotalHours int
|
TotalHours int
|
||||||
TotalUsers int
|
TotalUsers int
|
||||||
Newsbox *Newsbox
|
Newsbox *Newsbox
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
@@ -1,18 +1,17 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
type ImprintViewModel struct {
|
type ImprintViewModel struct {
|
||||||
|
Messages
|
||||||
HtmlText string
|
HtmlText string
|
||||||
Success string
|
|
||||||
Error string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,33 +2,34 @@ package view
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LeaderboardViewModel struct {
|
type LeaderboardViewModel struct {
|
||||||
|
Messages
|
||||||
User *models.User
|
User *models.User
|
||||||
By string
|
By string
|
||||||
Key string
|
Key string
|
||||||
Items []*models.LeaderboardItem
|
Items []*models.LeaderboardItemRanked
|
||||||
TopKeys []string
|
TopKeys []string
|
||||||
UserLanguages map[string][]string
|
UserLanguages map[string][]string
|
||||||
ApiKey string
|
ApiKey string
|
||||||
Success string
|
PageParams *utils.PageParams
|
||||||
Error string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
|
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
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 {
|
if principal != nil && item.UserID == principal.ID {
|
||||||
return "self"
|
return "self"
|
||||||
}
|
}
|
||||||
@@ -47,25 +48,32 @@ func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, princ
|
|||||||
func (s *LeaderboardViewModel) LangIcon(lang string) string {
|
func (s *LeaderboardViewModel) LangIcon(lang string) string {
|
||||||
// https://icon-sets.iconify.design/mdi/
|
// https://icon-sets.iconify.design/mdi/
|
||||||
langs := map[string]string{
|
langs := map[string]string{
|
||||||
"c++": "cpp",
|
"c++": "language-cpp",
|
||||||
"cpp": "cpp",
|
"cpp": "language-cpp",
|
||||||
"go": "go",
|
"go": "language-go",
|
||||||
"haskell": "haskell",
|
"haskell": "language-haskell",
|
||||||
"html": "html5",
|
"html": "language-html5",
|
||||||
"java": "java",
|
"java": "language-java",
|
||||||
"javascript": "javascript",
|
"javascript": "language-javascript",
|
||||||
"kotlin": "kotlin",
|
"jsx": "language-javascript",
|
||||||
"lua": "lua",
|
"kotlin": "language-kotlin",
|
||||||
"php": "php",
|
"lua": "language-lua",
|
||||||
"python": "python",
|
"php": "language-php",
|
||||||
"r": "r",
|
"python": "language-python",
|
||||||
"ruby": "ruby",
|
"r": "language-r",
|
||||||
"rust": "rust",
|
"ruby": "language-ruby",
|
||||||
"swift": "swift",
|
"rust": "language-rust",
|
||||||
"typescript": "typescript",
|
"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 {
|
if match, ok := langs[strings.ToLower(lang)]; ok {
|
||||||
return "mdi:language-" + match
|
return "mdi:" + match
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
type LoginViewModel struct {
|
type LoginViewModel struct {
|
||||||
Success string
|
Messages
|
||||||
Error string
|
|
||||||
TotalUsers int
|
TotalUsers int
|
||||||
AllowSignup bool
|
AllowSignup bool
|
||||||
}
|
}
|
||||||
@@ -13,11 +12,11 @@ type SetPasswordViewModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
|
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
@@ -1,16 +1,22 @@
|
|||||||
package view
|
package view
|
||||||
|
|
||||||
import "github.com/muety/wakapi/models"
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type SettingsViewModel struct {
|
type SettingsViewModel struct {
|
||||||
|
Messages
|
||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
Labels []*SettingsVMCombinedLabel
|
Labels []*SettingsVMCombinedLabel
|
||||||
Projects []string
|
Projects []string
|
||||||
|
SubscriptionPrice string
|
||||||
|
DataRetentionMonths int
|
||||||
|
UserFirstData time.Time
|
||||||
|
SupportContact string
|
||||||
ApiKey string
|
ApiKey string
|
||||||
Success string
|
|
||||||
Error string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsVMCombinedAlias struct {
|
type SettingsVMCombinedAlias struct {
|
||||||
@@ -24,12 +30,16 @@ type SettingsVMCombinedLabel struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
|
||||||
|
return s.SubscriptionPrice != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ package view
|
|||||||
import "github.com/muety/wakapi/models"
|
import "github.com/muety/wakapi/models"
|
||||||
|
|
||||||
type SummaryViewModel struct {
|
type SummaryViewModel struct {
|
||||||
|
Messages
|
||||||
*models.Summary
|
*models.Summary
|
||||||
*models.SummaryParams
|
*models.SummaryParams
|
||||||
User *models.User
|
User *models.User
|
||||||
@@ -10,18 +11,16 @@ type SummaryViewModel struct {
|
|||||||
EditorColors map[string]string
|
EditorColors map[string]string
|
||||||
LanguageColors map[string]string
|
LanguageColors map[string]string
|
||||||
OSColors map[string]string
|
OSColors map[string]string
|
||||||
Error string
|
|
||||||
Success string
|
|
||||||
ApiKey string
|
ApiKey string
|
||||||
RawQuery string
|
RawQuery string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||||
s.Success = m
|
s.SetSuccess(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
||||||
s.Error = m
|
s.SetError(m)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
|
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/json": "^1.1.444",
|
"@iconify/json": "^2.1.136",
|
||||||
"@iconify/json-tools": "^1.0.10",
|
"@iconify/json-tools": "^1.0.10",
|
||||||
"chokidar-cli": "^3.0.0",
|
"chokidar-cli": "^3.0.0",
|
||||||
"tailwindcss": "^3.1.8"
|
"tailwindcss": "^3.1.8"
|
||||||
|
@@ -170,7 +170,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
|||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
func (r *HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||||
var results []string
|
var results []string
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Model(&models.Heartbeat{}).
|
Model(&models.Heartbeat{}).
|
||||||
@@ -199,3 +199,13 @@ func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
@@ -34,6 +34,17 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
|
|||||||
return kv, nil
|
return kv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
|
||||||
|
var keyValues []*models.KeyStringValue
|
||||||
|
if err := r.db.Table("key_string_values").
|
||||||
|
Where("`key` like ?", like).
|
||||||
|
Find(&keyValues).
|
||||||
|
Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keyValues, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||||
result := r.db.
|
result := r.db.
|
||||||
Clauses(clause.OnConflict{
|
Clauses(clause.OnConflict{
|
||||||
|
@@ -33,13 +33,27 @@ func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
|
|||||||
return count, err
|
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 ?
|
// 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\"").
|
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||||
Where("\"interval\" in ?", *key)
|
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 {
|
if err := q.Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -47,13 +61,16 @@ func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalK
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
|
||||||
var items []*models.LeaderboardItem
|
var items []*models.LeaderboardItemRanked
|
||||||
q := r.db.
|
subq := r.db.
|
||||||
|
Table("leaderboard_items").
|
||||||
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||||
Where("user_id = ?", userId).
|
|
||||||
Where("\"interval\" in ?", *key)
|
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 {
|
if err := q.Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -79,3 +96,13 @@ func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *mode
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
@@ -31,6 +31,7 @@ type IHeartbeatRepository interface {
|
|||||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
DeleteByUser(*models.User) error
|
DeleteByUser(*models.User) error
|
||||||
|
DeleteByUserBefore(*models.User, time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDiagnosticsRepository interface {
|
type IDiagnosticsRepository interface {
|
||||||
@@ -42,6 +43,7 @@ type IKeyValueRepository interface {
|
|||||||
GetString(string) (*models.KeyStringValue, error)
|
GetString(string) (*models.KeyStringValue, error)
|
||||||
PutString(*models.KeyStringValue) error
|
PutString(*models.KeyStringValue) error
|
||||||
DeleteString(string) error
|
DeleteString(string) error
|
||||||
|
Search(string) ([]*models.KeyStringValue, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ILanguageMappingRepository interface {
|
type ILanguageMappingRepository interface {
|
||||||
@@ -66,14 +68,12 @@ type ISummaryRepository interface {
|
|||||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||||
GetLastByUser() ([]*models.TimeByUser, error)
|
GetLastByUser() ([]*models.TimeByUser, error)
|
||||||
DeleteByUser(string) error
|
DeleteByUser(string) error
|
||||||
|
DeleteByUserBefore(string, time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IUserRepository interface {
|
type IUserRepository interface {
|
||||||
GetById(string) (*models.User, error)
|
FindOne(user models.User) (*models.User, error)
|
||||||
GetByIds([]string) ([]*models.User, error)
|
GetByIds([]string) ([]*models.User, error)
|
||||||
GetByApiKey(string) (*models.User, error)
|
|
||||||
GetByEmail(string) (*models.User, error)
|
|
||||||
GetByResetToken(string) (*models.User, error)
|
|
||||||
GetAll() ([]*models.User, error)
|
GetAll() ([]*models.User, error)
|
||||||
GetMany([]string) ([]*models.User, error)
|
GetMany([]string) ([]*models.User, error)
|
||||||
GetAllByReports(bool) ([]*models.User, error)
|
GetAllByReports(bool) ([]*models.User, error)
|
||||||
@@ -90,8 +90,9 @@ type IUserRepository interface {
|
|||||||
type ILeaderboardRepository interface {
|
type ILeaderboardRepository interface {
|
||||||
InsertBatch([]*models.LeaderboardItem) error
|
InsertBatch([]*models.LeaderboardItem) error
|
||||||
CountAllByUser(string) (int64, error)
|
CountAllByUser(string) (int64, error)
|
||||||
|
CountUsers() (int64, error)
|
||||||
DeleteByUser(string) error
|
DeleteByUser(string) error
|
||||||
DeleteByUserAndInterval(string, *models.IntervalKey) error
|
DeleteByUserAndInterval(string, *models.IntervalKey) error
|
||||||
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
|
||||||
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
|
||||||
}
|
}
|
||||||
|
@@ -86,6 +86,16 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *SummaryRepository) DeleteByUserBefore(userId string, t time.Time) error {
|
||||||
|
if err := r.db.
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Where("to_time <= ?", t.Local()).
|
||||||
|
Delete(models.Summary{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// inplace
|
// inplace
|
||||||
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
|
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
|
||||||
var items []*models.SummaryItem
|
var items []*models.SummaryItem
|
||||||
|
@@ -15,9 +15,9 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
|
|||||||
return &UserRepository{db: db}
|
return &UserRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) GetById(userId string) (*models.User, error) {
|
func (r *UserRepository) FindOne(attributes models.User) (*models.User, error) {
|
||||||
u := &models.User{}
|
u := &models.User{}
|
||||||
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
if err := r.db.Where(&attributes).First(u).Error; err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
@@ -34,39 +34,6 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
|
||||||
if key == "" {
|
|
||||||
return nil, errors.New("invalid input")
|
|
||||||
}
|
|
||||||
u := &models.User{}
|
|
||||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
|
|
||||||
if resetToken == "" {
|
|
||||||
return nil, errors.New("invalid input")
|
|
||||||
}
|
|
||||||
u := &models.User{}
|
|
||||||
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
|
||||||
if email == "" {
|
|
||||||
return nil, errors.New("invalid input")
|
|
||||||
}
|
|
||||||
u := &models.User{}
|
|
||||||
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||||
var users []*models.User
|
var users []*models.User
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
@@ -144,7 +111,7 @@ func (r *UserRepository) Count() (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
||||||
if u, err := r.GetById(user.ID); err == nil && u != nil && u.ID != "" {
|
if u, err := r.FindOne(models.User{ID: user.ID}); err == nil && u != nil && u.ID != "" {
|
||||||
return u, false, nil
|
return u, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +143,8 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
|||||||
"location": user.Location,
|
"location": user.Location,
|
||||||
"reports_weekly": user.ReportsWeekly,
|
"reports_weekly": user.ReportsWeekly,
|
||||||
"public_leaderboard": user.PublicLeaderboard,
|
"public_leaderboard": user.PublicLeaderboard,
|
||||||
|
"subscribed_until": user.SubscribedUntil,
|
||||||
|
"stripe_customer_id": user.StripeCustomerId,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := r.db.Model(user).Updates(updateMap)
|
result := r.db.Model(user).Updates(updateMap)
|
||||||
|
@@ -40,7 +40,7 @@ func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !h.cache.Contains(hash) {
|
if !h.cache.Contains(hash) {
|
||||||
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
|
h.cache.Add(hash, avatars.MakeAvatar(hash))
|
||||||
}
|
}
|
||||||
data, _ := h.cache.Get(hash)
|
data, _ := h.cache.Get(hash)
|
||||||
|
|
||||||
|
@@ -2,14 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/services"
|
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiagnosticsApiHandler struct {
|
type DiagnosticsApiHandler struct {
|
||||||
@@ -55,5 +54,5 @@ func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondJSON(w, r, http.StatusCreated, struct{}{})
|
helpers.RespondJSON(w, r, http.StatusCreated, struct{}{})
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -120,7 +121,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
defer func() {}()
|
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)
|
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||||
|
@@ -5,13 +5,13 @@ import (
|
|||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
mm "github.com/muety/wakapi/models/metrics"
|
mm "github.com/muety/wakapi/models/metrics"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -37,6 +37,9 @@ const (
|
|||||||
DescAdminTotalUsers = "Total number of registered users."
|
DescAdminTotalUsers = "Total number of registered users."
|
||||||
DescAdminActiveUsers = "Number of active users."
|
DescAdminActiveUsers = "Number of active users."
|
||||||
|
|
||||||
|
DescJobQueueEnqueued = "Number of jobs currently enqueued"
|
||||||
|
DescJobQueueTotalFinished = "Total number of processed jobs"
|
||||||
|
|
||||||
DescMemAllocTotal = "Total number of bytes allocated for heap"
|
DescMemAllocTotal = "Total number of bytes allocated for heap"
|
||||||
DescMemSysTotal = "Total number of bytes obtained from the OS"
|
DescMemSysTotal = "Total number of bytes obtained from the OS"
|
||||||
DescGoroutines = "Total number of running goroutines"
|
DescGoroutines = "Total number of running goroutines"
|
||||||
@@ -126,7 +129,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
|
from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ())
|
||||||
|
|
||||||
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
|
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
|
|
||||||
// User Metrics
|
// User Metrics
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_cumulative_seconds_total",
|
Name: MetricsPrefix + "_cumulative_seconds_total",
|
||||||
Desc: DescAllTime,
|
Desc: DescAllTime,
|
||||||
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
|
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_seconds_total",
|
Name: MetricsPrefix + "_seconds_total",
|
||||||
Desc: DescTotal,
|
Desc: DescTotal,
|
||||||
Value: int64(summaryToday.TotalTime().Seconds()),
|
Value: int64(summaryToday.TotalTime().Seconds()),
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_heartbeats_total",
|
Name: MetricsPrefix + "_heartbeats_total",
|
||||||
Desc: DescHeartbeats,
|
Desc: DescHeartbeats,
|
||||||
Value: int64(heartbeatCount),
|
Value: int64(heartbeatCount),
|
||||||
@@ -164,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
})
|
})
|
||||||
|
|
||||||
for _, p := range summaryToday.Projects {
|
for _, p := range summaryToday.Projects {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_project_seconds_total",
|
Name: MetricsPrefix + "_project_seconds_total",
|
||||||
Desc: DescProjects,
|
Desc: DescProjects,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
|
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 {
|
for _, l := range summaryToday.Languages {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_language_seconds_total",
|
Name: MetricsPrefix + "_language_seconds_total",
|
||||||
Desc: DescLanguages,
|
Desc: DescLanguages,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
|
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 {
|
for _, e := range summaryToday.Editors {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_editor_seconds_total",
|
Name: MetricsPrefix + "_editor_seconds_total",
|
||||||
Desc: DescEditors,
|
Desc: DescEditors,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
|
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 {
|
for _, o := range summaryToday.OperatingSystems {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_operating_system_seconds_total",
|
Name: MetricsPrefix + "_operating_system_seconds_total",
|
||||||
Desc: DescOperatingSystems,
|
Desc: DescOperatingSystems,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
|
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 {
|
for _, m := range summaryToday.Machines {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_machine_seconds_total",
|
Name: MetricsPrefix + "_machine_seconds_total",
|
||||||
Desc: DescMachines,
|
Desc: DescMachines,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
|
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 {
|
for _, m := range summaryToday.Labels {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_label_seconds_total",
|
Name: MetricsPrefix + "_label_seconds_total",
|
||||||
Desc: DescLabels,
|
Desc: DescLabels,
|
||||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
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
|
var memStats runtime.MemStats
|
||||||
runtime.ReadMemStats(&memStats)
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_goroutines_total",
|
Name: MetricsPrefix + "_goroutines_total",
|
||||||
Desc: DescGoroutines,
|
Desc: DescGoroutines,
|
||||||
Value: int64(runtime.NumGoroutine()),
|
Value: int64(runtime.NumGoroutine()),
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_mem_alloc_total",
|
Name: MetricsPrefix + "_mem_alloc_total",
|
||||||
Desc: DescMemAllocTotal,
|
Desc: DescMemAllocTotal,
|
||||||
Value: int64(memStats.Alloc),
|
Value: int64(memStats.Alloc),
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_mem_sys_total",
|
Name: MetricsPrefix + "_mem_sys_total",
|
||||||
Desc: DescMemSysTotal,
|
Desc: DescMemSysTotal,
|
||||||
Value: int64(memStats.Sys),
|
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)
|
logbuch.Warn("failed to get database size (%v)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_db_total_bytes",
|
Name: MetricsPrefix + "_db_total_bytes",
|
||||||
Desc: DescDatabaseSize,
|
Desc: DescDatabaseSize,
|
||||||
Value: dbSize,
|
Value: dbSize,
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Miscellaneous
|
||||||
|
for _, qm := range conf.GetQueueMetrics() {
|
||||||
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
|
Name: MetricsPrefix + "_queue_jobs_enqueued",
|
||||||
|
Value: int64(qm.EnqueuedJobs),
|
||||||
|
Desc: DescJobQueueEnqueued,
|
||||||
|
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
|
||||||
|
})
|
||||||
|
|
||||||
|
metrics = append(metrics, &mm.CounterMetric{
|
||||||
|
Name: MetricsPrefix + "_queue_jobs_total_finished",
|
||||||
|
Value: int64(qm.FinishedJobs),
|
||||||
|
Desc: DescJobQueueTotalFinished,
|
||||||
|
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &metrics, nil
|
return &metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,28 +301,28 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_admin_seconds_total",
|
Name: MetricsPrefix + "_admin_seconds_total",
|
||||||
Desc: DescAdminTotalTime,
|
Desc: DescAdminTotalTime,
|
||||||
Value: int64(totalSeconds),
|
Value: int64(totalSeconds),
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_admin_heartbeats_total",
|
Name: MetricsPrefix + "_admin_heartbeats_total",
|
||||||
Desc: DescAdminTotalHeartbeats,
|
Desc: DescAdminTotalHeartbeats,
|
||||||
Value: totalHeartbeats,
|
Value: totalHeartbeats,
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_admin_users_total",
|
Name: MetricsPrefix + "_admin_users_total",
|
||||||
Desc: DescAdminTotalUsers,
|
Desc: DescAdminTotalUsers,
|
||||||
Value: totalUsers,
|
Value: totalUsers,
|
||||||
Labels: []mm.Label{},
|
Labels: []mm.Label{},
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_admin_users_active_total",
|
Name: MetricsPrefix + "_admin_users_active_total",
|
||||||
Desc: DescAdminActiveUsers,
|
Desc: DescAdminActiveUsers,
|
||||||
Value: int64(len(activeUsers)),
|
Value: int64(len(activeUsers)),
|
||||||
@@ -318,7 +338,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, uc := range userCounts {
|
for _, uc := range userCounts {
|
||||||
metrics = append(metrics, &mm.CounterMetric{
|
metrics = append(metrics, &mm.GaugeMetric{
|
||||||
Name: MetricsPrefix + "_admin_user_heartbeats_total",
|
Name: MetricsPrefix + "_admin_user_heartbeats_total",
|
||||||
Desc: DescAdminUserHeartbeats,
|
Desc: DescAdminUserHeartbeats,
|
||||||
Value: uc.Count,
|
Value: uc.Count,
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ import (
|
|||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SummaryApiHandler struct {
|
type SummaryApiHandler struct {
|
||||||
@@ -58,5 +58,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondJSON(w, r, http.StatusOK, summary)
|
helpers.RespondJSON(w, r, http.StatusOK, summary)
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,7 +12,6 @@ import (
|
|||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"github.com/patrickmn/go-cache"
|
"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())
|
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
|
||||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +83,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
vm := v1.NewBadgeDataFrom(summary)
|
vm := v1.NewBadgeDataFrom(summary)
|
||||||
h.cache.SetDefault(cacheKey, vm)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,12 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -50,7 +50,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return // response was already sent by util function
|
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 {
|
if err != nil {
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -58,7 +58,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := v1.NewAllTimeFrom(summary)
|
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) {
|
func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filters) (*models.Summary, error, int) {
|
||||||
|
@@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/duke-git/lancet/v2/datetime"
|
"github.com/duke-git/lancet/v2/datetime"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ import (
|
|||||||
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type HeartbeatsResult struct {
|
type HeartbeatsResult struct {
|
||||||
@@ -82,5 +82,5 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
End: rangeTo.UTC().Format(time.RFC3339),
|
End: rangeTo.UTC().Format(time.RFC3339),
|
||||||
Timezone: timezone.String(),
|
Timezone: timezone.String(),
|
||||||
}
|
}
|
||||||
utils.RespondJSON(w, r, http.StatusOK, res)
|
helpers.RespondJSON(w, r, http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ import (
|
|||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectsHandler struct {
|
type ProjectsHandler struct {
|
||||||
@@ -70,5 +70,5 @@ func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := &v1.ProjectsViewModel{Data: projects}
|
vm := &v1.ProjectsViewModel{Data: projects}
|
||||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,7 +11,6 @@ import (
|
|||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatsHandler struct {
|
type StatsHandler struct {
|
||||||
@@ -79,14 +79,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
rangeParam = (*models.IntervalPast7Days)[0]
|
rangeParam = (*models.IntervalPast7Days)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("invalid range"))
|
w.Write([]byte("invalid range"))
|
||||||
return
|
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) &&
|
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
@@ -94,7 +94,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -120,7 +120,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats.Data.Machines = nil
|
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) {
|
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ import (
|
|||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatusBarViewModel struct {
|
type StatusBarViewModel struct {
|
||||||
@@ -65,7 +65,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
rangeParam = (*models.IntervalToday)[0]
|
rangeParam = (*models.IntervalToday)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ())
|
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, user.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("invalid range"))
|
w.Write([]byte("invalid range"))
|
||||||
@@ -79,7 +79,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
summariesView := v1.NewSummariesFrom([]*models.Summary{summary})
|
summariesView := v1.NewSummariesFrom([]*models.Summary{summary})
|
||||||
utils.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
|
helpers.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
|
||||||
CachedAt: time.Now(),
|
CachedAt: time.Now(),
|
||||||
Data: *summariesView.Data[0],
|
Data: *summariesView.Data[0],
|
||||||
})
|
})
|
||||||
|
@@ -3,6 +3,7 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/duke-git/lancet/v2/datetime"
|
"github.com/duke-git/lancet/v2/datetime"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -76,7 +77,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := v1.NewSummariesFrom(summaries)
|
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) {
|
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||||
@@ -94,24 +95,24 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
var start, end time.Time
|
var start, end time.Time
|
||||||
if rangeParam != "" {
|
if rangeParam != "" {
|
||||||
// range param takes precedence
|
// 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
|
start, end = parsedFrom, parsedTo
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
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
|
// also accept start param to be a range param
|
||||||
start, end = parsedFrom, parsedTo
|
start, end = parsedFrom, parsedTo
|
||||||
} else {
|
} else {
|
||||||
// eventually, consider start and end params a date
|
// eventually, consider start and end params a date
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
|
start, err = helpers.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
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 {
|
if err != nil {
|
||||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
summaries := make([]*models.Summary, len(intervals))
|
summaries := make([]*models.Summary, len(intervals))
|
||||||
|
|
||||||
// filtering
|
// filtering
|
||||||
filters := utils.ParseSummaryFilters(r)
|
filters := helpers.ParseSummaryFilters(r)
|
||||||
|
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervals {
|
||||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))
|
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UsersHandler struct {
|
type UsersHandler struct {
|
||||||
@@ -56,5 +56,5 @@ func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
conf.Log().Request(r).Error("%v", err)
|
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})
|
||||||
}
|
}
|
||||||
|
@@ -3,11 +3,13 @@ package routes
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/schema"
|
"github.com/gorilla/schema"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/models/view"
|
"github.com/muety/wakapi/models/view"
|
||||||
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -45,10 +47,10 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 totalHours int
|
||||||
var totalUsers int
|
var totalUsers int
|
||||||
var newsbox view.Newsbox
|
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 != "" {
|
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{
|
vm := &view.HomeViewModel{
|
||||||
Success: r.URL.Query().Get("success"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
TotalHours: totalHours,
|
TotalHours: totalHours,
|
||||||
TotalUsers: totalUsers,
|
TotalUsers: totalUsers,
|
||||||
Newsbox: &newsbox,
|
Newsbox: &newsbox,
|
||||||
}
|
}
|
||||||
|
return routeutils.WithSessionMessages(vm, r, w)
|
||||||
}
|
}
|
||||||
|
@@ -39,8 +39,5 @@ func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
||||||
return &view.ImprintViewModel{
|
return &view.ImprintViewModel{}
|
||||||
Success: r.URL.Query().Get("success"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,9 @@ import (
|
|||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/models/view"
|
"github.com/muety/wakapi/models/view"
|
||||||
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -37,6 +39,7 @@ func (h *LeaderboardHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
r.Use(
|
r.Use(
|
||||||
middlewares.NewAuthenticateMiddleware(h.userService).
|
middlewares.NewAuthenticateMiddleware(h.userService).
|
||||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||||
|
WithRedirectErrorMessage("unauthorized").
|
||||||
WithOptionalFor([]string{"/"}).
|
WithOptionalFor([]string{"/"}).
|
||||||
Handler,
|
Handler,
|
||||||
)
|
)
|
||||||
@@ -47,15 +50,19 @@ func (h *LeaderboardHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
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())
|
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)
|
user := middlewares.GetPrincipal(r)
|
||||||
byParam := strings.ToLower(r.URL.Query().Get("by"))
|
byParam := strings.ToLower(r.URL.Query().Get("by"))
|
||||||
keyParam := strings.ToLower(r.URL.Query().Get("key"))
|
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 err error
|
||||||
var leaderboard models.Leaderboard
|
var leaderboard models.Leaderboard
|
||||||
@@ -63,20 +70,46 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
|||||||
var topKeys []string
|
var topKeys []string
|
||||||
|
|
||||||
if byParam == "" {
|
if byParam == "" {
|
||||||
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true)
|
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, pageParams, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
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 {
|
} else {
|
||||||
if by, ok := allowedAggregations[byParam]; ok {
|
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 {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
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
|
return item.UserID
|
||||||
})
|
})
|
||||||
userLanguages = map[string][]string{}
|
userLanguages = map[string][]string{}
|
||||||
@@ -93,7 +126,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
|||||||
leaderboard = leaderboard.TopByKey(by, keyParam)
|
leaderboard = leaderboard.TopByKey(by, keyParam)
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
apiKey = user.ApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return &view.LeaderboardViewModel{
|
vm := &view.LeaderboardViewModel{
|
||||||
User: user,
|
User: user,
|
||||||
By: byParam,
|
By: byParam,
|
||||||
Key: keyParam,
|
Key: keyParam,
|
||||||
@@ -110,7 +145,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
|||||||
UserLanguages: userLanguages,
|
UserLanguages: userLanguages,
|
||||||
TopKeys: topKeys,
|
TopKeys: topKeys,
|
||||||
ApiKey: apiKey,
|
ApiKey: apiKey,
|
||||||
Success: r.URL.Query().Get("success"),
|
PageParams: pageParams,
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
}
|
}
|
||||||
|
return routeutils.WithSessionMessages(vm, r, w)
|
||||||
}
|
}
|
||||||
|
@@ -5,8 +5,10 @@ import (
|
|||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/models/view"
|
"github.com/muety/wakapi/models/view"
|
||||||
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -31,13 +33,21 @@ func NewLoginHandler(userService services.IUserService, mailService services.IMa
|
|||||||
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
|
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
|
||||||
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||||
router.Path("/login").Methods(http.MethodPost).HandlerFunc(h.PostLogin)
|
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.MethodGet).HandlerFunc(h.GetSignup)
|
||||||
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
|
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.MethodGet).HandlerFunc(h.GetSetPassword)
|
||||||
router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword)
|
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.MethodGet).HandlerFunc(h.GetResetPassword)
|
||||||
router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword)
|
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) {
|
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
|
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) {
|
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
|
var login models.Login
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userSrvc.GetUserById(login.Username)
|
user, err := h.userSrvc.GetUserById(login.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +102,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to encode secure cookie - %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +118,9 @@ func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user := middlewares.GetPrincipal(r); user != nil {
|
||||||
|
h.userSrvc.FlushUserCache(user.ID)
|
||||||
|
}
|
||||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
||||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
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
|
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) {
|
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 {
|
if !h.config.IsDev() && !h.config.Security.AllowSignup {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,18 +157,18 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
var signup models.Signup
|
var signup models.Signup
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !signup.IsValid() {
|
if !signup.IsValid() {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,23 +178,24 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to create new user - %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
if !created {
|
if !created {
|
||||||
w.WriteHeader(http.StatusConflict)
|
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
|
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) {
|
func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
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) {
|
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")
|
token := values.Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vm := &view.SetPasswordViewModel{
|
vm := &view.SetPasswordViewModel{
|
||||||
LoginViewModel: *h.buildViewModel(r),
|
LoginViewModel: *h.buildViewModel(r, w),
|
||||||
Token: token,
|
Token: token,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,25 +227,25 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
var setRequest models.SetPasswordRequest
|
var setRequest models.SetPasswordRequest
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
|
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
|
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !setRequest.IsValid() {
|
if !setRequest.IsValid() {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
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 {
|
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
||||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
|
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to set new password"))
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
user.Password = hash
|
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 {
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to save new password - %v", err)
|
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
|
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) {
|
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 {
|
if !h.config.Mail.Enabled {
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var resetRequest models.ResetPasswordRequest
|
var resetRequest models.ResetPasswordRequest
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
|
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +298,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
|
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
conf.Log().Request(r).Error("failed to generate password reset token - %v", err)
|
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
|
return
|
||||||
} else {
|
} else {
|
||||||
go func(user *models.User) {
|
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)
|
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()
|
numUsers, _ := h.userSrvc.Count()
|
||||||
|
|
||||||
return &view.LoginViewModel{
|
vm := &view.LoginViewModel{
|
||||||
Success: r.URL.Query().Get("success"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
TotalUsers: int(numUsers),
|
TotalUsers: int(numUsers),
|
||||||
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
||||||
}
|
}
|
||||||
|
return routeutils.WithSessionMessages(vm, r, w)
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/muety/wakapi/helpers"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,16 +24,16 @@ func Init() {
|
|||||||
func DefaultTemplateFuncs() template.FuncMap {
|
func DefaultTemplateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"json": utils.Json,
|
"json": utils.Json,
|
||||||
"date": utils.FormatDateHuman,
|
"date": helpers.FormatDateHuman,
|
||||||
"datetime": utils.FormatDateTimeHuman,
|
"datetime": helpers.FormatDateTimeHuman,
|
||||||
"simpledate": utils.FormatDate,
|
"simpledate": helpers.FormatDate,
|
||||||
"simpledatetime": utils.FormatDateTime,
|
"simpledatetime": helpers.FormatDateTime,
|
||||||
"duration": utils.FmtWakatimeDuration,
|
"duration": helpers.FmtWakatimeDuration,
|
||||||
"floordate": datetime.BeginOfDay,
|
"floordate": datetime.BeginOfDay,
|
||||||
"ceildate": utils.CeilDate,
|
"ceildate": utils.CeilDate,
|
||||||
"title": strings.Title,
|
"title": strings.Title,
|
||||||
"join": strings.Join,
|
"join": strings.Join,
|
||||||
"add": utils.Add,
|
"add": add,
|
||||||
"capitalize": utils.Capitalize,
|
"capitalize": utils.Capitalize,
|
||||||
"lower": strings.ToLower,
|
"lower": strings.ToLower,
|
||||||
"toRunes": utils.ToRunes,
|
"toRunes": utils.ToRunes,
|
||||||
@@ -104,5 +104,9 @@ func loadTemplates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultErrorRedirectTarget() string {
|
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
|
||||||
}
|
}
|
||||||
|
@@ -69,7 +69,10 @@ func NewSettingsHandler(
|
|||||||
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
|
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
|
||||||
r := router.PathPrefix("/settings").Subrouter()
|
r := router.PathPrefix("/settings").Subrouter()
|
||||||
r.Use(
|
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.MethodGet).HandlerFunc(h.GetIndex)
|
||||||
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
|
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() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
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) {
|
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 {
|
if err := r.ParseForm(); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
if actionFunc == nil {
|
if actionFunc == nil {
|
||||||
logbuch.Warn("failed to dispatch action '%s'", action)
|
logbuch.Warn("failed to dispatch action '%s'", action)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +116,15 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if errorMsg != "" {
|
if errorMsg != "" {
|
||||||
w.WriteHeader(status)
|
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
|
return
|
||||||
}
|
}
|
||||||
if successMsg != "" {
|
if successMsg != "" {
|
||||||
w.WriteHeader(status)
|
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
|
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 {
|
func (h *SettingsHandler) dispatchAction(action string) action {
|
||||||
@@ -178,7 +181,11 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !payload.IsValid() {
|
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
|
user.Email = payload.Email
|
||||||
@@ -282,7 +289,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
|
|||||||
var err error
|
var err error
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
defer h.userSrvc.FlushCache()
|
defer h.userSrvc.FlushUserCache(user.ID)
|
||||||
|
|
||||||
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
|
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
|
||||||
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
|
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
|
// Healthcheck, if a new API key is set, i.e. the feature is activated
|
||||||
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
|
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 {
|
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)
|
}(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.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, "", ""
|
return -1, "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,19 +665,19 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) boo
|
|||||||
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
||||||
logbuch.Info("clearing summaries for user '%s'", user.ID)
|
logbuch.Info("clearing summaries for user '%s'", user.ID)
|
||||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil {
|
if err := h.aggregationSrvc.AggregateSummaries(datastructure.NewSet(user.ID)); err != nil {
|
||||||
logbuch.Error("failed to regenerate summaries: %v", err)
|
conf.Log().Error("failed to regenerate summaries: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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)
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
// mappings
|
// mappings
|
||||||
@@ -679,7 +687,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while building alias map - %v", err)
|
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)
|
aliasMap := make(map[string][]*models.Alias)
|
||||||
for _, a := range aliases {
|
for _, a := range aliases {
|
||||||
@@ -708,7 +716,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
|
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
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)
|
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)
|
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
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{
|
// 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,
|
User: user,
|
||||||
LanguageMappings: mappings,
|
LanguageMappings: mappings,
|
||||||
Aliases: combinedAliases,
|
Aliases: combinedAliases,
|
||||||
Labels: combinedLabels,
|
Labels: combinedLabels,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
ApiKey: user.ApiKey,
|
ApiKey: user.ApiKey,
|
||||||
Success: r.URL.Query().Get("success"),
|
UserFirstData: firstData,
|
||||||
Error: r.URL.Query().Get("error"),
|
SubscriptionPrice: subscriptionPrice,
|
||||||
|
SupportContact: h.config.App.SupportContact,
|
||||||
|
DataRetentionMonths: h.config.App.DataRetentionMonths,
|
||||||
}
|
}
|
||||||
|
return routeutils.WithSessionMessages(vm, r, w)
|
||||||
}
|
}
|
||||||
|
347
routes/subscription.go
Normal file
347
routes/subscription.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,15 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/models/view"
|
"github.com/muety/wakapi/models/view"
|
||||||
su "github.com/muety/wakapi/routes/utils"
|
su "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,11 +29,17 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
|
|||||||
|
|
||||||
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
|
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
|
||||||
r1 := router.PathPrefix("/summary").Subrouter()
|
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)
|
r1.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||||
|
|
||||||
r2 := router.PathPrefix("/summary").Subrouter()
|
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)
|
r2.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,23 +51,33 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
rawQuery := r.URL.RawQuery
|
rawQuery := r.URL.RawQuery
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
if q.Get("interval") == "" && q.Get("from") == "" {
|
if q.Get("interval") == "" && q.Get("from") == "" {
|
||||||
q.Set("interval", "today")
|
// If the PersistentIntervalKey cookie is set, redirect to the correct summary page
|
||||||
r.URL.RawQuery = q.Encode()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryParams, _ := utils.ParseSummaryParams(r)
|
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, _ := helpers.ParseSummaryParams(r)
|
||||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
conf.Log().Request(r).Error("failed to load summary - %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +85,9 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
Summary: summary,
|
Summary: summary,
|
||||||
SummaryParams: summaryParams,
|
SummaryParams: summaryParams,
|
||||||
User: user,
|
User: user,
|
||||||
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
EditorColors: su.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||||
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
LanguageColors: su.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||||
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
OSColors: su.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
||||||
ApiKey: user.ApiKey,
|
ApiKey: user.ApiKey,
|
||||||
RawQuery: rawQuery,
|
RawQuery: rawQuery,
|
||||||
}
|
}
|
||||||
@@ -77,9 +95,6 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
templates[conf.SummaryTemplate].Execute(w, vm)
|
templates[conf.SummaryTemplate].Execute(w, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
|
func (h *SummaryHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SummaryViewModel {
|
||||||
return &view.SummaryViewModel{
|
return su.WithSessionMessages(&view.SummaryViewModel{}, r, w)
|
||||||
Success: r.URL.Query().Get("success"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,10 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,18 +31,18 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI
|
|||||||
|
|
||||||
var intervalKey = models.IntervalPast30Days
|
var intervalKey = models.IntervalPast30Days
|
||||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
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
|
intervalKey = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
|
_, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
|
||||||
interval := &models.KeyedInterval{
|
interval := &models.KeyedInterval{
|
||||||
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
|
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
|
||||||
Key: intervalKey,
|
Key: intervalKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
|
minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
|
||||||
// negative value means no limit
|
// negative value means no limit
|
||||||
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||||
return nil, nil, errors.New("requested time range too broad")
|
return nil, nil, errors.New("requested time range too broad")
|
||||||
|
33
routes/utils/messages.go
Normal file
33
routes/utils/messages.go
Normal 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)
|
||||||
|
}
|
@@ -1,14 +1,15 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
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 {
|
if err != nil {
|
||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
}
|
}
|
||||||
@@ -38,3 +39,13 @@ func LoadUserSummaryByParams(ss services.ISummaryService, params *models.Summary
|
|||||||
|
|
||||||
return summary, nil, http.StatusOK
|
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
|
||||||
|
}
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { Collection } = require('@iconify/json-tools')
|
const { Collection } = require('@iconify/json-tools')
|
||||||
|
const { locate } = require("@iconify/json");
|
||||||
|
|
||||||
let icons = [
|
let icons = [
|
||||||
'fxemoji:key',
|
'fxemoji:key',
|
||||||
@@ -70,7 +71,13 @@ let icons = [
|
|||||||
'mdi:language-rust',
|
'mdi:language-rust',
|
||||||
'mdi:language-swift',
|
'mdi:language-swift',
|
||||||
'mdi:language-typescript',
|
'mdi:language-typescript',
|
||||||
|
'mdi:language-markdown',
|
||||||
|
'mdi:vuejs',
|
||||||
|
'mdi:react',
|
||||||
|
'mdi:code-json',
|
||||||
|
'mdi:bash',
|
||||||
'twemoji:frowning-face',
|
'twemoji:frowning-face',
|
||||||
|
'ci:dot-03-m'
|
||||||
]
|
]
|
||||||
|
|
||||||
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
|
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
|
||||||
@@ -102,7 +109,7 @@ icons.forEach(icon => {
|
|||||||
let code = ''
|
let code = ''
|
||||||
Object.keys(filtered).forEach(prefix => {
|
Object.keys(filtered).forEach(prefix => {
|
||||||
let collection = new Collection()
|
let collection = new Collection()
|
||||||
if (!collection.loadIconifyCollection(prefix)) {
|
if (!collection.loadFromFile(locate(prefix))) {
|
||||||
console.error('Error loading collection', prefix)
|
console.error('Error loading collection', prefix)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
55
scripts/email_checker.go
Normal file
55
scripts/email_checker.go
Normal 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
58
scripts/send_mail.py
Normal 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)
|
@@ -4,12 +4,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron"
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +24,8 @@ type AggregationService struct {
|
|||||||
summaryService ISummaryService
|
summaryService ISummaryService
|
||||||
heartbeatService IHeartbeatService
|
heartbeatService IHeartbeatService
|
||||||
inProgress datastructure.Set[string]
|
inProgress datastructure.Set[string]
|
||||||
|
queueDefault *artifex.Dispatcher
|
||||||
|
queueWorkers *artifex.Dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||||
@@ -34,6 +35,8 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
|||||||
summaryService: summaryService,
|
summaryService: summaryService,
|
||||||
heartbeatService: heartbeatService,
|
heartbeatService: heartbeatService,
|
||||||
inProgress: datastructure.NewSet[string](),
|
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
|
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||||
func (srv *AggregationService) Schedule() {
|
func (srv *AggregationService) Schedule() {
|
||||||
s := gocron.NewScheduler(time.Local)
|
logbuch.Info("scheduling summary aggregation")
|
||||||
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
|
|
||||||
s.StartBlocking()
|
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 {
|
if err := srv.lockUsers(userIds); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer srv.unlockUsers(userIds)
|
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")
|
logbuch.Info("generating summaries")
|
||||||
|
|
||||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
// 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
|
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
|
// Generate summary aggregation jobs
|
||||||
for _, e := range lastUserSummaryTimes {
|
for _, e := range lastUserSummaryTimes {
|
||||||
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
|
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
|
||||||
@@ -141,24 +123,15 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
|
func (srv *AggregationService) process(job AggregationJob) {
|
||||||
aggregationLock.Lock()
|
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||||
defer aggregationLock.Unlock()
|
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||||
for uid := range userIds {
|
} else {
|
||||||
if srv.inProgress.Contain(uid) {
|
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||||
return errors.New("aggregation already in progress for at least of the request users")
|
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) {
|
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 {
|
func getStartOfToday() time.Time {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location())
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location())
|
||||||
|
@@ -192,6 +192,11 @@ func (srv *HeartbeatService) DeleteByUser(user *models.User) error {
|
|||||||
return srv.repository.DeleteByUser(user)
|
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) {
|
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
82
services/housekeeping.go
Normal file
82
services/housekeeping.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
@@ -7,8 +7,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/duke-git/lancet/v2/datetime"
|
"github.com/duke-git/lancet/v2/datetime"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
@@ -30,18 +32,24 @@ const (
|
|||||||
|
|
||||||
type WakatimeHeartbeatImporter struct {
|
type WakatimeHeartbeatImporter struct {
|
||||||
ApiKey string
|
ApiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
queue *artifex.Dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
|
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
|
||||||
return &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 {
|
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
|
||||||
out := make(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)
|
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
|
||||||
|
|
||||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||||
@@ -57,14 +65,20 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
endDate = maxTo
|
endDate = maxTo
|
||||||
}
|
}
|
||||||
|
|
||||||
userAgents, err := w.fetchUserAgents(baseUrl)
|
userAgents := map[string]*wakatime.UserAgentEntry{}
|
||||||
if err != nil {
|
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)
|
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
machinesNames, err := w.fetchMachineNames(baseUrl)
|
machinesNames := map[string]*wakatime.MachineEntry{}
|
||||||
if err != nil {
|
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)
|
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,7 +102,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
d := day.Format(config.SimpleDateFormat)
|
d := day.Format(config.SimpleDateFormat)
|
||||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||||
if err != nil {
|
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 {
|
for _, h := range heartbeats {
|
||||||
@@ -100,7 +114,18 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
|||||||
}
|
}
|
||||||
}(d)
|
}(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
|
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://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||||
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
|
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)
|
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,12 +146,13 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
|
|||||||
q.Add("date", day)
|
q.Add("date", day)
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
res, err := httpClient.Do(w.withHeaders(req))
|
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if res.StatusCode >= 400 {
|
} else if res.StatusCode >= 400 {
|
||||||
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
||||||
}
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||||
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
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://wakatime.com/api/v1/users/current/all_time_since_today
|
||||||
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||||
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
notime := time.Time{}
|
notime := time.Time{}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
|
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
|
return notime, notime, err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := httpClient.Do(w.withHeaders(req))
|
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notime, notime, err
|
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://wakatime.com/api/v1/users/current/user_agents
|
||||||
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||||
|
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
@@ -188,10 +208,11 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := httpClient.Do(w.withHeaders(req))
|
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
var userAgentsData wakatime.UserAgentsViewModel
|
var userAgentsData wakatime.UserAgentsViewModel
|
||||||
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
var machineData wakatime.MachineViewModel
|
var machineData wakatime.MachineViewModel
|
||||||
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
||||||
@@ -259,11 +281,19 @@ func mapHeartbeat(
|
|||||||
) *models.Heartbeat {
|
) *models.Heartbeat {
|
||||||
ua := userAgents[entry.UserAgentId]
|
ua := userAgents[entry.UserAgentId]
|
||||||
if ua == nil {
|
if ua == nil {
|
||||||
|
// try to parse id as an actual user agent string (as returned by wakapi)
|
||||||
|
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
|
||||||
|
ua = &wakatime.UserAgentEntry{
|
||||||
|
Editor: opSys,
|
||||||
|
Os: editor,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ua = &wakatime.UserAgentEntry{
|
ua = &wakatime.UserAgentEntry{
|
||||||
Editor: "unknown",
|
Editor: "unknown",
|
||||||
Os: "unknown",
|
Os: "unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ma := machineNames[entry.MachineNameId]
|
ma := machineNames[entry.MachineNameId]
|
||||||
if ma == nil {
|
if ma == nil {
|
||||||
|
@@ -22,6 +22,10 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
|
|||||||
return srv.repository.GetString(key)
|
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 {
|
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
|
||||||
kv, err := srv.repository.GetString(key)
|
kv, err := srv.repository.GetString(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@@ -2,14 +2,16 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/go-co-op/gocron"
|
|
||||||
"github.com/leandro-lugaresi/hub"
|
"github.com/leandro-lugaresi/hub"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,6 +23,8 @@ type LeaderboardService struct {
|
|||||||
repository repositories.ILeaderboardRepository
|
repository repositories.ILeaderboardRepository
|
||||||
summaryService ISummaryService
|
summaryService ISummaryService
|
||||||
userService IUserService
|
userService IUserService
|
||||||
|
queueDefault *artifex.Dispatcher
|
||||||
|
queueWorkers *artifex.Dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
|
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
|
||||||
@@ -31,6 +35,8 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
|||||||
repository: leaderboardRepo,
|
repository: leaderboardRepo,
|
||||||
summaryService: summaryService,
|
summaryService: summaryService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
|
queueDefault: config.GetDefaultQueue(),
|
||||||
|
queueWorkers: config.GetQueue(config.QueueProcessing),
|
||||||
}
|
}
|
||||||
|
|
||||||
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
||||||
@@ -47,7 +53,7 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
|||||||
|
|
||||||
if user.PublicLeaderboard && !exists {
|
if user.PublicLeaderboard && !exists {
|
||||||
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
|
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 {
|
} else if !user.PublicLeaderboard && exists {
|
||||||
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
|
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
|
||||||
if err := srv.repository.DeleteByUser(user.ID); err != nil {
|
if err := srv.repository.DeleteByUser(user.ID); err != nil {
|
||||||
@@ -61,23 +67,26 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
|||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *LeaderboardService) ScheduleDefault() {
|
func (srv *LeaderboardService) Schedule() {
|
||||||
runAllUsers := func(interval *models.IntervalKey, by []uint8) {
|
logbuch.Info("scheduling leaderboard generation")
|
||||||
|
|
||||||
|
generate := func() {
|
||||||
users, err := srv.userService.GetAllByLeaderboard(true)
|
users, err := srv.userService.GetAllByLeaderboard(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to get users for leaderboard generation - %v", err)
|
config.Log().Error("failed to get users for leaderboard generation - %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
srv.ComputeLeaderboard(users, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||||
srv.Run(users, interval, by)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := gocron.NewScheduler(time.Local)
|
for _, cronExp := range srv.config.App.GetLeaderboardGenerationTimeCron() {
|
||||||
s.Every(1).Day().At(srv.config.App.LeaderboardGenerationTime).Do(runAllUsers, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
if _, err := srv.queueDefault.DispatchCron(generate, cronExp); err != nil {
|
||||||
s.StartBlocking()
|
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))
|
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
|
||||||
|
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
@@ -125,25 +134,41 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
|
|||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) {
|
func (srv *LeaderboardService) CountUsers() (int64, error) {
|
||||||
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
|
|
||||||
// check cache
|
// check cache
|
||||||
cacheKey := srv.getHash(interval, by)
|
cacheKey := "count_total"
|
||||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolveUsers {
|
if resolveUsers {
|
||||||
a := models.Leaderboard(items).UserIDs()
|
|
||||||
println(a)
|
|
||||||
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
|
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Error("failed to resolve users for leaderboard item - %v", err)
|
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
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,10 +260,13 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string {
|
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *utils.PageParams) string {
|
||||||
k := strings.Join(*interval, "__")
|
k := strings.Join(*interval, "__") + "__" + user
|
||||||
if by != nil && !reflect.ValueOf(by).IsNil() {
|
if by != nil && !reflect.ValueOf(by).IsNil() {
|
||||||
k += "__" + models.GetEntityColumn(*by)
|
k += "__" + models.GetEntityColumn(*by)
|
||||||
}
|
}
|
||||||
|
if pageParams != nil {
|
||||||
|
k += "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
|
||||||
|
}
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ package mail
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/routes"
|
"github.com/muety/wakapi/routes"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
@@ -18,10 +19,12 @@ const (
|
|||||||
tplNameImportNotification = "import_finished"
|
tplNameImportNotification = "import_finished"
|
||||||
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
|
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
|
||||||
tplNameReport = "report"
|
tplNameReport = "report"
|
||||||
|
tplNameSubscriptionNotification = "subscription_expiring"
|
||||||
subjectPasswordReset = "Wakapi - Password Reset"
|
subjectPasswordReset = "Wakapi - Password Reset"
|
||||||
subjectImportNotification = "Wakapi - Data Import Finished"
|
subjectImportNotification = "Wakapi - Data Import Finished"
|
||||||
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
|
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
|
||||||
subjectReport = "Wakapi - Report from %s"
|
subjectReport = "Wakapi - Report from %s"
|
||||||
|
subjectSubscriptionNotification = "Wakapi - Subscription expiring / expired"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendingService interface {
|
type SendingService interface {
|
||||||
@@ -115,7 +118,25 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
|
|||||||
mail := &models.Mail{
|
mail := &models.Mail{
|
||||||
From: models.MailAddress(m.config.Mail.Sender),
|
From: models.MailAddress(m.config.Mail.Sender),
|
||||||
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
|
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())
|
mail.WithHTML(tpl.String())
|
||||||
return m.sendingService.Send(mail)
|
return m.sendingService.Send(mail)
|
||||||
@@ -153,6 +174,14 @@ func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, erro
|
|||||||
return &rendered, nil
|
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 {
|
func (m *MailService) fmtName(name string) string {
|
||||||
return fmt.Sprintf("%s.tpl.html", name)
|
return fmt.Sprintf("%s.tpl.html", name)
|
||||||
}
|
}
|
||||||
|
@@ -42,9 +42,14 @@ func (s *SMTPSendingService) Send(mail *models.Mail) error {
|
|||||||
|
|
||||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||||
if err = c.StartTLS(nil); err != nil {
|
if err = c.StartTLS(nil); err != nil {
|
||||||
|
errCode := err.(*smtp.SMTPError).Code
|
||||||
|
if errCode == 503 {
|
||||||
|
// TLS already active
|
||||||
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
if ok, _ := c.Extension("AUTH"); !ok {
|
if ok, _ := c.Extension("AUTH"); !ok {
|
||||||
return errors.New("smtp: server doesn't support AUTH")
|
return errors.New("smtp: server doesn't support AUTH")
|
||||||
|
@@ -20,3 +20,9 @@ type WakatimeFailureNotificationNotificationTplData struct {
|
|||||||
type ReportTplData struct {
|
type ReportTplData struct {
|
||||||
Report *models.Report
|
Report *models.Report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscriptionNotificationTplData struct {
|
||||||
|
PublicUrl string
|
||||||
|
HasExpired bool
|
||||||
|
DataRetentionMonths int
|
||||||
|
}
|
||||||
|
267
services/misc.go
267
services/misc.go
@@ -1,104 +1,269 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"runtime"
|
"github.com/muety/wakapi/utils"
|
||||||
|
"go.uber.org/atomic"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron"
|
|
||||||
"github.com/muety/wakapi/models"
|
"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 {
|
type MiscService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
userService IUserService
|
userService IUserService
|
||||||
|
heartbeatService IHeartbeatService
|
||||||
summaryService ISummaryService
|
summaryService ISummaryService
|
||||||
keyValueService IKeyValueService
|
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{
|
return &MiscService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
userService: userService,
|
userService: userService,
|
||||||
|
heartbeatService: heartbeatService,
|
||||||
summaryService: summaryService,
|
summaryService: summaryService,
|
||||||
keyValueService: keyValueService,
|
keyValueService: keyValueService,
|
||||||
|
mailService: mailService,
|
||||||
|
queueDefault: config.GetDefaultQueue(),
|
||||||
|
queueWorkers: config.GetQueue(config.QueueProcessing),
|
||||||
|
queueMails: config.GetQueue(config.QueueMails),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountTotalTimeJob struct {
|
func (srv *MiscService) Schedule() {
|
||||||
UserID string
|
logbuch.Info("scheduling total time counting")
|
||||||
NumJobs int
|
if _, err := srv.queueDefault.DispatchEvery(srv.CountTotalTime, countUsersEvery); err != nil {
|
||||||
|
config.Log().Error("failed to schedule user counting jobs, %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountTotalTimeResult struct {
|
func (srv *MiscService) CountTotalTime() {
|
||||||
UserId string
|
logbuch.Info("counting users total time")
|
||||||
Total time.Duration
|
if ok := countLock.TryLock(); !ok {
|
||||||
}
|
config.Log().Warn("couldn't acquire lock for counting users total time, job is still pending")
|
||||||
|
}
|
||||||
|
defer countLock.Unlock()
|
||||||
|
|
||||||
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()
|
users, err := srv.userService.GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
config.Log().Error("failed to fetch users for time counting, %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs := make(chan *CountTotalTimeJob, len(users))
|
var totalTime = atomic.NewDuration(0)
|
||||||
results := make(chan *CountTotalTimeResult, len(users))
|
var pendingJobs sync.WaitGroup
|
||||||
|
pendingJobs.Add(len(users))
|
||||||
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
jobs <- &CountTotalTimeJob{
|
user := *u
|
||||||
UserID: u.ID,
|
if err := srv.queueWorkers.Dispatch(func() {
|
||||||
NumJobs: len(users),
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(jobs)
|
|
||||||
|
|
||||||
for i := 0; i < runtime.NumCPU(); i++ {
|
|
||||||
go srv.countTotalTimeWorker(jobs, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
// persist
|
// persist
|
||||||
var i int
|
go func(wg *sync.WaitGroup) {
|
||||||
var total time.Duration
|
if !utils.WaitTimeout(&pendingJobs, 2*countUsersEvery) {
|
||||||
for i = 0; i < len(users); i++ {
|
|
||||||
result := <-results
|
|
||||||
total += result.Total
|
|
||||||
}
|
|
||||||
close(results)
|
|
||||||
|
|
||||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||||
Key: config.KeyLatestTotalTime,
|
Key: config.KeyLatestTotalTime,
|
||||||
Value: total.String(),
|
Value: totalTime.Load().String(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logbuch.Error("failed to save total time count: %v", err)
|
config.Log().Error("failed to save total time count: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||||
Key: config.KeyLatestTotalUsers,
|
Key: config.KeyLatestTotalUsers,
|
||||||
Value: strconv.Itoa(i),
|
Value: strconv.Itoa(len(users)),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logbuch.Error("failed to save total users count: %v", err)
|
config.Log().Error("failed to save total users count: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
results <- &CountTotalTimeResult{
|
config.Log().Error("waiting for user counting jobs timed out")
|
||||||
UserId: job.UserID,
|
|
||||||
Total: result.TotalTime(),
|
|
||||||
}
|
}
|
||||||
|
}(&pendingJobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
@@ -1,21 +1,21 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/go-co-op/gocron"
|
|
||||||
"github.com/leandro-lugaresi/hub"
|
"github.com/leandro-lugaresi/hub"
|
||||||
|
"github.com/muety/artifex/v2"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
|
||||||
"time"
|
"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
|
// past time range to cover in the report
|
||||||
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
|
const reportRange = 7 * 24 * time.Hour
|
||||||
const offsetIntervalMin = 15
|
|
||||||
|
|
||||||
type ReportService struct {
|
type ReportService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
@@ -23,8 +23,9 @@ type ReportService struct {
|
|||||||
summaryService ISummaryService
|
summaryService ISummaryService
|
||||||
userService IUserService
|
userService IUserService
|
||||||
mailService IMailService
|
mailService IMailService
|
||||||
scheduler *gocron.Scheduler
|
|
||||||
rand *rand.Rand
|
rand *rand.Rand
|
||||||
|
queueDefault *artifex.Dispatcher
|
||||||
|
queueWorkers *artifex.Dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
|
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
|
||||||
@@ -34,80 +35,67 @@ func NewReportService(summaryService ISummaryService, userService IUserService,
|
|||||||
summaryService: summaryService,
|
summaryService: summaryService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
mailService: mailService,
|
mailService: mailService,
|
||||||
scheduler: gocron.NewScheduler(time.Local),
|
|
||||||
rand: rand.New(rand.NewSource(time.Now().Unix())),
|
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
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *ReportService) Schedule() {
|
func (srv *ReportService) Schedule() {
|
||||||
logbuch.Info("initializing report service")
|
logbuch.Info("scheduling report generation")
|
||||||
|
|
||||||
|
scheduleUserReport := func(u *models.User) {
|
||||||
|
if err := srv.queueWorkers.Dispatch(func() {
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
if err := srv.SendReport(u, reportRange); err != nil {
|
||||||
|
config.Log().Error("failed to generate report for '%s', %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := srv.queueDefault.DispatchCron(func() {
|
||||||
|
// fetch all users with reports enabled
|
||||||
users, err := srv.userService.GetAllByReports(true)
|
users, err := srv.userService.GetAllByReports(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
config.Log().Fatal("%v", err)
|
config.Log().Error("failed to get users for report generation, %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logbuch.Info("scheduling reports for %d users", len(users))
|
// 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 {
|
for _, u := range users {
|
||||||
srv.SyncSchedule(u)
|
scheduleUserReport(u)
|
||||||
|
}
|
||||||
|
}, srv.config.App.GetWeeklyReportCron())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
config.Log().Error("failed to dispatch report generation jobs, %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncSchedule syncs the currently active schedulers with the user's wish about whether or not to receive reports.
|
func (srv *ReportService) SendReport(user *models.User, duration time.Duration) error {
|
||||||
// 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return u.ReportsWeekly
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
|
||||||
if user.Email == "" {
|
if user.Email == "" {
|
||||||
logbuch.Warn("not generating report for '%s' as no e-mail address is set")
|
logbuch.Warn("not generating report for '%s' as no e-mail address is set")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !srv.SyncSchedule(user) {
|
logbuch.Info("generating report for '%s'", user.ID)
|
||||||
logbuch.Info("reports for user '%s' were turned off in the meanwhile since last report job ran")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
end := time.Now().In(user.TZ())
|
end := time.Now().In(user.TZ())
|
||||||
start := time.Now().Add(-1 * duration)
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logbuch.Info("sent report to user '%s'", user.ID)
|
logbuch.Info("sent report to user '%s'", user.ID)
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
@@ -3,16 +3,18 @@ package services
|
|||||||
import (
|
import (
|
||||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IAggregationService interface {
|
type IAggregationService interface {
|
||||||
Schedule()
|
Schedule()
|
||||||
Run(set datastructure.Set[string]) error
|
AggregateSummaries(set datastructure.Set[string]) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IMiscService interface {
|
type IMiscService interface {
|
||||||
ScheduleCountTotalTime()
|
Schedule()
|
||||||
|
CountTotalTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
type IAliasService interface {
|
type IAliasService interface {
|
||||||
@@ -41,6 +43,7 @@ type IHeartbeatService interface {
|
|||||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
DeleteByUser(*models.User) error
|
DeleteByUser(*models.User) error
|
||||||
|
DeleteByUserBefore(*models.User, time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDiagnosticsService interface {
|
type IDiagnosticsService interface {
|
||||||
@@ -50,6 +53,7 @@ type IDiagnosticsService interface {
|
|||||||
type IKeyValueService interface {
|
type IKeyValueService interface {
|
||||||
GetString(string) (*models.KeyStringValue, error)
|
GetString(string) (*models.KeyStringValue, error)
|
||||||
MustGetString(string) *models.KeyStringValue
|
MustGetString(string) *models.KeyStringValue
|
||||||
|
GetByPrefix(string) ([]*models.KeyStringValue, error)
|
||||||
PutString(*models.KeyStringValue) error
|
PutString(*models.KeyStringValue) error
|
||||||
DeleteString(string) error
|
DeleteString(string) error
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,7 @@ type IMailService interface {
|
|||||||
SendWakatimeFailureNotification(*models.User, int) error
|
SendWakatimeFailureNotification(*models.User, int) error
|
||||||
SendImportNotification(*models.User, time.Duration, int) error
|
SendImportNotification(*models.User, time.Duration, int) error
|
||||||
SendReport(*models.User, *models.Report) error
|
SendReport(*models.User, *models.Report) error
|
||||||
|
SendSubscriptionNotification(*models.User, bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDurationService interface {
|
type IDurationService interface {
|
||||||
@@ -88,21 +93,29 @@ type ISummaryService interface {
|
|||||||
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
|
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
|
||||||
GetLatestByUser() ([]*models.TimeByUser, error)
|
GetLatestByUser() ([]*models.TimeByUser, error)
|
||||||
DeleteByUser(string) error
|
DeleteByUser(string) error
|
||||||
|
DeleteByUserBefore(string, time.Time) error
|
||||||
Insert(*models.Summary) error
|
Insert(*models.Summary) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IReportService interface {
|
type IReportService interface {
|
||||||
Schedule()
|
Schedule()
|
||||||
SyncSchedule(user *models.User) bool
|
SendReport(*models.User, time.Duration) error
|
||||||
Run(*models.User, time.Duration) error
|
}
|
||||||
|
|
||||||
|
type IHousekeepingService interface {
|
||||||
|
Schedule()
|
||||||
|
CleanUserDataBefore(*models.User, time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ILeaderboardService interface {
|
type ILeaderboardService interface {
|
||||||
ScheduleDefault()
|
Schedule()
|
||||||
Run([]*models.User, *models.IntervalKey, []uint8) error
|
ComputeLeaderboard([]*models.User, *models.IntervalKey, []uint8) error
|
||||||
ExistsAnyByUser(string) (bool, error)
|
ExistsAnyByUser(string) (bool, error)
|
||||||
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error)
|
CountUsers() (int64, error)
|
||||||
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error)
|
GetByInterval(*models.IntervalKey, *utils.PageParams, bool) (models.Leaderboard, error)
|
||||||
|
GetByIntervalAndUser(*models.IntervalKey, string, bool) (models.Leaderboard, error)
|
||||||
|
GetAggregatedByInterval(*models.IntervalKey, *uint8, *utils.PageParams, bool) (models.Leaderboard, error)
|
||||||
|
GetAggregatedByIntervalAndUser(*models.IntervalKey, string, *uint8, bool) (models.Leaderboard, error)
|
||||||
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
|
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
|
||||||
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
|
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
|
||||||
}
|
}
|
||||||
@@ -112,6 +125,7 @@ type IUserService interface {
|
|||||||
GetUserByKey(string) (*models.User, error)
|
GetUserByKey(string) (*models.User, error)
|
||||||
GetUserByEmail(string) (*models.User, error)
|
GetUserByEmail(string) (*models.User, error)
|
||||||
GetUserByResetToken(string) (*models.User, error)
|
GetUserByResetToken(string) (*models.User, error)
|
||||||
|
GetUserByStripeCustomerId(string) (*models.User, error)
|
||||||
GetAll() ([]*models.User, error)
|
GetAll() ([]*models.User, error)
|
||||||
GetMany([]string) ([]*models.User, error)
|
GetMany([]string) ([]*models.User, error)
|
||||||
GetManyMapped([]string) (map[string]*models.User, error)
|
GetManyMapped([]string) (map[string]*models.User, error)
|
||||||
@@ -127,4 +141,5 @@ type IUserService interface {
|
|||||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||||
GenerateResetToken(*models.User) (*models.User, error)
|
GenerateResetToken(*models.User) (*models.User, error)
|
||||||
FlushCache()
|
FlushCache()
|
||||||
|
FlushUserCache(string)
|
||||||
}
|
}
|
||||||
|
@@ -208,6 +208,11 @@ func (srv *SummaryService) DeleteByUser(userId string) error {
|
|||||||
return srv.repository.DeleteByUser(userId)
|
return srv.repository.DeleteByUser(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *SummaryService) DeleteByUserBefore(userId string, t time.Time) error {
|
||||||
|
srv.invalidateUserCache(userId)
|
||||||
|
return srv.repository.DeleteByUserBefore(userId, t)
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||||
srv.invalidateUserCache(summary.UserID)
|
srv.invalidateUserCache(summary.UserID)
|
||||||
return srv.repository.Insert(summary)
|
return srv.repository.Insert(summary)
|
||||||
|
@@ -62,12 +62,12 @@ func (srv *UserService) GetUserById(userId string) (*models.User, error) {
|
|||||||
return u.(*models.User), nil
|
return u.(*models.User), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := srv.repository.GetById(userId)
|
u, err := srv.repository.FindOne(models.User{ID: userId})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
|
srv.cache.SetDefault(u.ID, u)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
|||||||
return u.(*models.User), nil
|
return u.(*models.User), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := srv.repository.GetByApiKey(key)
|
u, err := srv.repository.FindOne(models.User{ApiKey: key})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -86,11 +86,15 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) GetUserByEmail(email string) (*models.User, error) {
|
func (srv *UserService) GetUserByEmail(email string) (*models.User, error) {
|
||||||
return srv.repository.GetByEmail(email)
|
return srv.repository.FindOne(models.User{Email: email})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) GetUserByResetToken(resetToken string) (*models.User, error) {
|
func (srv *UserService) GetUserByResetToken(resetToken string) (*models.User, error) {
|
||||||
return srv.repository.GetByResetToken(resetToken)
|
return srv.repository.FindOne(models.User{ResetToken: resetToken})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *UserService) GetUserByStripeCustomerId(customerId string) (*models.User, error) {
|
||||||
|
return srv.repository.FindOne(models.User{StripeCustomerId: customerId})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) GetAll() ([]*models.User, error) {
|
func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||||
@@ -163,19 +167,19 @@ func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*model
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
||||||
srv.cache.Flush()
|
srv.FlushUserCache(user.ID)
|
||||||
srv.notifyUpdate(user)
|
srv.notifyUpdate(user)
|
||||||
return srv.repository.Update(user)
|
return srv.repository.Update(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||||
srv.cache.Flush()
|
srv.FlushUserCache(user.ID)
|
||||||
user.ApiKey = uuid.NewV4().String()
|
user.ApiKey = uuid.NewV4().String()
|
||||||
return srv.Update(user)
|
return srv.Update(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey string, apiUrl string) (*models.User, error) {
|
func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey string, apiUrl string) (*models.User, error) {
|
||||||
srv.cache.Flush()
|
srv.FlushUserCache(user.ID)
|
||||||
|
|
||||||
if apiKey != user.WakatimeApiKey {
|
if apiKey != user.WakatimeApiKey {
|
||||||
if u, err := srv.repository.UpdateField(user, "wakatime_api_key", apiKey); err != nil {
|
if u, err := srv.repository.UpdateField(user, "wakatime_api_key", apiKey); err != nil {
|
||||||
@@ -191,7 +195,7 @@ func (srv *UserService) SetWakatimeApiCredentials(user *models.User, apiKey stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||||
srv.cache.Flush()
|
srv.FlushUserCache(user.ID)
|
||||||
user.Password = login.Password
|
user.Password = login.Password
|
||||||
if hash, err := utils.HashBcrypt(user.Password, srv.config.Security.PasswordSalt); err != nil {
|
if hash, err := utils.HashBcrypt(user.Password, srv.config.Security.PasswordSalt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -206,7 +210,7 @@ func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) Delete(user *models.User) error {
|
func (srv *UserService) Delete(user *models.User) error {
|
||||||
srv.cache.Flush()
|
srv.FlushUserCache(user.ID)
|
||||||
|
|
||||||
user.ReportsWeekly = false
|
user.ReportsWeekly = false
|
||||||
srv.notifyUpdate(user)
|
srv.notifyUpdate(user)
|
||||||
@@ -218,6 +222,10 @@ func (srv *UserService) FlushCache() {
|
|||||||
srv.cache.Flush()
|
srv.cache.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *UserService) FlushUserCache(userId string) {
|
||||||
|
srv.cache.Delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *UserService) notifyUpdate(user *models.User) {
|
func (srv *UserService) notifyUpdate(user *models.User) {
|
||||||
srv.eventBus.Publish(hub.Message{
|
srv.eventBus.Publish(hub.Message{
|
||||||
Name: config.EventUserUpdate,
|
Name: config.EventUserUpdate,
|
||||||
|
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user