mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
71 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 | |||
e89ce076fd | |||
ba81c07345 |
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.19
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -22,7 +22,7 @@ jobs:
|
||||
run: go get
|
||||
|
||||
- name: Unit Tests
|
||||
run: go test ./... -run ./...
|
||||
run: CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -run ./... # skip scripts package, because not actually a package
|
||||
|
||||
- name: API Tests
|
||||
run: |
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.19
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
@ -90,7 +90,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.19
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@ -101,3 +101,24 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
|
||||
migration:
|
||||
name: Migration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: [sqlite, postgres, mysql, mariadb]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.19
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- run: ./testing/run_api_tests.sh ${{ matrix.db }} --migration
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.19
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,9 +5,10 @@ wakapi
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
*.zip
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
!testing/config.testing.yml
|
||||
!testing/config.*.yml
|
||||
pkged.go
|
||||
package-lock.json
|
||||
node_modules
|
||||
node_modules
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM golang:1.18-alpine AS build-env
|
||||
FROM golang:1.19-alpine AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||
|
15
README.md
15
README.md
@ -36,7 +36,7 @@ Installation instructions can be found below and in the [Wiki](https://github.co
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
* ✅ 100 % free and open-source
|
||||
* ✅ Free and open-source
|
||||
* ✅ Built by developers for developers
|
||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||
* ✅ Badges
|
||||
@ -109,7 +109,7 @@ $ ./wakapi -config wakapi.yml
|
||||
|
||||
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
|
||||
|
||||
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
|
||||
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
|
||||
|
||||
### 💻 Client setup
|
||||
|
||||
@ -137,13 +137,17 @@ You can specify configuration options either via a config file (default: `config
|
||||
| YAML key / Env. variable | Default | Description |
|
||||
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
|
||||
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
|
||||
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
|
||||
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
|
||||
| `app.leaderboard_generation_time` /<br>`WAKAPI_LEADERBOARD_GENERATION_TIME` | `0 0 6 * * *,0 0 18 * * *` | One or multiple times of day at which to re-calculate the leaderboard |
|
||||
| `app.data_cleanup_time` /<br>`WAKAPI_DATA_CLEANUP_TIME` | `0 0 6 * * 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.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
|
||||
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
|
||||
| `app.custom_languages` | - | Map from file endings to language names |
|
||||
| `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
|
||||
| `app.support_contact` /<br>`WAKAPI_SUPPORT_CONTACT` | `hostmaster@wakapi.dev` | E-Mail address to display as a support contact on the page |
|
||||
| `app.data_retention_months` /<br>`WAKAPI_DATA_RETENTION_MONTHS` | `-1` | Maximum retention period in months for user data (heartbeats) (-1 for unlimited) |
|
||||
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
@ -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` |
|
||||
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
|
||||
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
|
||||
| `db.socket` /<br> `WAKAPI_DB_SOCKET` | - | Database UNIX socket (alternative to `host`) (for MySQL only) |
|
||||
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
|
||||
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||
@ -307,7 +312,7 @@ Unit tests are supposed to test business logic on a fine-grained level. They are
|
||||
#### How to run
|
||||
|
||||
```bash
|
||||
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
$ CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
### API tests
|
||||
|
@ -1,4 +1,6 @@
|
||||
env: production
|
||||
quick_start: false # whether to skip initial tasks on application startup, like summary generation
|
||||
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
@ -12,12 +14,14 @@ server:
|
||||
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
leaderboard_generation_time: '06:00;18:00' # time at which to run daily aggregation batch jobs
|
||||
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
|
||||
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
|
||||
aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
|
||||
leaderboard_generation_time: '0 0 6 * * *,0 0 18 * * *' # times at which to re-calculate the leaderboard
|
||||
report_time_weekly: '0 0 18 * * 5' # time at which to fan out weekly reports (extended cron)
|
||||
data_cleanup_time: '0 0 6 * * 7' # time at which to run old data cleanup (if enabled through data_retention_months)
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
|
||||
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
|
||||
data_retention_months: -1 # maximum retention period on months for user data (heartbeats) (-1 for infinity)
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
@ -34,6 +38,7 @@ app:
|
||||
db:
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
socket: # alternative to db.host (leave blank when using sqlite3)
|
||||
user: # leave blank when using sqlite3
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
@ -41,7 +46,7 @@ db:
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
|
||||
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
|
||||
security:
|
||||
password_salt: # change this
|
||||
@ -57,6 +62,15 @@ sentry:
|
||||
sample_rate: 0.75 # probability of tracing a request
|
||||
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
|
||||
|
||||
# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments
|
||||
subscriptions:
|
||||
enabled: false
|
||||
expiry_notifications: true
|
||||
stripe_api_key:
|
||||
stripe_secret_key:
|
||||
stripe_endpoint_secret:
|
||||
standard_price_id:
|
||||
|
||||
mail:
|
||||
enabled: true # whether to enable mails (used for password resets, reports, etc.)
|
||||
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
|
||||
@ -74,7 +88,4 @@ mail:
|
||||
mailwhale:
|
||||
url:
|
||||
client_id:
|
||||
client_secret:
|
||||
|
||||
quick_start: false # whether to skip initial tasks on application startup, like summary generation
|
||||
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
|
||||
client_secret:
|
251
config/config.go
251
config/config.go
@ -4,10 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/robfig/cron/v3"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -15,9 +16,8 @@ import (
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/data"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -27,10 +27,14 @@ const (
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
KeyLastImportImport = "last_import"
|
||||
KeyNewsbox = "newsbox"
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
KeyLastImportImport = "last_import"
|
||||
KeyFirstHeartbeat = "first_heartbeat"
|
||||
KeySubscriptionNotificationSent = "sub_reminder"
|
||||
KeyNewsbox = "newsbox"
|
||||
|
||||
SessionKeyDefault = "default"
|
||||
|
||||
SimpleDateFormat = "2006-01-02"
|
||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||
@ -65,15 +69,18 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||
var env string
|
||||
|
||||
type appConfig struct {
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"06:00;18:00" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
|
||||
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||
AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"0 0 6 * * *,0 0 18 * * *" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
|
||||
ReportTimeWeekly string `yaml:"report_time_weekly" default:"0 0 18 * * 5" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||
DataCleanupTime string `yaml:"data_cleanup_time" default:"0 0 6 * * 7" env:"WAKAPI_DATA_CLEANUP_TIME"`
|
||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
|
||||
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||
DataRetentionMonths int `yaml:"data_retention_months" default:"-1" env:"WAKAPI_DATA_RETENTION_MONTHS"`
|
||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
|
||||
SupportContact string `yaml:"support_contact" default:"hostmaster@wakapi.dev" env:"WAKAPI_SUPPORT_CONTACT"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
}
|
||||
@ -87,10 +94,12 @@ type securityConfig struct {
|
||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
|
||||
SecureCookie *securecookie.SecureCookie `yaml:"-"`
|
||||
SessionKey []byte `yaml:"-"`
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
Host string `env:"WAKAPI_DB_HOST"`
|
||||
Socket string `env:"WAKAPI_DB_SOCKET"`
|
||||
Port uint `env:"WAKAPI_DB_PORT"`
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
@ -116,6 +125,16 @@ type serverConfig struct {
|
||||
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 {
|
||||
Dsn string `env:"WAKAPI_SENTRY_DSN"`
|
||||
EnableTracing bool `yaml:"enable_tracing" env:"WAKAPI_SENTRY_TRACING"`
|
||||
@ -155,6 +174,7 @@ type Config struct {
|
||||
Security securityConfig
|
||||
Db dbConfig
|
||||
Server serverConfig
|
||||
Subscriptions subscriptionsConfig
|
||||
Sentry sentryConfig
|
||||
Mail mailConfig
|
||||
}
|
||||
@ -175,7 +195,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
|
||||
MaxAge: maxAge,
|
||||
Secure: !c.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,68 +207,97 @@ func (c *Config) UseTLS() bool {
|
||||
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
|
||||
}
|
||||
|
||||
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
return utils.CloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetLanguageColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["languages"], true)
|
||||
return utils.CloneStringMap(c.Colors["languages"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetEditorColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["editors"], true)
|
||||
return utils.CloneStringMap(c.Colors["editors"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetOSColors() map[string]string {
|
||||
return cloneStringMap(c.Colors["operating_systems"], true)
|
||||
return utils.CloneStringMap(c.Colors["operating_systems"], true)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
|
||||
s := strings.Split(c.ReportTimeWeekly, ",")[0]
|
||||
return parseWeekday(s)
|
||||
func (c *appConfig) GetAggregationTimeCron() string {
|
||||
if strings.Contains(c.AggregationTime, ":") {
|
||||
// old gocron format, e.g. "15:04"
|
||||
timeParts := strings.Split(c.AggregationTime, ":")
|
||||
h, err := strconv.Atoi(timeParts[0])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
m, err := strconv.Atoi(timeParts[1])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("0 %d %d * * *", m, h)
|
||||
}
|
||||
|
||||
return utils.CronPadToSecondly(c.AggregationTime)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetWeeklyReportTime() string {
|
||||
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
||||
func (c *appConfig) GetWeeklyReportCron() string {
|
||||
if strings.Contains(c.ReportTimeWeekly, ",") {
|
||||
// old gocron format, e.g. "fri,18:00"
|
||||
split := strings.Split(c.ReportTimeWeekly, ",")
|
||||
weekday := utils.ParseWeekday(split[0])
|
||||
timeParts := strings.Split(split[1], ":")
|
||||
|
||||
h, err := strconv.Atoi(timeParts[0])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
m, err := strconv.Atoi(timeParts[1])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
|
||||
}
|
||||
|
||||
return utils.CronPadToSecondly(c.ReportTimeWeekly)
|
||||
}
|
||||
|
||||
func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
|
||||
crons := []string{}
|
||||
|
||||
var parse func(string) string
|
||||
|
||||
if strings.Contains(c.LeaderboardGenerationTime, ":") {
|
||||
// old gocron format, e.g. "15:04"
|
||||
parse = func(s string) string {
|
||||
timeParts := strings.Split(s, ":")
|
||||
h, err := strconv.Atoi(timeParts[0])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
m, err := strconv.Atoi(timeParts[1])
|
||||
if err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("0 %d %d * * *", m, h)
|
||||
}
|
||||
} else {
|
||||
parse = func(s string) string {
|
||||
return utils.CronPadToSecondly(s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") {
|
||||
crons = append(crons, parse(strings.TrimSpace(s)))
|
||||
}
|
||||
|
||||
return crons
|
||||
}
|
||||
|
||||
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
|
||||
@ -303,13 +352,6 @@ func readColors() map[string]map[string]string {
|
||||
return colors
|
||||
}
|
||||
|
||||
func mustReadConfigLocation() string {
|
||||
if _, err := os.Stat(*cFlag); err != nil {
|
||||
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
|
||||
}
|
||||
return *cFlag
|
||||
}
|
||||
|
||||
func resolveDbDialect(dbType string) string {
|
||||
if dbType == "cockroach" {
|
||||
return "postgres"
|
||||
@ -323,35 +365,6 @@ func resolveDbDialect(dbType string) string {
|
||||
return dbType
|
||||
}
|
||||
|
||||
func findString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func parseWeekday(s string) time.Weekday {
|
||||
switch strings.ToLower(s) {
|
||||
case "mon", strings.ToLower(time.Monday.String()):
|
||||
return time.Monday
|
||||
case "tue", strings.ToLower(time.Tuesday.String()):
|
||||
return time.Tuesday
|
||||
case "wed", strings.ToLower(time.Wednesday.String()):
|
||||
return time.Wednesday
|
||||
case "thu", strings.ToLower(time.Thursday.String()):
|
||||
return time.Thursday
|
||||
case "fri", strings.ToLower(time.Friday.String()):
|
||||
return time.Friday
|
||||
case "sat", strings.ToLower(time.Saturday.String()):
|
||||
return time.Saturday
|
||||
case "sun", strings.ToLower(time.Sunday.String()):
|
||||
return time.Sunday
|
||||
}
|
||||
return time.Monday
|
||||
}
|
||||
|
||||
func Set(config *Config) {
|
||||
cfg = config
|
||||
}
|
||||
@ -365,7 +378,7 @@ func Load(version string) *Config {
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
|
||||
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
|
||||
logbuch.Fatal("failed to read config: %v", err)
|
||||
}
|
||||
|
||||
@ -384,6 +397,7 @@ func Load(version string) *Config {
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
)
|
||||
config.Security.SessionKey = securecookie.GenerateRandomKey(32)
|
||||
|
||||
if strings.HasSuffix(config.Server.BasePath, "/") {
|
||||
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
|
||||
@ -400,8 +414,18 @@ func Load(version string) *Config {
|
||||
initSentry(config.Sentry, config.IsDev())
|
||||
}
|
||||
|
||||
if config.App.DataRetentionMonths <= 0 {
|
||||
logbuch.Info("disabling data retention policy, keeping data forever")
|
||||
} else {
|
||||
dataRetentionWarning := fmt.Sprintf("⚠️ data retention policy will cause user data older than %d months to be deleted", config.App.DataRetentionMonths)
|
||||
if config.Subscriptions.Enabled {
|
||||
dataRetentionWarning += " (except for users with active subscriptions)"
|
||||
}
|
||||
logbuch.Warn(dataRetentionWarning)
|
||||
}
|
||||
|
||||
// some validation checks
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
|
||||
if config.Server.ListenIpV4 == "-" && config.Server.ListenIpV6 == "-" && config.Server.ListenSocket == "" {
|
||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
|
||||
}
|
||||
if config.Db.MaxConn <= 0 {
|
||||
@ -411,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
|
||||
config.Db.MaxConn = 1
|
||||
}
|
||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||
if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
|
||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||
}
|
||||
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
|
||||
logbuch.Fatal("invalid interval set for report_time_weekly")
|
||||
}
|
||||
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
|
||||
logbuch.Fatal("invalid interval set for aggregation_time")
|
||||
}
|
||||
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
|
||||
logbuch.Fatal("invalid duration set for heartbeat_max_age")
|
||||
}
|
||||
|
||||
cronParser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
if _, err := cronParser.Parse(config.App.GetWeeklyReportCron()); err != nil {
|
||||
logbuch.Fatal("invalid cron expression for report_time_weekly")
|
||||
}
|
||||
if _, err := cronParser.Parse(config.App.GetAggregationTimeCron()); err != nil {
|
||||
logbuch.Fatal("invalid cron expression for aggregation_time")
|
||||
}
|
||||
for _, c := range config.App.GetLeaderboardGenerationTimeCron() {
|
||||
if _, err := cronParser.Parse(c); err != nil {
|
||||
logbuch.Fatal("invalid cron expression for leaderboard_generation_time")
|
||||
}
|
||||
}
|
||||
|
||||
// deprecation notices
|
||||
if strings.Contains(config.App.AggregationTime, ":") {
|
||||
logbuch.Warn("you're using deprecated syntax for 'aggregation_time', please change it to a valid cron expression")
|
||||
}
|
||||
if strings.Contains(config.App.ReportTimeWeekly, ":") {
|
||||
logbuch.Warn("you're using deprecated syntax for 'report_time_weekly', please change it to a valid cron expression")
|
||||
}
|
||||
if strings.Contains(config.App.LeaderboardGenerationTime, ":") {
|
||||
logbuch.Warn("you're using deprecated syntax for 'leaderboard_generation_time', please change it to a semicolon-separated list if valid cron expressions")
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
}
|
||||
|
@ -2,8 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfig_IsDev(t *testing.T) {
|
||||
@ -37,6 +38,28 @@ func Test_mysqlConnectionString(t *testing.T) {
|
||||
), mysqlConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_mysqlConnectionStringSocket(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Socket: "/var/run/mysql.sock",
|
||||
Port: 9999,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Name: "test_name",
|
||||
Dialect: "mysql",
|
||||
Charset: "utf8mb4",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"%s:%s@unix(%s)/%s?charset=utf8mb4&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
c.User,
|
||||
c.Password,
|
||||
c.Socket,
|
||||
c.Name,
|
||||
"Local",
|
||||
), mysqlConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_postgresConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Host: "test_host",
|
||||
|
12
config/db.go
12
config/db.go
@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
@ -54,11 +55,16 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
host := fmt.Sprintf("tcp(%s:%d)", config.Host, config.Port)
|
||||
|
||||
if config.Socket != "" {
|
||||
host = fmt.Sprintf("unix(%s)", config.Socket)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
host,
|
||||
config.Name,
|
||||
config.Charset,
|
||||
"Local",
|
||||
|
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 (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -89,8 +88,8 @@ func (l *SentryWrapperLogger) log(msg string, level sentry.Level) {
|
||||
if h := l.req.Context().Value(sentry.HubContextKey); h != nil {
|
||||
hub := h.(*sentry.Hub)
|
||||
hub.Scope().SetRequest(l.req)
|
||||
if u := getPrincipal(l.req); u != nil {
|
||||
hub.Scope().SetUser(sentry.User{ID: u.ID})
|
||||
if uid := getPrincipal(l.req); uid != "" {
|
||||
hub.Scope().SetUser(sentry.User{ID: uid})
|
||||
}
|
||||
hub.CaptureEvent(event)
|
||||
return
|
||||
@ -133,8 +132,8 @@ func initSentry(config sentryConfig, debug bool) {
|
||||
BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
if hint.Context != nil {
|
||||
if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok {
|
||||
if u := getPrincipal(req); u != nil {
|
||||
event.User.ID = u.ID
|
||||
if uid := getPrincipal(req); uid != "" {
|
||||
event.User.ID = uid
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -145,12 +144,14 @@ func initSentry(config sentryConfig, debug bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func getPrincipal(r *http.Request) *models.User {
|
||||
type principalGetter interface {
|
||||
GetPrincipal() *models.User
|
||||
// returns a user id
|
||||
func getPrincipal(r *http.Request) string {
|
||||
type principalIdentityGetter interface {
|
||||
GetPrincipalIdentity() string
|
||||
}
|
||||
|
||||
if p := r.Context().Value("principal"); p != nil {
|
||||
return p.(principalGetter).GetPrincipal()
|
||||
return p.(principalIdentityGetter).GetPrincipalIdentity()
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
14
config/session.go
Normal file
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
|
||||
|
||||
go 1.18
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
codeberg.org/Codeberg/avatars v1.0.0
|
||||
github.com/duke-git/lancet/v2 v2.1.6
|
||||
github.com/duke-git/lancet/v2 v2.1.10
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||
github.com/emersion/go-smtp v0.15.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/go-co-op/gocron v1.17.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/schema v1.2.0
|
||||
@ -20,27 +19,29 @@ require (
|
||||
github.com/leandro-lugaresi/hub v1.1.1
|
||||
github.com/lpar/gzipped/v2 v2.1.0
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
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
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
||||
gorm.io/driver/mysql v1.4.1
|
||||
gorm.io/driver/postgres v1.4.4
|
||||
gorm.io/driver/sqlite v1.4.2
|
||||
gorm.io/gorm v1.24.0
|
||||
golang.org/x/crypto v0.3.0
|
||||
golang.org/x/sync v0.1.0
|
||||
gorm.io/driver/mysql v1.4.4
|
||||
gorm.io/driver/postgres v1.4.5
|
||||
gorm.io/driver/sqlite v1.4.3
|
||||
gorm.io/gorm v1.24.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.2.0 // indirect
|
||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/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/jsonreference v0.20.0 // 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.13.0 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
@ -65,18 +67,18 @@ require (
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/stripe/stripe-go/v74 v74.3.0 // indirect
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
golang.org/x/image v0.1.0 // indirect
|
||||
golang.org/x/net v0.2.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/tools v0.3.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.20.3 // indirect
|
||||
modernc.org/libc v1.21.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/sqlite v1.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/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
|
||||
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
|
||||
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
@ -16,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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
|
||||
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||
github.com/duke-git/lancet/v2 v2.1.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
|
||||
github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||
github.com/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-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.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||
github.com/getsentry/sentry-go v0.14.0 h1:rlOBkuFZRKKdUnKO+0U3JclRDQKlRu5vVQtkWSQvC70=
|
||||
github.com/getsentry/sentry-go v0.14.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
|
||||
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
|
||||
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
|
||||
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
|
||||
github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
|
||||
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
|
||||
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
|
||||
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
|
||||
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
|
||||
github.com/glebarez/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
|
||||
github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
|
||||
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
|
||||
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
|
||||
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
|
||||
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@ -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/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@ -79,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/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/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/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
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/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
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/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=
|
||||
@ -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.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
|
||||
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@ -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.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
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/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
||||
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
||||
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
|
||||
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
|
||||
github.com/swaggo/swag v1.8.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
|
||||
github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
@ -249,34 +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-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
|
||||
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -287,25 +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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
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-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@ -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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
|
||||
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic=
|
||||
gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
|
||||
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/postgres v1.4.4 h1:zt1fxJ+C+ajparn0SteEnkoPg0BQ6wOWXEQ99bteAmw=
|
||||
gorm.io/driver/postgres v1.4.4/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/driver/sqlite v1.4.2 h1:F6vYJcmR4Cnh0ErLyoY8JSfabBGyR0epIGuhgHJuNws=
|
||||
gorm.io/driver/sqlite v1.4.2/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
|
||||
gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
|
||||
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
|
||||
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
|
||||
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
|
||||
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
|
||||
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
|
||||
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
|
||||
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
|
||||
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
|
||||
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
|
||||
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.3 h1:BodaDPuUse7taQchAClMmbE/yZp3T2ZBiwCDFyBLEXw=
|
||||
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
|
||||
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
|
||||
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
|
||||
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||
modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
|
||||
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
|
||||
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
|
||||
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
|
||||
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
|
@ -1,9 +1,8 @@
|
||||
package utils
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/config"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -41,22 +40,10 @@ func FormatDateHuman(date time.Time) string {
|
||||
return date.Format("Mon, 02 Jan 2006")
|
||||
}
|
||||
|
||||
func Add(i, j int) int {
|
||||
return i + j
|
||||
}
|
||||
|
||||
func ParseUserAgent(ua string) (string, string, error) {
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
groups := re.FindAllStringSubmatch(ua, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
||||
return "", "", errors.New("failed to parse user agent string")
|
||||
}
|
||||
return groups[0][1], groups[0][2], nil
|
||||
}
|
||||
|
||||
func SubSlice[T any](slice []T, from, to uint) []T {
|
||||
if int(to) > len(slice) {
|
||||
to = uint(len(slice))
|
||||
}
|
||||
return slice[from:int(to)]
|
||||
func FmtWakatimeDuration(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
return fmt.Sprintf("%d hrs %d mins", h, m)
|
||||
}
|
4
helpers/helpers.go
Normal file
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 (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||
for _, i := range models.AllIntervals {
|
||||
if i.HasAlias(interval) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("not a valid interval")
|
||||
}
|
||||
|
||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||
return from, to
|
||||
}
|
||||
|
||||
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||
parsed, err := ParseInterval(interval)
|
||||
if err != nil {
|
||||
return err, time.Time{}, time.Time{}
|
||||
}
|
||||
return ResolveIntervalTZ(parsed, tz)
|
||||
}
|
||||
|
||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||
now := time.Now().In(tz)
|
||||
to = now
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = BeginOfToday(tz)
|
||||
case models.IntervalYesterday:
|
||||
from = BeginOfToday(tz).Add(-24 * time.Hour)
|
||||
to = BeginOfToday(tz)
|
||||
case models.IntervalThisWeek:
|
||||
from = BeginOfThisWeek(tz)
|
||||
case models.IntervalLastWeek:
|
||||
from = BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
||||
to = BeginOfThisWeek(tz)
|
||||
case models.IntervalThisMonth:
|
||||
from = BeginOfThisMonth(tz)
|
||||
case models.IntervalLastMonth:
|
||||
from = BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
||||
to = BeginOfThisMonth(tz)
|
||||
case models.IntervalThisYear:
|
||||
from = BeginOfThisYear(tz)
|
||||
case models.IntervalPast7Days:
|
||||
from = now.AddDate(0, 0, -7)
|
||||
case models.IntervalPast7DaysYesterday:
|
||||
from = BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = BeginOfToday(tz).AddDate(0, 0, -1)
|
||||
case models.IntervalPast14Days:
|
||||
from = now.AddDate(0, 0, -14)
|
||||
case models.IntervalPast30Days:
|
||||
from = now.AddDate(0, 0, -30)
|
||||
case models.IntervalPast6Months:
|
||||
from = now.AddDate(0, -6, 0)
|
||||
case models.IntervalPast12Months:
|
||||
from = now.AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
err = errors.New("invalid interval")
|
||||
}
|
||||
|
||||
return err, from, to
|
||||
}
|
||||
|
||||
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
user := extractUser(r)
|
||||
params := r.URL.Query()
|
||||
@ -144,3 +79,69 @@ func extractUser(r *http.Request) *models.User {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||
for _, i := range models.AllIntervals {
|
||||
if i.HasAlias(interval) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("not a valid interval")
|
||||
}
|
||||
|
||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||
return from, to
|
||||
}
|
||||
|
||||
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||
parsed, err := ParseInterval(interval)
|
||||
if err != nil {
|
||||
return err, time.Time{}, time.Time{}
|
||||
}
|
||||
return ResolveIntervalTZ(parsed, tz)
|
||||
}
|
||||
|
||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||
now := time.Now().In(tz)
|
||||
to = now
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = utils.BeginOfToday(tz)
|
||||
case models.IntervalYesterday:
|
||||
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
||||
to = utils.BeginOfToday(tz)
|
||||
case models.IntervalThisWeek:
|
||||
from = utils.BeginOfThisWeek(tz)
|
||||
case models.IntervalLastWeek:
|
||||
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
||||
to = utils.BeginOfThisWeek(tz)
|
||||
case models.IntervalThisMonth:
|
||||
from = utils.BeginOfThisMonth(tz)
|
||||
case models.IntervalLastMonth:
|
||||
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
||||
to = utils.BeginOfThisMonth(tz)
|
||||
case models.IntervalThisYear:
|
||||
from = utils.BeginOfThisYear(tz)
|
||||
case models.IntervalPast7Days:
|
||||
from = now.AddDate(0, 0, -7)
|
||||
case models.IntervalPast7DaysYesterday:
|
||||
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
||||
case models.IntervalPast14Days:
|
||||
from = now.AddDate(0, 0, -14)
|
||||
case models.IntervalPast30Days:
|
||||
from = now.AddDate(0, 0, -30)
|
||||
case models.IntervalPast6Months:
|
||||
from = now.AddDate(0, -6, 0)
|
||||
case models.IntervalPast12Months:
|
||||
from = now.AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
err = errors.New("invalid interval")
|
||||
}
|
||||
|
||||
return err, from, to
|
||||
}
|
42
main.go
42
main.go
@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/muety/wakapi/static/docs"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@ -11,11 +10,13 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/static/docs"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lpar/gzipped/v2"
|
||||
"github.com/swaggo/http-swagger"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
@ -81,6 +82,7 @@ var (
|
||||
keyValueService services.IKeyValueService
|
||||
reportService services.IReportService
|
||||
diagnosticsService services.IDiagnosticsService
|
||||
housekeepingService services.IHousekeepingService
|
||||
miscService services.IMiscService
|
||||
)
|
||||
|
||||
@ -130,14 +132,12 @@ func main() {
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||
logbuch.Info("starting with %s database", config.Db.Dialect)
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}, conf.GetWakapiDBOpts(&config.Db))
|
||||
if err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
logbuch.Fatal("could not open database")
|
||||
}
|
||||
if config.Db.IsSQLite() {
|
||||
db.Exec("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
@ -182,13 +182,15 @@ func main() {
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
|
||||
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
|
||||
|
||||
// Schedule background tasks
|
||||
go aggregationService.Schedule()
|
||||
go leaderboardService.ScheduleDefault()
|
||||
go miscService.ScheduleCountTotalTime()
|
||||
go leaderboardService.Schedule()
|
||||
go reportService.Schedule()
|
||||
go housekeepingService.Schedule()
|
||||
go miscService.Schedule()
|
||||
|
||||
routes.Init()
|
||||
|
||||
@ -214,6 +216,7 @@ func main() {
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
|
||||
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
@ -251,6 +254,7 @@ func main() {
|
||||
summaryHandler.RegisterRoutes(rootRouter)
|
||||
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||
settingsHandler.RegisterRoutes(rootRouter)
|
||||
subscriptionHandler.RegisterRoutes(rootRouter)
|
||||
relayHandler.RegisterRoutes(rootRouter)
|
||||
|
||||
// API route registrations
|
||||
@ -295,7 +299,7 @@ func listen(handler http.Handler) {
|
||||
var s4, s6, sSocket *http.Server
|
||||
|
||||
// IPv4
|
||||
if config.Server.ListenIpV4 != "" {
|
||||
if config.Server.ListenIpV4 != "-" && config.Server.ListenIpV4 != "" {
|
||||
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||
s4 = &http.Server{
|
||||
Handler: handler,
|
||||
@ -306,7 +310,7 @@ func listen(handler http.Handler) {
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if config.Server.ListenIpV6 != "" {
|
||||
if config.Server.ListenIpV6 != "-" && config.Server.ListenIpV6 != "" {
|
||||
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
|
||||
s6 = &http.Server{
|
||||
Handler: handler,
|
||||
@ -317,10 +321,10 @@ func listen(handler http.Handler) {
|
||||
}
|
||||
|
||||
// UNIX domain socket
|
||||
if config.Server.ListenSocket != "" {
|
||||
if config.Server.ListenSocket != "-" && config.Server.ListenSocket != "" {
|
||||
// Remove if exists
|
||||
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
|
||||
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket)
|
||||
logbuch.Info("👉 Removing unix socket %s", config.Server.ListenSocket)
|
||||
if err := os.Remove(config.Server.ListenSocket); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
@ -334,7 +338,7 @@ func listen(handler http.Handler) {
|
||||
|
||||
if config.UseTLS() {
|
||||
if s4 != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr)
|
||||
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
@ -342,7 +346,7 @@ func listen(handler http.Handler) {
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr)
|
||||
logbuch.Info("👉 Listening for HTTPS on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
@ -350,7 +354,7 @@ func listen(handler http.Handler) {
|
||||
}()
|
||||
}
|
||||
if sSocket != nil {
|
||||
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
||||
logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
||||
go func() {
|
||||
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||
if err != nil {
|
||||
@ -363,7 +367,7 @@ func listen(handler http.Handler) {
|
||||
}
|
||||
} else {
|
||||
if s4 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
||||
logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
@ -371,7 +375,7 @@ func listen(handler http.Handler) {
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
|
||||
logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServe(); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
@ -379,7 +383,7 @@ func listen(handler http.Handler) {
|
||||
}()
|
||||
}
|
||||
if sSocket != nil {
|
||||
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
||||
logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
||||
go func() {
|
||||
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||
if err != nil {
|
||||
|
@ -2,6 +2,7 @@ package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@ -21,10 +22,11 @@ var (
|
||||
)
|
||||
|
||||
type AuthenticateMiddleware struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
optionalForPaths []string
|
||||
redirectTarget string // optional
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
optionalForPaths []string
|
||||
redirectTarget string // optional
|
||||
redirectErrorMessage string // optional
|
||||
}
|
||||
|
||||
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
|
||||
@ -45,6 +47,11 @@ func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMi
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *AuthenticateMiddleware {
|
||||
m.redirectErrorMessage = message
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.ServeHTTP(w, r, h.ServeHTTP)
|
||||
@ -72,6 +79,11 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(conf.ErrUnauthorized))
|
||||
} else {
|
||||
if m.redirectErrorMessage != "" {
|
||||
session, _ := conf.GetSessionStore().Get(r, conf.SessionKeyDefault)
|
||||
session.AddFlash(m.redirectErrorMessage, "error")
|
||||
session.Save(r, w)
|
||||
}
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
|
||||
}
|
||||
@ -121,7 +133,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||
username, err := utils.ExtractCookieAuth(r, m.config)
|
||||
username, err := helpers.ExtractCookieAuth(r, m.config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -160,17 +160,23 @@ func (m *WakatimeRelayMiddleware) filterByCache(r *http.Request) error {
|
||||
|
||||
newData := make([]interface{}, 0, len(heartbeats))
|
||||
|
||||
for i, hb := range heartbeats {
|
||||
hb = hb.Hashed()
|
||||
|
||||
process := func(heartbeat *models.Heartbeat, rawData interface{}) {
|
||||
heartbeat = heartbeat.Hashed()
|
||||
// we didn't see this particular heartbeat before
|
||||
if _, found := m.hashCache.Get(hb.Hash); !found {
|
||||
m.hashCache.SetDefault(hb.Hash, true)
|
||||
newData = append(newData, rawData.([]interface{})[i])
|
||||
continue
|
||||
if _, found := m.hashCache.Get(heartbeat.Hash); !found {
|
||||
m.hashCache.SetDefault(heartbeat.Hash, true)
|
||||
newData = append(newData, rawData)
|
||||
}
|
||||
}
|
||||
|
||||
if _, isList := rawData.([]interface{}); isList {
|
||||
for i, hb := range heartbeats {
|
||||
process(hb, rawData.([]interface{})[i])
|
||||
}
|
||||
} else if len(heartbeats) > 0 {
|
||||
process(heartbeats[0], rawData.(interface{}))
|
||||
}
|
||||
|
||||
if len(newData) == 0 {
|
||||
return errors.New("no new heartbeats to relay")
|
||||
}
|
||||
|
@ -20,6 +20,10 @@ func (c *PrincipalContainer) GetPrincipal() *models.User {
|
||||
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
|
||||
// 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()),
|
||||
|
@ -3,11 +3,14 @@ package migrations
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type gormMigrationFunc func(db *gorm.DB) error
|
||||
|
||||
type migrationFunc struct {
|
||||
f func(db *gorm.DB, cfg *config.Config) error
|
||||
name string
|
||||
@ -20,6 +23,45 @@ var (
|
||||
postMigrations migrationFuncs
|
||||
)
|
||||
|
||||
func GetMigrationFunc(cfg *config.Config) gormMigrationFunc {
|
||||
switch cfg.Db.Dialect {
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !cfg.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerPreMigration(f migrationFunc) {
|
||||
preMigrations = append(preMigrations, f)
|
||||
}
|
||||
@ -35,7 +77,7 @@ func Run(db *gorm.DB, cfg *config.Config) {
|
||||
}
|
||||
|
||||
func RunSchemaMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
if err := cfg.GetMigrationFunc(cfg.Db.Dialect)(db); err != nil {
|
||||
if err := GetMigrationFunc(cfg)(db); err != nil {
|
||||
logbuch.Fatal(err.Error())
|
||||
}
|
||||
}
|
||||
|
@ -10,13 +10,13 @@ type HeartbeatServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
|
||||
args := m.Called(heartbeat)
|
||||
func (m *HeartbeatServiceMock) Insert(h *models.Heartbeat) error {
|
||||
args := m.Called(h)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
args := m.Called(heartbeats)
|
||||
func (m *HeartbeatServiceMock) InsertBatch(h []*models.Heartbeat) error {
|
||||
args := m.Called(h)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
@ -74,3 +74,8 @@ func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteByUserBefore(u *models.User, t time.Time) error {
|
||||
args := m.Called(u, t)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
@ -10,8 +10,8 @@ type SummaryRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
||||
args := m.Called(summary)
|
||||
func (m *SummaryRepositoryMock) Insert(s *models.Summary) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
@ -20,8 +20,8 @@ func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(user, time, time2)
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(u *models.User, t1 time.Time, t2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(u, t1, t2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
@ -34,3 +34,8 @@ func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) DeleteByUserBefore(s string, t time.Time) error {
|
||||
args := m.Called(s, t)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
@ -54,6 +54,11 @@ func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByStripeCustomerId(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
|
||||
args := m.Called(b)
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
@ -107,3 +112,7 @@ func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, e
|
||||
func (m *UserServiceMock) FlushCache() {
|
||||
m.Called()
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) FlushUserCache(s string) {
|
||||
m.Called(s)
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
// https://shields.io/endpoint
|
||||
@ -23,7 +23,7 @@ func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
|
||||
return &BadgeData{
|
||||
SchemaVersion: 1,
|
||||
Label: defaultLabel,
|
||||
Message: utils.FmtWakatimeDuration(summary.TotalTime()),
|
||||
Message: helpers.FmtWakatimeDuration(summary.TotalTime()),
|
||||
Color: defaultColor,
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -33,13 +33,13 @@ func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
|
||||
return &AllTimeViewModel{
|
||||
Data: &AllTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
Text: helpers.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
Range: &AllTimeRange{
|
||||
End: summary.ToTime.T().Format(time.RFC3339),
|
||||
EndDate: utils.FormatDate(summary.ToTime.T()),
|
||||
EndDate: helpers.FormatDate(summary.ToTime.T()),
|
||||
Start: summary.FromTime.T().Format(time.RFC3339),
|
||||
StartDate: utils.FormatDate(summary.FromTime.T()),
|
||||
StartDate: helpers.FormatDate(summary.FromTime.T()),
|
||||
Timezone: tzName,
|
||||
},
|
||||
},
|
||||
|
@ -2,8 +2,8 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
@ -96,7 +96,7 @@ func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
|
||||
Decimal: fmt.Sprintf("%.2f", totalHrs),
|
||||
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
|
||||
Seconds: totalSecs,
|
||||
Text: utils.FmtWakatimeDuration(totalTime),
|
||||
Text: helpers.FmtWakatimeDuration(totalTime),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -119,7 +119,7 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
Text: helpers.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &SummariesRange{
|
||||
@ -201,7 +201,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEn
|
||||
Name: e.Key,
|
||||
Percent: percentage,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
Text: helpers.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
}
|
||||
}
|
||||
|
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)
|
||||
}
|
||||
}
|
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,20 +5,18 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
UserKey = "user"
|
||||
ImprintKey = "imprint"
|
||||
AuthCookieKey = "wakapi_auth"
|
||||
UserKey = "user"
|
||||
ImprintKey = "imprint"
|
||||
AuthCookieKey = "wakapi_auth"
|
||||
PersistentIntervalKey = "wakapi_summary_interval"
|
||||
)
|
||||
|
||||
type MigrationFunc func(db *gorm.DB) error
|
||||
|
||||
type KeyStringValue struct {
|
||||
Key string `gorm:"primary_key"`
|
||||
Value string `gorm:"type:text"`
|
||||
@ -34,11 +32,6 @@ type KeyedInterval struct {
|
||||
Key *IntervalKey
|
||||
}
|
||||
|
||||
type PageParams struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
|
||||
type CustomTime time.Time
|
||||
|
||||
@ -104,17 +97,3 @@ func (j CustomTime) T() time.Time {
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
||||
func (p *PageParams) Limit() int {
|
||||
if p.PageSize < 0 {
|
||||
return 0
|
||||
}
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
func (p *PageParams) Offset() int {
|
||||
if p.PageSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ type Summary struct {
|
||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project filter is applied
|
||||
NumHeartbeats int `json:"-" gorm:"default:0"`
|
||||
NumHeartbeats int `json:"-"`
|
||||
}
|
||||
|
||||
type SummaryItems []*SummaryItem
|
||||
|
@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@ -13,27 +15,29 @@ func init() {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ShareDataMaxDays int `json:"-" gorm:"default:0"`
|
||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ShareDataMaxDays int `json:"-"`
|
||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
StripeCustomerId string `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -82,6 +86,10 @@ type CountByUser struct {
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (u *User) Identity() string {
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *User) TZ() *time.Location {
|
||||
if u.Location == "" {
|
||||
u.Location = "Local"
|
||||
@ -120,6 +128,40 @@ func (u *User) WakaTimeURL(fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// HasActiveSubscription returns true if subscriptions are enabled on the server and the user has got one
|
||||
func (u *User) HasActiveSubscription() bool {
|
||||
return conf.Get().Subscriptions.Enabled && u.HasActiveSubscriptionStrict()
|
||||
}
|
||||
|
||||
func (u *User) HasActiveSubscriptionStrict() bool {
|
||||
return u.SubscribedUntil != nil && u.SubscribedUntil.T().After(time.Now())
|
||||
}
|
||||
|
||||
// SubscriptionExpiredSince returns if a user's subscription has expiration and the duration since when that happened.
|
||||
// Returns (false, <negative duration>), if subscription hasn't expired, yet.
|
||||
// Returns (false, 0), if subscriptions are not enabled in the first place.
|
||||
// Returns (true, <very long duration>), if the user has never had a subscription.
|
||||
func (u *User) SubscriptionExpiredSince() (bool, time.Duration) {
|
||||
cfg := conf.Get()
|
||||
if !cfg.Subscriptions.Enabled {
|
||||
return false, 0
|
||||
}
|
||||
if u.SubscribedUntil == nil {
|
||||
return true, 99 * 365 * 24 * time.Hour
|
||||
}
|
||||
diff := time.Now().Sub(u.SubscribedUntil.T())
|
||||
return diff >= 0, diff
|
||||
}
|
||||
|
||||
func (u *User) MinDataAge() time.Time {
|
||||
retentionMonths := conf.Get().App.DataRetentionMonths
|
||||
if retentionMonths <= 0 || u.HasActiveSubscription() {
|
||||
return time.Time{}
|
||||
}
|
||||
// this is not exactly precise, because of summer / winter time, etc.
|
||||
return time.Now().AddDate(0, -retentionMonths, 0)
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return ValidatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
@ -149,8 +191,9 @@ func ValidatePassword(password string) bool {
|
||||
return len(password) >= 6
|
||||
}
|
||||
|
||||
// ValidateEmail checks that, if an email address is given, it has proper syntax and (if not in dev mode) an MX record exists for the domain
|
||||
func ValidateEmail(email string) bool {
|
||||
return email == "" || mailRegex.Match([]byte(email))
|
||||
return email == "" || (mailRegex.Match([]byte(email)) && (conf.Get().IsDev() || utils.CheckEmailMX(email)))
|
||||
}
|
||||
|
||||
func ValidateTimezone(tz string) bool {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
@ -18,3 +19,41 @@ func TestUser_TZ(t *testing.T) {
|
||||
assert.InDelta(t, time.Duration(offset1*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
|
||||
assert.InDelta(t, time.Duration(offset2*int(time.Second)), sut2.TZOffset(), float64(1*time.Second))
|
||||
}
|
||||
|
||||
func TestUser_MinDataAge(t *testing.T) {
|
||||
c := conf.Load("")
|
||||
|
||||
var sut *User
|
||||
|
||||
// test with unlimited retention time / clean-up disabled
|
||||
c.App.DataRetentionMonths = -1
|
||||
c.Subscriptions.Enabled = false
|
||||
sut = &User{}
|
||||
assert.Zero(t, sut.MinDataAge())
|
||||
|
||||
// test with limited retention time / clean-up enabled, and subscriptions disabled
|
||||
c.App.DataRetentionMonths = 1
|
||||
c.Subscriptions.Enabled = false
|
||||
sut = &User{}
|
||||
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||
|
||||
// test with limited retention time, subscriptions enabled, and user hasn't got one
|
||||
c.App.DataRetentionMonths = 1
|
||||
c.Subscriptions.Enabled = true
|
||||
sut = &User{}
|
||||
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||
|
||||
// test with limited retention time, subscriptions disabled, but user still got (an expired) one
|
||||
c.App.DataRetentionMonths = 1
|
||||
c.Subscriptions.Enabled = false
|
||||
until2 := CustomTime(time.Now().AddDate(0, 0, -1))
|
||||
sut = &User{SubscribedUntil: &until2}
|
||||
assert.WithinRange(t, sut.MinDataAge(), time.Now().AddDate(0, -1, -1), time.Now().AddDate(0, -1, 1))
|
||||
|
||||
// test with limited retention time, subscriptions enabled, and user has got one
|
||||
c.App.DataRetentionMonths = 1
|
||||
c.Subscriptions.Enabled = true
|
||||
until1 := CustomTime(time.Now().AddDate(0, 1, 0))
|
||||
sut = &User{SubscribedUntil: &until1}
|
||||
assert.Zero(t, sut.MinDataAge())
|
||||
}
|
||||
|
19
models/view/common.go
Normal file
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 {
|
||||
Success string
|
||||
Error string
|
||||
Messages
|
||||
TotalHours int
|
||||
TotalUsers int
|
||||
Newsbox *Newsbox
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
package view
|
||||
|
||||
type ImprintViewModel struct {
|
||||
Messages
|
||||
HtmlText string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
||||
|
@ -2,11 +2,13 @@ package view
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LeaderboardViewModel struct {
|
||||
Messages
|
||||
User *models.User
|
||||
By string
|
||||
Key string
|
||||
@ -14,18 +16,16 @@ type LeaderboardViewModel struct {
|
||||
TopKeys []string
|
||||
UserLanguages map[string][]string
|
||||
ApiKey string
|
||||
PageParams *models.PageParams
|
||||
Success string
|
||||
Error string
|
||||
PageParams *utils.PageParams
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
package view
|
||||
|
||||
type LoginViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
Messages
|
||||
TotalUsers int
|
||||
AllowSignup bool
|
||||
}
|
||||
@ -13,11 +12,11 @@ type SetPasswordViewModel struct {
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
@ -1,16 +1,22 @@
|
||||
package view
|
||||
|
||||
import "github.com/muety/wakapi/models"
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Labels []*SettingsVMCombinedLabel
|
||||
Projects []string
|
||||
ApiKey string
|
||||
Success string
|
||||
Error string
|
||||
Messages
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Labels []*SettingsVMCombinedLabel
|
||||
Projects []string
|
||||
SubscriptionPrice string
|
||||
DataRetentionMonths int
|
||||
UserFirstData time.Time
|
||||
SupportContact string
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type SettingsVMCombinedAlias struct {
|
||||
@ -24,12 +30,16 @@ type SettingsVMCombinedLabel struct {
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
|
||||
return s.SubscriptionPrice != ""
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package view
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type SummaryViewModel struct {
|
||||
Messages
|
||||
*models.Summary
|
||||
*models.SummaryParams
|
||||
User *models.User
|
||||
@ -10,18 +11,16 @@ type SummaryViewModel struct {
|
||||
EditorColors map[string]string
|
||||
LanguageColors map[string]string
|
||||
OSColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
ApiKey string
|
||||
RawQuery string
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||
s.Success = m
|
||||
s.SetSuccess(m)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
||||
s.Error = m
|
||||
s.SetError(m)
|
||||
return s
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||
func (r *HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||
var results []string
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
@ -199,3 +199,13 @@ func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteByUserBefore(user *models.User, t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", user.ID).
|
||||
Where("time <= ?", t.Local()).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -34,6 +34,17 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
|
||||
var keyValues []*models.KeyStringValue
|
||||
if err := r.db.Table("key_string_values").
|
||||
Where("`key` like ?", like).
|
||||
Find(&keyValues).
|
||||
Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyValues, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||
result := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
|
@ -31,6 +31,7 @@ type IHeartbeatRepository interface {
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
DeleteByUser(*models.User) error
|
||||
DeleteByUserBefore(*models.User, time.Time) error
|
||||
}
|
||||
|
||||
type IDiagnosticsRepository interface {
|
||||
@ -42,6 +43,7 @@ type IKeyValueRepository interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
Search(string) ([]*models.KeyStringValue, error)
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
@ -66,14 +68,12 @@ type ISummaryRepository interface {
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
DeleteByUserBefore(string, time.Time) error
|
||||
}
|
||||
|
||||
type IUserRepository interface {
|
||||
GetById(string) (*models.User, error)
|
||||
FindOne(user models.User) (*models.User, error)
|
||||
GetByIds([]string) ([]*models.User, error)
|
||||
GetByApiKey(string) (*models.User, error)
|
||||
GetByEmail(string) (*models.User, error)
|
||||
GetByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetMany([]string) ([]*models.User, error)
|
||||
GetAllByReports(bool) ([]*models.User, error)
|
||||
|
@ -86,6 +86,16 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) DeleteByUserBefore(userId string, t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Where("to_time <= ?", t.Local()).
|
||||
Delete(models.Summary{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inplace
|
||||
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
|
||||
var items []*models.SummaryItem
|
||||
|
@ -15,9 +15,9 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetById(userId string) (*models.User, error) {
|
||||
func (r *UserRepository) FindOne(attributes models.User) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
if err := r.db.Where(&attributes).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
@ -34,39 +34,6 @@ func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) {
|
||||
if resetToken == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
if email == "" {
|
||||
return nil, errors.New("invalid input")
|
||||
}
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
@ -144,7 +111,7 @@ func (r *UserRepository) Count() (int64, error) {
|
||||
}
|
||||
|
||||
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
||||
if u, err := r.GetById(user.ID); err == nil && u != nil && u.ID != "" {
|
||||
if u, err := r.FindOne(models.User{ID: user.ID}); err == nil && u != nil && u.ID != "" {
|
||||
return u, false, nil
|
||||
}
|
||||
|
||||
@ -176,6 +143,8 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"location": user.Location,
|
||||
"reports_weekly": user.ReportsWeekly,
|
||||
"public_leaderboard": user.PublicLeaderboard,
|
||||
"subscribed_until": user.SubscribedUntil,
|
||||
"stripe_customer_id": user.StripeCustomerId,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
|
@ -40,7 +40,7 @@ func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !h.cache.Contains(hash) {
|
||||
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
|
||||
h.cache.Add(hash, avatars.MakeAvatar(hash))
|
||||
}
|
||||
data, _ := h.cache.Get(hash)
|
||||
|
||||
|
@ -2,14 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
)
|
||||
|
||||
type DiagnosticsApiHandler struct {
|
||||
@ -55,5 +54,5 @@ func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusCreated, struct{}{})
|
||||
helpers.RespondJSON(w, r, http.StatusCreated, struct{}{})
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -120,7 +121,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
defer func() {}()
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
helpers.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
|
@ -5,13 +5,13 @@ import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
mm "github.com/muety/wakapi/models/metrics"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sort"
|
||||
@ -37,6 +37,9 @@ const (
|
||||
DescAdminTotalUsers = "Total number of registered users."
|
||||
DescAdminActiveUsers = "Number of active users."
|
||||
|
||||
DescJobQueueEnqueued = "Number of jobs currently enqueued"
|
||||
DescJobQueueTotalFinished = "Total number of processed jobs"
|
||||
|
||||
DescMemAllocTotal = "Total number of bytes allocated for heap"
|
||||
DescMemSysTotal = "Total number of bytes obtained from the OS"
|
||||
DescGoroutines = "Total number of running goroutines"
|
||||
@ -126,7 +129,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
|
||||
from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ())
|
||||
|
||||
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
|
||||
if err != nil {
|
||||
@ -142,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
|
||||
// User Metrics
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_cumulative_seconds_total",
|
||||
Desc: DescAllTime,
|
||||
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_seconds_total",
|
||||
Desc: DescTotal,
|
||||
Value: int64(summaryToday.TotalTime().Seconds()),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_heartbeats_total",
|
||||
Desc: DescHeartbeats,
|
||||
Value: int64(heartbeatCount),
|
||||
@ -164,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
})
|
||||
|
||||
for _, p := range summaryToday.Projects {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_project_seconds_total",
|
||||
Desc: DescProjects,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
|
||||
@ -173,7 +176,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, l := range summaryToday.Languages {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_language_seconds_total",
|
||||
Desc: DescLanguages,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
|
||||
@ -182,7 +185,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, e := range summaryToday.Editors {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_editor_seconds_total",
|
||||
Desc: DescEditors,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
|
||||
@ -191,7 +194,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, o := range summaryToday.OperatingSystems {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_operating_system_seconds_total",
|
||||
Desc: DescOperatingSystems,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
|
||||
@ -200,7 +203,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, m := range summaryToday.Machines {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_machine_seconds_total",
|
||||
Desc: DescMachines,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
|
||||
@ -209,7 +212,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, m := range summaryToday.Labels {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_label_seconds_total",
|
||||
Desc: DescLabels,
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
||||
@ -221,21 +224,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_goroutines_total",
|
||||
Desc: DescGoroutines,
|
||||
Value: int64(runtime.NumGoroutine()),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_mem_alloc_total",
|
||||
Desc: DescMemAllocTotal,
|
||||
Value: int64(memStats.Alloc),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_mem_sys_total",
|
||||
Desc: DescMemSysTotal,
|
||||
Value: int64(memStats.Sys),
|
||||
@ -248,13 +251,30 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
logbuch.Warn("failed to get database size (%v)", err)
|
||||
}
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_db_total_bytes",
|
||||
Desc: DescDatabaseSize,
|
||||
Value: dbSize,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
// Miscellaneous
|
||||
for _, qm := range conf.GetQueueMetrics() {
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_queue_jobs_enqueued",
|
||||
Value: int64(qm.EnqueuedJobs),
|
||||
Desc: DescJobQueueEnqueued,
|
||||
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_queue_jobs_total_finished",
|
||||
Value: int64(qm.FinishedJobs),
|
||||
Desc: DescJobQueueTotalFinished,
|
||||
Labels: []mm.Label{{Key: "queue", Value: qm.Queue}},
|
||||
})
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
@ -281,28 +301,28 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_admin_seconds_total",
|
||||
Desc: DescAdminTotalTime,
|
||||
Value: int64(totalSeconds),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_admin_heartbeats_total",
|
||||
Desc: DescAdminTotalHeartbeats,
|
||||
Value: totalHeartbeats,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_admin_users_total",
|
||||
Desc: DescAdminTotalUsers,
|
||||
Value: totalUsers,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_admin_users_active_total",
|
||||
Desc: DescAdminActiveUsers,
|
||||
Value: int64(len(activeUsers)),
|
||||
@ -318,7 +338,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
for _, uc := range userCounts {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
metrics = append(metrics, &mm.GaugeMetric{
|
||||
Name: MetricsPrefix + "_admin_user_heartbeats_total",
|
||||
Desc: DescAdminUserHeartbeats,
|
||||
Value: uc.Count,
|
||||
|
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"net/http"
|
||||
|
||||
@ -8,7 +9,6 @@ import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type SummaryApiHandler struct {
|
||||
@ -58,5 +58,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusOK, summary)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, summary)
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
@ -11,7 +12,6 @@ import (
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
@ -63,7 +63,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
helpers.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
return
|
||||
}
|
||||
|
||||
@ -83,11 +83,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary)
|
||||
h.cache.SetDefault(cacheKey, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey, filters *models.Filters) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
@ -3,12 +3,12 @@ package v1
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@ -50,7 +50,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, utils.ParseSummaryFilters(r))
|
||||
summary, err, status := h.loadUserSummary(user, helpers.ParseSummaryFilters(r))
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -58,7 +58,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filters) (*models.Summary, error, int) {
|
||||
|
@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -11,7 +12,6 @@ import (
|
||||
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type HeartbeatsResult struct {
|
||||
@ -82,5 +82,5 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
End: rangeTo.UTC().Format(time.RFC3339),
|
||||
Timezone: timezone.String(),
|
||||
}
|
||||
utils.RespondJSON(w, r, http.StatusOK, res)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, res)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@ -11,7 +12,6 @@ import (
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type ProjectsHandler struct {
|
||||
@ -70,5 +70,5 @@ func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := &v1.ProjectsViewModel{Data: projects}
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -10,7 +11,6 @@ import (
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type StatsHandler struct {
|
||||
@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
rangeParam = (*models.IntervalPast7Days)[0]
|
||||
}
|
||||
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid range"))
|
||||
@ -94,7 +94,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, utils.ParseSummaryFilters(r))
|
||||
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, helpers.ParseSummaryFilters(r))
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -120,7 +120,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
stats.Data.Machines = nil
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusOK, stats)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -11,7 +12,6 @@ import (
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type StatusBarViewModel struct {
|
||||
@ -65,7 +65,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
rangeParam = (*models.IntervalToday)[0]
|
||||
}
|
||||
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ())
|
||||
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, user.TZ())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid range"))
|
||||
@ -79,7 +79,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
summariesView := v1.NewSummariesFrom([]*models.Summary{summary})
|
||||
utils.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
|
||||
helpers.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
|
||||
CachedAt: time.Now(),
|
||||
Data: *summariesView.Data[0],
|
||||
})
|
||||
|
@ -3,6 +3,7 @@ package v1
|
||||
import (
|
||||
"errors"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -76,7 +77,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
@ -94,24 +95,24 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
var start, end time.Time
|
||||
if rangeParam != "" {
|
||||
// range param takes precedence
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
|
||||
if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
||||
}
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
|
||||
} else if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
|
||||
// also accept start param to be a range param
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
// eventually, consider start and end params a date
|
||||
var err error
|
||||
|
||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
|
||||
start, err = helpers.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
|
||||
end, err = helpers.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
@ -133,7 +134,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
// filtering
|
||||
filters := utils.ParseSummaryFilters(r)
|
||||
filters := helpers.ParseSummaryFilters(r)
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))
|
||||
|
@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -9,7 +10,6 @@ import (
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type UsersHandler struct {
|
||||
@ -56,5 +56,5 @@ func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, r, http.StatusOK, v1.UserViewModel{Data: user})
|
||||
helpers.RespondJSON(w, r, http.StatusOK, v1.UserViewModel{Data: user})
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -46,10 +47,10 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.HomeViewModel {
|
||||
var totalHours int
|
||||
var totalUsers int
|
||||
var newsbox view.Newsbox
|
||||
@ -72,11 +73,10 @@ func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
vm := &view.HomeViewModel{
|
||||
TotalHours: totalHours,
|
||||
TotalUsers: totalUsers,
|
||||
Newsbox: &newsbox,
|
||||
}
|
||||
return routeutils.WithSessionMessages(vm, r, w)
|
||||
}
|
||||
|
@ -39,8 +39,5 @@ func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
||||
return &view.ImprintViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
return &view.ImprintViewModel{}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -38,6 +39,7 @@ func (h *LeaderboardHandler) RegisterRoutes(router *mux.Router) {
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userService).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithRedirectErrorMessage("unauthorized").
|
||||
WithOptionalFor([]string{"/"}).
|
||||
Handler,
|
||||
)
|
||||
@ -48,12 +50,12 @@ func (h *LeaderboardHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r)); err != nil {
|
||||
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r, w)); err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardViewModel {
|
||||
func (h *LeaderboardHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.LeaderboardViewModel {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
byParam := strings.ToLower(r.URL.Query().Get("by"))
|
||||
keyParam := strings.ToLower(r.URL.Query().Get("key"))
|
||||
@ -71,7 +73,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, pageParams, true)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
||||
return &view.LeaderboardViewModel{Error: criticalError}
|
||||
return &view.LeaderboardViewModel{
|
||||
Messages: view.Messages{Error: criticalError},
|
||||
}
|
||||
}
|
||||
|
||||
// regardless of page, always show own rank
|
||||
@ -88,7 +92,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, pageParams, true)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
||||
return &view.LeaderboardViewModel{Error: criticalError}
|
||||
return &view.LeaderboardViewModel{
|
||||
Messages: view.Messages{Error: criticalError},
|
||||
}
|
||||
}
|
||||
|
||||
// regardless of page, always show own rank
|
||||
@ -120,7 +126,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
leaderboard = leaderboard.TopByKey(by, keyParam)
|
||||
}
|
||||
} else {
|
||||
return &view.LeaderboardViewModel{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)}
|
||||
return &view.LeaderboardViewModel{
|
||||
Messages: view.Messages{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +137,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
apiKey = user.ApiKey
|
||||
}
|
||||
|
||||
return &view.LeaderboardViewModel{
|
||||
vm := &view.LeaderboardViewModel{
|
||||
User: user,
|
||||
By: byParam,
|
||||
Key: keyParam,
|
||||
@ -138,7 +146,6 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
TopKeys: topKeys,
|
||||
ApiKey: apiKey,
|
||||
PageParams: pageParams,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
return routeutils.WithSessionMessages(vm, r, w)
|
||||
}
|
||||
|
@ -5,8 +5,10 @@ import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -31,13 +33,21 @@ func NewLoginHandler(userService services.IUserService, mailService services.IMa
|
||||
func (h *LoginHandler) RegisterRoutes(router *mux.Router) {
|
||||
router.Path("/login").Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
router.Path("/login").Methods(http.MethodPost).HandlerFunc(h.PostLogin)
|
||||
router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
|
||||
router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup)
|
||||
router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup)
|
||||
router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword)
|
||||
router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword)
|
||||
router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword)
|
||||
router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword)
|
||||
|
||||
authMiddleware := middlewares.NewAuthenticateMiddleware(h.userSrvc).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithRedirectErrorMessage("unauthorized").
|
||||
WithOptionalFor([]string{"/logout"})
|
||||
|
||||
logoutRouter := router.PathPrefix("/logout").Subrouter()
|
||||
logoutRouter.Use(authMiddleware.Handler)
|
||||
logoutRouter.Path("").Methods(http.MethodPost).HandlerFunc(h.PostLogout)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@ -50,7 +60,7 @@ func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@ -66,25 +76,25 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CompareBcrypt(user.Password, login.Password, h.config.Security.PasswordSalt) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -92,7 +102,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to encode secure cookie - %v", err)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r, w).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -108,6 +118,9 @@ func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if user := middlewares.GetPrincipal(r); user != nil {
|
||||
h.userSrvc.FlushUserCache(user.ID)
|
||||
}
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
@ -122,7 +135,7 @@ func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
@ -132,7 +145,7 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if !h.config.IsDev() && !h.config.Security.AllowSignup {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("registration is disabled on this server"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("registration is disabled on this server"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -144,18 +157,18 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -165,23 +178,24 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to create new user - %v", err)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r, w).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
|
||||
routeutils.SetSuccess(r, w, "account created successfully")
|
||||
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
@ -193,12 +207,12 @@ func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
token := values.Get("token")
|
||||
if token == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid or missing token"))
|
||||
return
|
||||
}
|
||||
|
||||
vm := &view.SetPasswordViewModel{
|
||||
LoginViewModel: *h.buildViewModel(r),
|
||||
LoginViewModel: *h.buildViewModel(r, w),
|
||||
Token: token,
|
||||
}
|
||||
|
||||
@ -213,25 +227,25 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var setRequest models.SetPasswordRequest
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserByResetToken(setRequest.Token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid token"))
|
||||
return
|
||||
}
|
||||
|
||||
if !setRequest.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -240,7 +254,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to set new password"))
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
@ -249,11 +263,12 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to save new password - %v", err)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to save new password"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound)
|
||||
routeutils.SetSuccess(r, w, "password updated successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/login", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
@ -263,19 +278,19 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if !h.config.Mail.Enabled {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server"))
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("mailing is disabled on this server"))
|
||||
return
|
||||
}
|
||||
|
||||
var resetRequest models.ResetPasswordRequest
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -283,7 +298,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to generate password reset token - %v", err)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r, w).WithError("failed to generate password reset token"))
|
||||
return
|
||||
} else {
|
||||
go func(user *models.User) {
|
||||
@ -299,16 +314,16 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
conf.Log().Request(r).Warn("password reset requested for unregistered address '%s'", resetRequest.Email)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound)
|
||||
routeutils.SetSuccess(r, w, "an e-mail was sent to you in case your e-mail address was registered")
|
||||
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.LoginViewModel {
|
||||
numUsers, _ := h.userSrvc.Count()
|
||||
|
||||
return &view.LoginViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
vm := &view.LoginViewModel{
|
||||
TotalUsers: int(numUsers),
|
||||
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
||||
}
|
||||
return routeutils.WithSessionMessages(vm, r, w)
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -24,16 +24,16 @@ func Init() {
|
||||
func DefaultTemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"datetime": utils.FormatDateTimeHuman,
|
||||
"simpledate": utils.FormatDate,
|
||||
"simpledatetime": utils.FormatDateTime,
|
||||
"duration": utils.FmtWakatimeDuration,
|
||||
"date": helpers.FormatDateHuman,
|
||||
"datetime": helpers.FormatDateTimeHuman,
|
||||
"simpledate": helpers.FormatDate,
|
||||
"simpledatetime": helpers.FormatDateTime,
|
||||
"duration": helpers.FmtWakatimeDuration,
|
||||
"floordate": datetime.BeginOfDay,
|
||||
"ceildate": utils.CeilDate,
|
||||
"title": strings.Title,
|
||||
"join": strings.Join,
|
||||
"add": utils.Add,
|
||||
"add": add,
|
||||
"capitalize": utils.Capitalize,
|
||||
"lower": strings.ToLower,
|
||||
"toRunes": utils.ToRunes,
|
||||
@ -104,5 +104,9 @@ func loadTemplates() {
|
||||
}
|
||||
|
||||
func defaultErrorRedirectTarget() string {
|
||||
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
|
||||
return config.Get().Server.BasePath + "/"
|
||||
}
|
||||
|
||||
func add(i, j int) int {
|
||||
return i + j
|
||||
}
|
||||
|
@ -69,7 +69,10 @@ func NewSettingsHandler(
|
||||
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/settings").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithRedirectErrorMessage("unauthorized").
|
||||
Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
|
||||
@ -79,7 +82,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@ -89,7 +92,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing form values"))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing form values"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -100,7 +103,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if actionFunc == nil {
|
||||
logbuch.Warn("failed to dispatch action '%s'", action)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("unknown action requests"))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("unknown action requests"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -113,15 +116,15 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if errorMsg != "" {
|
||||
w.WriteHeader(status)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError(errorMsg))
|
||||
return
|
||||
}
|
||||
if successMsg != "" {
|
||||
w.WriteHeader(status)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithSuccess(successMsg))
|
||||
return
|
||||
}
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) dispatchAction(action string) action {
|
||||
@ -178,7 +181,11 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
if !payload.IsValid() {
|
||||
return http.StatusBadRequest, "", "invalid parameters"
|
||||
return http.StatusBadRequest, "", "invalid parameters - perhaps invalid e-mail address?"
|
||||
}
|
||||
|
||||
if payload.Email == "" && user.HasActiveSubscription() {
|
||||
return http.StatusBadRequest, "", "cannot unset email while subscription is active"
|
||||
}
|
||||
|
||||
user.Email = payload.Email
|
||||
@ -282,7 +289,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
|
||||
var err error
|
||||
user := middlewares.GetPrincipal(r)
|
||||
|
||||
defer h.userSrvc.FlushCache()
|
||||
defer h.userSrvc.FlushUserCache(user.ID)
|
||||
|
||||
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
|
||||
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
|
||||
@ -618,8 +625,9 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}(user)
|
||||
|
||||
routeutils.SetSuccess(r, w, "Your account will be deleted in a few minutes. Sorry to you go.")
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "Your account will be deleted in a few minutes. Sorry to you go."), http.StatusFound)
|
||||
http.Redirect(w, r, h.config.Server.BasePath, http.StatusFound)
|
||||
return -1, "", ""
|
||||
}
|
||||
|
||||
@ -657,19 +665,19 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) boo
|
||||
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
||||
logbuch.Info("clearing summaries for user '%s'", user.ID)
|
||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
||||
logbuch.Error("failed to clear summaries: %v", err)
|
||||
conf.Log().Error("failed to clear summaries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil {
|
||||
logbuch.Error("failed to regenerate summaries: %v", err)
|
||||
if err := h.aggregationSrvc.AggregateSummaries(datastructure.NewSet(user.ID)); err != nil {
|
||||
conf.Log().Error("failed to regenerate summaries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SettingsViewModel {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
|
||||
// mappings
|
||||
@ -679,7 +687,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while building alias map - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
|
||||
}
|
||||
aliasMap := make(map[string][]*models.Alias)
|
||||
for _, a := range aliases {
|
||||
@ -708,7 +716,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
|
||||
}
|
||||
|
||||
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
||||
@ -730,17 +738,33 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
|
||||
}
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Labels: combinedLabels,
|
||||
Projects: projects,
|
||||
ApiKey: user.ApiKey,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
// subscriptions
|
||||
var subscriptionPrice string
|
||||
if h.config.Subscriptions.Enabled {
|
||||
subscriptionPrice = h.config.Subscriptions.StandardPrice
|
||||
}
|
||||
|
||||
// user first data
|
||||
var firstData time.Time
|
||||
firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID))
|
||||
if firstDataKv.Value != "" {
|
||||
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
|
||||
}
|
||||
|
||||
vm := &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Labels: combinedLabels,
|
||||
Projects: projects,
|
||||
ApiKey: user.ApiKey,
|
||||
UserFirstData: firstData,
|
||||
SubscriptionPrice: subscriptionPrice,
|
||||
SupportContact: h.config.App.SupportContact,
|
||||
DataRetentionMonths: h.config.App.DataRetentionMonths,
|
||||
}
|
||||
return routeutils.WithSessionMessages(vm, r, w)
|
||||
}
|
||||
|
347
routes/subscription.go
Normal file
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
su "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -27,11 +29,17 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
|
||||
|
||||
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
|
||||
r1 := router.PathPrefix("/summary").Subrouter()
|
||||
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
|
||||
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithRedirectErrorMessage("unauthorized").
|
||||
Handler)
|
||||
r1.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
|
||||
r2 := router.PathPrefix("/summary").Subrouter()
|
||||
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
|
||||
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithRedirectErrorMessage("unauthorized").
|
||||
Handler)
|
||||
r2.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
}
|
||||
|
||||
@ -43,23 +51,33 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
rawQuery := r.URL.RawQuery
|
||||
q := r.URL.Query()
|
||||
if q.Get("interval") == "" && q.Get("from") == "" {
|
||||
// If the PersistentIntervalKey cookie is set, redirect to the correct summary page
|
||||
if intervalCookie, _ := r.Cookie(models.PersistentIntervalKey); intervalCookie != nil {
|
||||
redirectAddress := fmt.Sprintf("%s/summary?interval=%s", h.config.Server.BasePath, intervalCookie.Value)
|
||||
http.Redirect(w, r, redirectAddress, http.StatusFound)
|
||||
}
|
||||
|
||||
q.Set("interval", "today")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
} else if q.Get("interval") != "" {
|
||||
// Send a Set-Cookie header to persist the interval
|
||||
headerValue := fmt.Sprintf("%s=%s", models.PersistentIntervalKey, q.Get("interval"))
|
||||
w.Header().Add("Set-Cookie", headerValue)
|
||||
}
|
||||
|
||||
summaryParams, _ := utils.ParseSummaryParams(r)
|
||||
summaryParams, _ := helpers.ParseSummaryParams(r)
|
||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
conf.Log().Request(r).Error("failed to load summary - %v", err)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -67,9 +85,9 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
Summary: summary,
|
||||
SummaryParams: summaryParams,
|
||||
User: user,
|
||||
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
||||
EditorColors: su.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||
LanguageColors: su.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||
OSColors: su.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
||||
ApiKey: user.ApiKey,
|
||||
RawQuery: rawQuery,
|
||||
}
|
||||
@ -77,9 +95,6 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templates[conf.SummaryTemplate].Execute(w, vm)
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
|
||||
return &view.SummaryViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
func (h *SummaryHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SummaryViewModel {
|
||||
return su.WithSessionMessages(&view.SummaryViewModel{}, r, w)
|
||||
}
|
||||
|
@ -2,8 +2,8 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
@ -31,12 +31,12 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI
|
||||
|
||||
var intervalKey = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
if i, err := utils.ParseInterval(groups[1]); err == nil {
|
||||
if i, err := helpers.ParseInterval(groups[1]); err == nil {
|
||||
intervalKey = i
|
||||
}
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
|
||||
_, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
|
||||
interval := &models.KeyedInterval{
|
||||
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
|
||||
Key: intervalKey,
|
||||
|
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
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
summaryParams, err := helpers.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
@ -38,3 +39,13 @@ func LoadUserSummaryByParams(ss services.ISummaryService, params *models.Summary
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
||||
func FilterColors(all map[string]string, haystack models.SummaryItems) map[string]string {
|
||||
subset := make(map[string]string)
|
||||
for _, item := range haystack {
|
||||
if c, ok := all[strings.ToLower(item.Key)]; ok {
|
||||
subset[strings.ToLower(item.Key)] = c
|
||||
}
|
||||
}
|
||||
return subset
|
||||
}
|
||||
|
@ -77,6 +77,7 @@ let icons = [
|
||||
'mdi:code-json',
|
||||
'mdi:bash',
|
||||
'twemoji:frowning-face',
|
||||
'ci:dot-03-m'
|
||||
]
|
||||
|
||||
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
|
||||
|
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"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/artifex/v2"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
@ -25,6 +24,8 @@ type AggregationService struct {
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
inProgress datastructure.Set[string]
|
||||
queueDefault *artifex.Dispatcher
|
||||
queueWorkers *artifex.Dispatcher
|
||||
}
|
||||
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
@ -34,6 +35,8 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
inProgress: datastructure.NewSet[string](),
|
||||
queueDefault: config.GetDefaultQueue(),
|
||||
queueWorkers: config.GetQueue(config.QueueProcessing),
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,58 +48,23 @@ type AggregationJob struct {
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
func (srv *AggregationService) Schedule() {
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
|
||||
s.StartBlocking()
|
||||
logbuch.Info("scheduling summary aggregation")
|
||||
|
||||
if _, err := srv.queueDefault.DispatchCron(func() {
|
||||
if err := srv.AggregateSummaries(datastructure.NewSet[string]()); err != nil {
|
||||
config.Log().Error("failed to generate summaries, %v", err)
|
||||
}
|
||||
}, srv.config.App.GetAggregationTimeCron()); err != nil {
|
||||
config.Log().Error("failed to schedule summary generation, %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds datastructure.Set[string]) error {
|
||||
func (srv *AggregationService) AggregateSummaries(userIds datastructure.Set[string]) error {
|
||||
if err := srv.lockUsers(userIds); err != nil {
|
||||
return err
|
||||
}
|
||||
defer srv.unlockUsers(userIds)
|
||||
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.summaryWorker(jobs, summaries)
|
||||
}
|
||||
|
||||
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
|
||||
go srv.persistWorker(summaries)
|
||||
}
|
||||
|
||||
// don't leak open channels
|
||||
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
|
||||
defer close(c1)
|
||||
defer close(c2)
|
||||
time.Sleep(1 * time.Hour)
|
||||
}(jobs, summaries)
|
||||
|
||||
return srv.trigger(jobs, userIds)
|
||||
}
|
||||
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
summaries <- summary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds datastructure.Set[string]) error {
|
||||
logbuch.Info("generating summaries")
|
||||
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
@ -119,6 +87,20 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
|
||||
firstUserHeartbeatLookup[e.User] = e.Time
|
||||
}
|
||||
|
||||
// Dispatch summary generation jobs
|
||||
jobs := make(chan *AggregationJob)
|
||||
defer close(jobs)
|
||||
go func() {
|
||||
for jobRef := range jobs {
|
||||
job := *jobRef
|
||||
if err := srv.queueWorkers.Dispatch(func() {
|
||||
srv.process(job)
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to dispatch summary generation job for user '%s'", job.UserID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
|
||||
@ -141,24 +123,15 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
if srv.inProgress.Contain(uid) {
|
||||
return errors.New("aggregation already in progress for at least of the request users")
|
||||
func (srv *AggregationService) process(job AggregationJob) {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
srv.inProgress = srv.inProgress.Union(userIds)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
srv.inProgress.Delete(uid)
|
||||
}
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
@ -189,6 +162,26 @@ func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
if srv.inProgress.Contain(uid) {
|
||||
return errors.New("aggregation already in progress for at least of the request users")
|
||||
}
|
||||
}
|
||||
srv.inProgress = srv.inProgress.Union(userIds)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
srv.inProgress.Delete(uid)
|
||||
}
|
||||
}
|
||||
|
||||
func getStartOfToday() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 1, now.Location())
|
||||
|
@ -192,6 +192,11 @@ func (srv *HeartbeatService) DeleteByUser(user *models.User) error {
|
||||
return srv.repository.DeleteByUser(user)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteByUserBefore(user *models.User, t time.Time) error {
|
||||
go srv.cache.Flush()
|
||||
return srv.repository.DeleteByUserBefore(user, t)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
|
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"
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/artifex/v2"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
@ -29,19 +31,25 @@ const (
|
||||
)
|
||||
|
||||
type WakatimeHeartbeatImporter struct {
|
||||
ApiKey string
|
||||
ApiKey string
|
||||
httpClient *http.Client
|
||||
queue *artifex.Dispatcher
|
||||
}
|
||||
|
||||
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
|
||||
return &WakatimeHeartbeatImporter{
|
||||
ApiKey: apiKey,
|
||||
ApiKey: apiKey,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
queue: config.GetQueue(config.QueueImports),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
|
||||
out := make(chan *models.Heartbeat)
|
||||
|
||||
go func(user *models.User, out chan *models.Heartbeat) {
|
||||
process := func(user *models.User, minFrom time.Time, maxTo time.Time, out chan *models.Heartbeat) {
|
||||
logbuch.Info("running wakatime import for user '%s'", user.ID)
|
||||
|
||||
baseUrl := user.WakaTimeURL(config.WakatimeApiUrl)
|
||||
|
||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||
@ -58,18 +66,22 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
}
|
||||
|
||||
userAgents := map[string]*wakatime.UserAgentEntry{}
|
||||
//userAgents, err := w.fetchUserAgents(baseUrl)
|
||||
// if err != nil {
|
||||
// config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
// return
|
||||
// }
|
||||
if data, err := w.fetchUserAgents(baseUrl); err == nil {
|
||||
userAgents = data
|
||||
} else if strings.Contains(baseUrl, "wakatime.com") {
|
||||
// when importing from wakatime, resolving user agents is mandatorily required
|
||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
machinesNames := map[string]*wakatime.MachineEntry{}
|
||||
// machinesNames, err := w.fetchMachineNames(baseUrl)
|
||||
// if err != nil {
|
||||
// config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
// return
|
||||
// }
|
||||
if data, err := w.fetchMachineNames(baseUrl); err == nil {
|
||||
machinesNames = data
|
||||
} else if strings.Contains(baseUrl, "wakatime.com") {
|
||||
// when importing from wakatime, resolving machine names is mandatorily required
|
||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
days := generateDays(startDate, endDate)
|
||||
|
||||
@ -90,7 +102,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
d := day.Format(config.SimpleDateFormat)
|
||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err)
|
||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - %v", d, user.ID, err)
|
||||
}
|
||||
|
||||
for _, h := range heartbeats {
|
||||
@ -102,7 +114,18 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
}
|
||||
}(d)
|
||||
}
|
||||
}(user, out)
|
||||
}
|
||||
|
||||
if minDataAge := user.MinDataAge(); minFrom.Before(minDataAge) {
|
||||
logbuch.Info("wakatime data import for user '%s' capped to [%v, &v]", user.ID, minDataAge, maxTo)
|
||||
}
|
||||
|
||||
logbuch.Info("scheduling wakatime import for user '%s' (interval [%v, &v])", user.ID, minFrom, maxTo)
|
||||
if err := w.queue.Dispatch(func() {
|
||||
process(user, minFrom, maxTo, out)
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to dispatch wakatime import job for user '%s', %v", user.ID, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@ -114,8 +137,6 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
|
||||
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -125,12 +146,13 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
|
||||
q.Add("date", day)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if res.StatusCode >= 400 {
|
||||
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
||||
@ -143,8 +165,6 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
|
||||
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
||||
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
notime := time.Time{}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
|
||||
@ -152,7 +172,7 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
|
||||
return notime, notime, err
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return notime, notime, err
|
||||
}
|
||||
@ -179,8 +199,6 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
|
||||
// https://wakatime.com/api/v1/users/current/user_agents
|
||||
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||
|
||||
for page := 1; ; page++ {
|
||||
@ -190,10 +208,11 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var userAgentsData wakatime.UserAgentsViewModel
|
||||
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
|
||||
@ -230,6 +249,7 @@ func (w *WakatimeHeartbeatImporter) fetchMachineNames(baseUrl string) (map[strin
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var machineData wakatime.MachineViewModel
|
||||
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
||||
|
@ -22,6 +22,10 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
|
||||
return srv.repository.GetString(key)
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) GetByPrefix(prefix string) ([]*models.KeyStringValue, error) {
|
||||
return srv.repository.Search(prefix + "%")
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
|
||||
kv, err := srv.repository.GetString(key)
|
||||
if err != nil {
|
||||
|
@ -2,9 +2,10 @@ package services
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/artifex/v2"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -22,6 +23,8 @@ type LeaderboardService struct {
|
||||
repository repositories.ILeaderboardRepository
|
||||
summaryService ISummaryService
|
||||
userService IUserService
|
||||
queueDefault *artifex.Dispatcher
|
||||
queueWorkers *artifex.Dispatcher
|
||||
}
|
||||
|
||||
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
|
||||
@ -32,6 +35,8 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
||||
repository: leaderboardRepo,
|
||||
summaryService: summaryService,
|
||||
userService: userService,
|
||||
queueDefault: config.GetDefaultQueue(),
|
||||
queueWorkers: config.GetQueue(config.QueueProcessing),
|
||||
}
|
||||
|
||||
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
||||
@ -48,7 +53,7 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
||||
|
||||
if user.PublicLeaderboard && !exists {
|
||||
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
|
||||
srv.Run([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
srv.ComputeLeaderboard([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
} else if !user.PublicLeaderboard && exists {
|
||||
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
|
||||
if err := srv.repository.DeleteByUser(user.ID); err != nil {
|
||||
@ -62,23 +67,26 @@ func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository,
|
||||
return srv
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) ScheduleDefault() {
|
||||
runAllUsers := func(interval *models.IntervalKey, by []uint8) {
|
||||
func (srv *LeaderboardService) Schedule() {
|
||||
logbuch.Info("scheduling leaderboard generation")
|
||||
|
||||
generate := func() {
|
||||
users, err := srv.userService.GetAllByLeaderboard(true)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to get users for leaderboard generation - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
srv.Run(users, interval, by)
|
||||
srv.ComputeLeaderboard(users, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.LeaderboardGenerationTime).Do(runAllUsers, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
s.StartBlocking()
|
||||
for _, cronExp := range srv.config.App.GetLeaderboardGenerationTimeCron() {
|
||||
if _, err := srv.queueDefault.DispatchCron(generate, cronExp); err != nil {
|
||||
config.Log().Error("failed to schedule leaderboard generation (%s), %v", cronExp, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) Run(users []*models.User, interval *models.IntervalKey, by []uint8) error {
|
||||
func (srv *LeaderboardService) ComputeLeaderboard(users []*models.User, interval *models.IntervalKey, by []uint8) error {
|
||||
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
|
||||
|
||||
for _, user := range users {
|
||||
@ -140,7 +148,7 @@ func (srv *LeaderboardService) CountUsers() (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
|
||||
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *utils.PageParams, resolveUsers bool) (models.Leaderboard, error) {
|
||||
return srv.GetAggregatedByInterval(interval, nil, pageParams, resolveUsers)
|
||||
}
|
||||
|
||||
@ -148,7 +156,7 @@ func (srv *LeaderboardService) GetByIntervalAndUser(interval *models.IntervalKey
|
||||
return srv.GetAggregatedByIntervalAndUser(interval, userId, nil, resolveUser)
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
|
||||
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 {
|
||||
@ -205,7 +213,7 @@ func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.I
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -224,7 +232,7 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -252,7 +260,7 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *models.PageParams) string {
|
||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *utils.PageParams) string {
|
||||
k := strings.Join(*interval, "__") + "__" + user
|
||||
if by != nil && !reflect.ValueOf(by).IsNil() {
|
||||
k += "__" + models.GetEntityColumn(*by)
|
||||
|
@ -3,6 +3,7 @@ package mail
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -18,10 +19,12 @@ const (
|
||||
tplNameImportNotification = "import_finished"
|
||||
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
|
||||
tplNameReport = "report"
|
||||
tplNameSubscriptionNotification = "subscription_expiring"
|
||||
subjectPasswordReset = "Wakapi - Password Reset"
|
||||
subjectImportNotification = "Wakapi - Data Import Finished"
|
||||
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
|
||||
subjectReport = "Wakapi - Report from %s"
|
||||
subjectSubscriptionNotification = "Wakapi - Subscription expiring / expired"
|
||||
)
|
||||
|
||||
type SendingService interface {
|
||||
@ -115,7 +118,25 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
|
||||
mail := &models.Mail{
|
||||
From: models.MailAddress(m.config.Mail.Sender),
|
||||
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
|
||||
Subject: fmt.Sprintf(subjectReport, utils.FormatDateHuman(time.Now().In(recipient.TZ()))),
|
||||
Subject: fmt.Sprintf(subjectReport, helpers.FormatDateHuman(time.Now().In(recipient.TZ()))),
|
||||
}
|
||||
mail.WithHTML(tpl.String())
|
||||
return m.sendingService.Send(mail)
|
||||
}
|
||||
|
||||
func (m *MailService) SendSubscriptionNotification(recipient *models.User, hasExpired bool) error {
|
||||
tpl, err := m.getSubscriptionNotificationTemplate(SubscriptionNotificationTplData{
|
||||
PublicUrl: m.config.Server.PublicUrl,
|
||||
DataRetentionMonths: m.config.App.DataRetentionMonths,
|
||||
HasExpired: hasExpired,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mail := &models.Mail{
|
||||
From: models.MailAddress(m.config.Mail.Sender),
|
||||
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
|
||||
Subject: subjectSubscriptionNotification,
|
||||
}
|
||||
mail.WithHTML(tpl.String())
|
||||
return m.sendingService.Send(mail)
|
||||
@ -153,6 +174,14 @@ func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, erro
|
||||
return &rendered, nil
|
||||
}
|
||||
|
||||
func (m *MailService) getSubscriptionNotificationTemplate(data SubscriptionNotificationTplData) (*bytes.Buffer, error) {
|
||||
var rendered bytes.Buffer
|
||||
if err := m.templates[m.fmtName(tplNameSubscriptionNotification)].Execute(&rendered, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rendered, nil
|
||||
}
|
||||
|
||||
func (m *MailService) fmtName(name string) string {
|
||||
return fmt.Sprintf("%s.tpl.html", name)
|
||||
}
|
||||
|
@ -42,7 +42,12 @@ func (s *SMTPSendingService) Send(mail *models.Mail) error {
|
||||
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err = c.StartTLS(nil); err != nil {
|
||||
return err
|
||||
errCode := err.(*smtp.SMTPError).Code
|
||||
if errCode == 503 {
|
||||
// TLS already active
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.auth != nil {
|
||||
|
@ -20,3 +20,9 @@ type WakatimeFailureNotificationNotificationTplData struct {
|
||||
type ReportTplData struct {
|
||||
Report *models.Report
|
||||
}
|
||||
|
||||
type SubscriptionNotificationTplData struct {
|
||||
PublicUrl string
|
||||
HasExpired bool
|
||||
DataRetentionMonths int
|
||||
}
|
||||
|
305
services/misc.go
305
services/misc.go
@ -1,104 +1,269 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/artifex/v2"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"go.uber.org/atomic"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const (
|
||||
countUsersEvery = 1 * time.Hour
|
||||
computeOldestDataEvery = 6 * time.Hour
|
||||
notifyExpiringSubscriptionsEvery = 12 * time.Hour
|
||||
)
|
||||
|
||||
const (
|
||||
notifyBeforeSubscriptionExpiry = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var countLock = sync.Mutex{}
|
||||
var firstDataLock = sync.Mutex{}
|
||||
|
||||
type MiscService struct {
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
keyValueService IKeyValueService
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
heartbeatService IHeartbeatService
|
||||
summaryService ISummaryService
|
||||
keyValueService IKeyValueService
|
||||
mailService IMailService
|
||||
queueDefault *artifex.Dispatcher
|
||||
queueWorkers *artifex.Dispatcher
|
||||
queueMails *artifex.Dispatcher
|
||||
}
|
||||
|
||||
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
|
||||
func NewMiscService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService, keyValueService IKeyValueService, mailService IMailService) *MiscService {
|
||||
return &MiscService{
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
keyValueService: keyValueService,
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
heartbeatService: heartbeatService,
|
||||
summaryService: summaryService,
|
||||
keyValueService: keyValueService,
|
||||
mailService: mailService,
|
||||
queueDefault: config.GetDefaultQueue(),
|
||||
queueWorkers: config.GetQueue(config.QueueProcessing),
|
||||
queueMails: config.GetQueue(config.QueueMails),
|
||||
}
|
||||
}
|
||||
|
||||
type CountTotalTimeJob struct {
|
||||
UserID string
|
||||
NumJobs int
|
||||
}
|
||||
|
||||
type CountTotalTimeResult struct {
|
||||
UserId string
|
||||
Total time.Duration
|
||||
}
|
||||
|
||||
func (srv *MiscService) ScheduleCountTotalTime() {
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime)
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *MiscService) runCountTotalTime() error {
|
||||
users, err := srv.userService.GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
func (srv *MiscService) Schedule() {
|
||||
logbuch.Info("scheduling total time counting")
|
||||
if _, err := srv.queueDefault.DispatchEvery(srv.CountTotalTime, countUsersEvery); err != nil {
|
||||
config.Log().Error("failed to schedule user counting jobs, %v", err)
|
||||
}
|
||||
|
||||
jobs := make(chan *CountTotalTimeJob, len(users))
|
||||
results := make(chan *CountTotalTimeResult, len(users))
|
||||
logbuch.Info("scheduling first data computing")
|
||||
if _, err := srv.queueDefault.DispatchEvery(srv.ComputeOldestHeartbeats, computeOldestDataEvery); err != nil {
|
||||
config.Log().Error("failed to schedule first data computing jobs, %v", err)
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
jobs <- &CountTotalTimeJob{
|
||||
UserID: u.ID,
|
||||
NumJobs: len(users),
|
||||
if srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
|
||||
logbuch.Info("scheduling subscription notifications")
|
||||
if _, err := srv.queueDefault.DispatchEvery(srv.NotifyExpiringSubscription, notifyExpiringSubscriptionsEvery); err != nil {
|
||||
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.countTotalTimeWorker(jobs, results)
|
||||
// run once initially for a fresh instance
|
||||
if !srv.existsUsersTotalTime() {
|
||||
if err := srv.queueDefault.Dispatch(srv.CountTotalTime); err != nil {
|
||||
config.Log().Error("failed to dispatch user counting jobs, %v", err)
|
||||
}
|
||||
}
|
||||
if !srv.existsUsersFirstData() {
|
||||
if err := srv.queueDefault.Dispatch(srv.ComputeOldestHeartbeats); err != nil {
|
||||
config.Log().Error("failed to dispatch first data computing jobs, %v", err)
|
||||
}
|
||||
}
|
||||
if !srv.existsSubscriptionNotifications() && srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
|
||||
if err := srv.queueDefault.Dispatch(srv.NotifyExpiringSubscription); err != nil {
|
||||
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *MiscService) CountTotalTime() {
|
||||
logbuch.Info("counting users total time")
|
||||
if ok := countLock.TryLock(); !ok {
|
||||
config.Log().Warn("couldn't acquire lock for counting users total time, job is still pending")
|
||||
}
|
||||
defer countLock.Unlock()
|
||||
|
||||
users, err := srv.userService.GetAll()
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch users for time counting, %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var totalTime = atomic.NewDuration(0)
|
||||
var pendingJobs sync.WaitGroup
|
||||
pendingJobs.Add(len(users))
|
||||
|
||||
for _, u := range users {
|
||||
user := *u
|
||||
if err := srv.queueWorkers.Dispatch(func() {
|
||||
defer pendingJobs.Done()
|
||||
totalTime.Add(srv.countUserTotalTime(user.ID))
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to enqueue counting job for user '%s'", user.ID)
|
||||
pendingJobs.Done()
|
||||
}
|
||||
}
|
||||
|
||||
// persist
|
||||
var i int
|
||||
var total time.Duration
|
||||
for i = 0; i < len(users); i++ {
|
||||
result := <-results
|
||||
total += result.Total
|
||||
}
|
||||
close(results)
|
||||
go func(wg *sync.WaitGroup) {
|
||||
if !utils.WaitTimeout(&pendingJobs, 2*countUsersEvery) {
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalTime,
|
||||
Value: totalTime.Load().String(),
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to save total time count: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalTime,
|
||||
Value: total.String(),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total time count: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalUsers,
|
||||
Value: strconv.Itoa(i),
|
||||
}); err != nil {
|
||||
logbuch.Error("failed to save total users count: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: config.KeyLatestTotalUsers,
|
||||
Value: strconv.Itoa(len(users)),
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to save total users count: %v", err)
|
||||
}
|
||||
} else {
|
||||
config.Log().Error("waiting for user counting jobs timed out")
|
||||
}
|
||||
}(&pendingJobs)
|
||||
}
|
||||
|
||||
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
|
||||
for job := range jobs {
|
||||
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil {
|
||||
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
|
||||
} else {
|
||||
results <- &CountTotalTimeResult{
|
||||
UserId: job.UserID,
|
||||
Total: result.TotalTime(),
|
||||
func (srv *MiscService) ComputeOldestHeartbeats() {
|
||||
logbuch.Info("computing users' first data")
|
||||
|
||||
if err := srv.queueWorkers.Dispatch(func() {
|
||||
if ok := firstDataLock.TryLock(); !ok {
|
||||
config.Log().Warn("couldn't acquire lock for computing users' first data, job is still pending")
|
||||
return
|
||||
}
|
||||
defer firstDataLock.Unlock()
|
||||
|
||||
results, err := srv.heartbeatService.GetFirstByUsers()
|
||||
if err != nil {
|
||||
config.Log().Error("failed to compute users' first data, %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range results {
|
||||
if entry.Time.T().IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
kvKey := fmt.Sprintf("%s_%s", config.KeyFirstHeartbeat, entry.User)
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: kvKey,
|
||||
Value: entry.Time.T().Format(time.RFC822Z),
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to save user's first heartbeat time: %v", err)
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to enqueue computing first data for user, %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyExpiringSubscription sends a reminder e-mail to all users, notifying them if their subscription has expired or is about to, given these conditions:
|
||||
// - Data cleanup is enabled on the server (non-zero retention time)
|
||||
// - Subscriptions are enabled on the server (aka. users can do something about their old data getting cleaned up)
|
||||
// - User has an e-mail address configured
|
||||
// - User's subscription has expired or is about to expire soon
|
||||
// - The user has gotten no such e-mail before recently
|
||||
// Note: only one mail will be sent for either "expired" or "about to expire" state.
|
||||
func (srv *MiscService) NotifyExpiringSubscription() {
|
||||
if srv.config.App.DataRetentionMonths <= 0 || !srv.config.Subscriptions.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
logbuch.Info("notifying users about soon to expire subscriptions")
|
||||
|
||||
users, err := srv.userService.GetAll()
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch users for subscription notifications, %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var subscriptionReminders map[string][]*models.KeyStringValue
|
||||
if result, err := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent); err == nil {
|
||||
subscriptionReminders = slice.GroupWith[*models.KeyStringValue, string](result, func(kv *models.KeyStringValue) string {
|
||||
return strings.TrimPrefix(kv.Key, config.KeySubscriptionNotificationSent+"_")
|
||||
})
|
||||
} else {
|
||||
config.Log().Error("failed to fetch key-values for subscription notifications, %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
if u.HasActiveSubscription() && u.Email == "" {
|
||||
config.Log().Warn("invalid state: user '%s' has active subscription but no e-mail address set", u.ID)
|
||||
}
|
||||
|
||||
// skip users without e-mail address
|
||||
// skip users who already received a notification before
|
||||
// skip users who either never had a subscription before or intentionally deleted it
|
||||
if _, ok := subscriptionReminders[u.ID]; ok || u.Email == "" || u.SubscribedUntil == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
expired, expiredSince := u.SubscriptionExpiredSince()
|
||||
if expired || (expiredSince < 0 && expiredSince*-1 <= notifyBeforeSubscriptionExpiry) {
|
||||
srv.sendSubscriptionNotificationScheduled(u, expired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
|
||||
result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to count total for user %s: %v", userId, err)
|
||||
return 0
|
||||
}
|
||||
return result.TotalTime()
|
||||
}
|
||||
|
||||
func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User, hasExpired bool) {
|
||||
u := *user
|
||||
srv.queueMails.Dispatch(func() {
|
||||
logbuch.Info("sending subscription expiry notification mail to %s (expired: %v)", u.ID, hasExpired)
|
||||
defer time.Sleep(10 * time.Second)
|
||||
|
||||
if err := srv.mailService.SendSubscriptionNotification(&u, hasExpired); err != nil {
|
||||
config.Log().Error("failed to send subscription notification mail to user '%s', %v", u.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := srv.keyValueService.PutString(&models.KeyStringValue{
|
||||
Key: fmt.Sprintf("%s_%s", config.KeySubscriptionNotificationSent, u.ID),
|
||||
Value: time.Now().Format(time.RFC822Z),
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to update subscription notification status key-value for user %s, %v", u.ID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (srv *MiscService) existsUsersTotalTime() bool {
|
||||
results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime)
|
||||
return len(results) > 0
|
||||
}
|
||||
|
||||
func (srv *MiscService) existsUsersFirstData() bool {
|
||||
results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat)
|
||||
return len(results) > 0
|
||||
}
|
||||
|
||||
func (srv *MiscService) existsSubscriptionNotifications() bool {
|
||||
results, _ := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent)
|
||||
return len(results) > 0
|
||||
}
|
||||
|
@ -1,21 +1,21 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/artifex/v2"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var reportLock = sync.Mutex{}
|
||||
// delay between evey report generation task (to throttle email sending frequency)
|
||||
const reportDelay = 10 * time.Second
|
||||
|
||||
// range for random offset to add / subtract when scheduling a new job
|
||||
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
|
||||
const offsetIntervalMin = 15
|
||||
// past time range to cover in the report
|
||||
const reportRange = 7 * 24 * time.Hour
|
||||
|
||||
type ReportService struct {
|
||||
config *config.Config
|
||||
@ -23,8 +23,9 @@ type ReportService struct {
|
||||
summaryService ISummaryService
|
||||
userService IUserService
|
||||
mailService IMailService
|
||||
scheduler *gocron.Scheduler
|
||||
rand *rand.Rand
|
||||
queueDefault *artifex.Dispatcher
|
||||
queueWorkers *artifex.Dispatcher
|
||||
}
|
||||
|
||||
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
|
||||
@ -34,80 +35,67 @@ func NewReportService(summaryService ISummaryService, userService IUserService,
|
||||
summaryService: summaryService,
|
||||
userService: userService,
|
||||
mailService: mailService,
|
||||
scheduler: gocron.NewScheduler(time.Local),
|
||||
rand: rand.New(rand.NewSource(time.Now().Unix())),
|
||||
queueDefault: config.GetDefaultQueue(),
|
||||
queueWorkers: config.GetQueue(config.QueueReports),
|
||||
}
|
||||
|
||||
srv.scheduler.StartAsync()
|
||||
|
||||
sub := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
||||
go func(sub *hub.Subscription) {
|
||||
for m := range sub.Receiver {
|
||||
srv.SyncSchedule(m.Fields[config.FieldPayload].(*models.User))
|
||||
}
|
||||
}(&sub)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (srv *ReportService) Schedule() {
|
||||
logbuch.Info("initializing report service")
|
||||
logbuch.Info("scheduling report generation")
|
||||
|
||||
users, err := srv.userService.GetAllByReports(true)
|
||||
if err != nil {
|
||||
config.Log().Fatal("%v", err)
|
||||
}
|
||||
scheduleUserReport := func(u *models.User) {
|
||||
if err := srv.queueWorkers.Dispatch(func() {
|
||||
t0 := time.Now()
|
||||
|
||||
logbuch.Info("scheduling reports for %d users", len(users))
|
||||
for _, u := range users {
|
||||
srv.SyncSchedule(u)
|
||||
}
|
||||
}
|
||||
if err := srv.SendReport(u, reportRange); err != nil {
|
||||
config.Log().Error("failed to generate report for '%s', %v", u.ID, err)
|
||||
}
|
||||
|
||||
// SyncSchedule syncs the currently active schedulers with the user's wish about whether or not to receive reports.
|
||||
// Returns whether a scheduler is active after this operation has run.
|
||||
func (srv *ReportService) SyncSchedule(u *models.User) bool {
|
||||
reportLock.Lock()
|
||||
defer reportLock.Unlock()
|
||||
|
||||
// unschedule
|
||||
if !u.ReportsWeekly {
|
||||
_ = srv.scheduler.RemoveByTag(u.ID)
|
||||
logbuch.Info("disabled scheduled reports for user %s", u.ID)
|
||||
return false
|
||||
}
|
||||
|
||||
// schedule
|
||||
if job := srv.getJobByTag(u.ID); job == nil && u.ReportsWeekly {
|
||||
t, _ := time.ParseInLocation("15:04", srv.config.App.GetWeeklyReportTime(), u.TZ())
|
||||
t = t.Add(time.Duration(srv.rand.Intn(offsetIntervalMin*60)) * time.Second)
|
||||
if job, err := srv.scheduler.
|
||||
SingletonMode().
|
||||
Every(1).
|
||||
Week().
|
||||
Weekday(srv.config.App.GetWeeklyReportDay()).
|
||||
At(t).
|
||||
Tag(u.ID).
|
||||
Do(srv.Run, u, 7*24*time.Hour); err != nil {
|
||||
config.Log().Error("failed to schedule report job for user '%s' - %v", u.ID, err)
|
||||
} else {
|
||||
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
|
||||
// make the job take at least reportDelay seconds
|
||||
if diff := reportDelay - time.Now().Sub(t0); diff > 0 {
|
||||
logbuch.Debug("waiting for %v before sending next report", diff)
|
||||
time.Sleep(diff)
|
||||
}
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to dispatch report generation job for user '%s', %v", u.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return u.ReportsWeekly
|
||||
_, err := srv.queueDefault.DispatchCron(func() {
|
||||
// fetch all users with reports enabled
|
||||
users, err := srv.userService.GetAllByReports(true)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to get users for report generation, %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// filter users who have their email set
|
||||
users = slice.Filter[*models.User](users, func(i int, u *models.User) bool {
|
||||
return u.Email != ""
|
||||
})
|
||||
|
||||
// schedule jobs, throttled by one job per x seconds
|
||||
logbuch.Info("scheduling report generation for %d users", len(users))
|
||||
for _, u := range users {
|
||||
scheduleUserReport(u)
|
||||
}
|
||||
}, srv.config.App.GetWeeklyReportCron())
|
||||
|
||||
if err != nil {
|
||||
config.Log().Error("failed to dispatch report generation jobs, %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
func (srv *ReportService) SendReport(user *models.User, duration time.Duration) error {
|
||||
if user.Email == "" {
|
||||
logbuch.Warn("not generating report for '%s' as no e-mail address is set")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !srv.SyncSchedule(user) {
|
||||
logbuch.Info("reports for user '%s' were turned off in the meanwhile since last report job ran")
|
||||
return nil
|
||||
}
|
||||
logbuch.Info("generating report for '%s'", user.ID)
|
||||
|
||||
end := time.Now().In(user.TZ())
|
||||
start := time.Now().Add(-1 * duration)
|
||||
@ -126,21 +114,10 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
}
|
||||
|
||||
if err := srv.mailService.SendReport(user, report); err != nil {
|
||||
config.Log().Error("failed to send report for '%s' - %v", user.ID, err)
|
||||
config.Log().Error("failed to send report for '%s', %v", user.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
logbuch.Info("sent report to user '%s'", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *ReportService) getJobByTag(tag string) *gocron.Job {
|
||||
for _, j := range srv.scheduler.Jobs() {
|
||||
for _, t := range j.Tags() {
|
||||
if t == tag {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -3,16 +3,18 @@ package services
|
||||
import (
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAggregationService interface {
|
||||
Schedule()
|
||||
Run(set datastructure.Set[string]) error
|
||||
AggregateSummaries(set datastructure.Set[string]) error
|
||||
}
|
||||
|
||||
type IMiscService interface {
|
||||
ScheduleCountTotalTime()
|
||||
Schedule()
|
||||
CountTotalTime()
|
||||
}
|
||||
|
||||
type IAliasService interface {
|
||||
@ -41,6 +43,7 @@ type IHeartbeatService interface {
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
DeleteByUser(*models.User) error
|
||||
DeleteByUserBefore(*models.User, time.Time) error
|
||||
}
|
||||
|
||||
type IDiagnosticsService interface {
|
||||
@ -50,6 +53,7 @@ type IDiagnosticsService interface {
|
||||
type IKeyValueService interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
MustGetString(string) *models.KeyStringValue
|
||||
GetByPrefix(string) ([]*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
@ -76,6 +80,7 @@ type IMailService interface {
|
||||
SendWakatimeFailureNotification(*models.User, int) error
|
||||
SendImportNotification(*models.User, time.Duration, int) error
|
||||
SendReport(*models.User, *models.Report) error
|
||||
SendSubscriptionNotification(*models.User, bool) error
|
||||
}
|
||||
|
||||
type IDurationService interface {
|
||||
@ -88,23 +93,28 @@ type ISummaryService interface {
|
||||
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
|
||||
GetLatestByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
DeleteByUserBefore(string, time.Time) error
|
||||
Insert(*models.Summary) error
|
||||
}
|
||||
|
||||
type IReportService interface {
|
||||
Schedule()
|
||||
SyncSchedule(user *models.User) bool
|
||||
Run(*models.User, time.Duration) error
|
||||
SendReport(*models.User, time.Duration) error
|
||||
}
|
||||
|
||||
type IHousekeepingService interface {
|
||||
Schedule()
|
||||
CleanUserDataBefore(*models.User, time.Time) error
|
||||
}
|
||||
|
||||
type ILeaderboardService interface {
|
||||
ScheduleDefault()
|
||||
Run([]*models.User, *models.IntervalKey, []uint8) error
|
||||
Schedule()
|
||||
ComputeLeaderboard([]*models.User, *models.IntervalKey, []uint8) error
|
||||
ExistsAnyByUser(string) (bool, error)
|
||||
CountUsers() (int64, error)
|
||||
GetByInterval(*models.IntervalKey, *models.PageParams, 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, *models.PageParams, 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)
|
||||
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
|
||||
@ -115,6 +125,7 @@ type IUserService interface {
|
||||
GetUserByKey(string) (*models.User, error)
|
||||
GetUserByEmail(string) (*models.User, error)
|
||||
GetUserByResetToken(string) (*models.User, error)
|
||||
GetUserByStripeCustomerId(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetMany([]string) ([]*models.User, error)
|
||||
GetManyMapped([]string) (map[string]*models.User, error)
|
||||
@ -130,4 +141,5 @@ type IUserService interface {
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
GenerateResetToken(*models.User) (*models.User, error)
|
||||
FlushCache()
|
||||
FlushUserCache(string)
|
||||
}
|
||||
|
@ -208,6 +208,11 @@ func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
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 {
|
||||
srv.invalidateUserCache(summary.UserID)
|
||||
return srv.repository.Insert(summary)
|
||||
|
@ -62,12 +62,12 @@ func (srv *UserService) GetUserById(userId string) (*models.User, error) {
|
||||
return u.(*models.User), nil
|
||||
}
|
||||
|
||||
u, err := srv.repository.GetById(userId)
|
||||
u, err := srv.repository.FindOne(models.User{ID: userId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
|
||||
srv.cache.SetDefault(u.ID, u)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
||||
return u.(*models.User), nil
|
||||
}
|
||||
|
||||
u, err := srv.repository.GetByApiKey(key)
|
||||
u, err := srv.repository.FindOne(models.User{ApiKey: key})
|
||||
if err != nil {
|
||||
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) {
|
||||
return srv.repository.GetByEmail(email)
|
||||
return srv.repository.FindOne(models.User{Email: email})
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -163,19 +167,19 @@ func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*model
|
||||
}
|
||||
|
||||
func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
||||
srv.cache.Flush()
|
||||
srv.FlushUserCache(user.ID)
|
||||
srv.notifyUpdate(user)
|
||||
return srv.repository.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
srv.cache.Flush()
|
||||
srv.FlushUserCache(user.ID)
|
||||
user.ApiKey = uuid.NewV4().String()
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
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 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) {
|
||||
srv.cache.Flush()
|
||||
srv.FlushUserCache(user.ID)
|
||||
user.Password = login.Password
|
||||
if hash, err := utils.HashBcrypt(user.Password, srv.config.Security.PasswordSalt); err != nil {
|
||||
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 {
|
||||
srv.cache.Flush()
|
||||
srv.FlushUserCache(user.ID)
|
||||
|
||||
user.ReportsWeekly = false
|
||||
srv.notifyUpdate(user)
|
||||
@ -218,6 +222,10 @@ func (srv *UserService) FlushCache() {
|
||||
srv.cache.Flush()
|
||||
}
|
||||
|
||||
func (srv *UserService) FlushUserCache(userId string) {
|
||||
srv.cache.Delete(userId)
|
||||
}
|
||||
|
||||
func (srv *UserService) notifyUpdate(user *models.User) {
|
||||
srv.eventBus.Publish(hub.Message{
|
||||
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
Binary file not shown.
38
testing/config.mysql.yml
Normal file
38
testing/config.mysql.yml
Normal file
@ -0,0 +1,38 @@
|
||||
env: production
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1
|
||||
listen_ipv6:
|
||||
tls_cert_path:
|
||||
tls_key_path:
|
||||
port: 3000
|
||||
base_path: /
|
||||
public_url: http://localhost:3000
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
svelte: Svelte
|
||||
|
||||
db:
|
||||
host: 127.0.0.1
|
||||
port: 53306
|
||||
user: wakapi
|
||||
password: wakapi
|
||||
name: wakapi
|
||||
dialect: mysql
|
||||
max_conn: 2
|
||||
ssl: false
|
||||
automgirate_fail_silently: false
|
||||
|
||||
security:
|
||||
password_salt:
|
||||
insecure_cookies: true
|
||||
cookie_max_age: 172800
|
||||
allow_signup: true
|
||||
expose_metrics: true
|
39
testing/config.postgres.yml
Normal file
39
testing/config.postgres.yml
Normal file
@ -0,0 +1,39 @@
|
||||
env: production
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1
|
||||
listen_ipv6:
|
||||
tls_cert_path:
|
||||
tls_key_path:
|
||||
port: 3000
|
||||
base_path: /
|
||||
public_url: http://localhost:3000
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
svelte: Svelte
|
||||
|
||||
db:
|
||||
host: 127.0.0.1
|
||||
port: 55432
|
||||
user: wakapi
|
||||
password: wakapi
|
||||
name: wakapi
|
||||
dialect: postgres
|
||||
charset:
|
||||
max_conn: 2
|
||||
ssl: false
|
||||
automgirate_fail_silently: false
|
||||
|
||||
security:
|
||||
password_salt:
|
||||
insecure_cookies: true
|
||||
cookie_max_age: 172800
|
||||
allow_signup: true
|
||||
expose_metrics: true
|
42
testing/docker-compose.yml
Normal file
42
testing/docker-compose.yml
Normal file
@ -0,0 +1,42 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "wakapi"
|
||||
POSTGRES_DB: "wakapi"
|
||||
PGPORT: 55432
|
||||
network_mode: host
|
||||
volumes:
|
||||
- wakapi-postgres:/var/lib/postgresql/data
|
||||
|
||||
mysql:
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_TCP_PORT: 53306
|
||||
MYSQL_USER: "wakapi"
|
||||
MYSQL_PASSWORD: "wakapi"
|
||||
MYSQL_DATABASE: "wakapi"
|
||||
MYSQL_ROOT_PASSWORD: example
|
||||
network_mode: host
|
||||
volumes:
|
||||
- wakapi-mysql:/var/lib/mysql
|
||||
|
||||
mariadb:
|
||||
image: mariadb:10
|
||||
environment:
|
||||
MYSQL_TCP_PORT: 53306
|
||||
MARIADB_USER: "wakapi"
|
||||
MARIADB_PASSWORD: "wakapi"
|
||||
MARIADB_DATABASE: "wakapi"
|
||||
MARIADB_ROOT_PASSWORD: example
|
||||
network_mode: host
|
||||
volumes:
|
||||
- wakapi-mariadb:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
wakapi-postgres: {}
|
||||
wakapi-mysql: {}
|
||||
wakapi-mariadb: {}
|
@ -1,42 +1,136 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Compiling."
|
||||
CGO_ENABLED=0 go build
|
||||
|
||||
if ! command -v newman &> /dev/null
|
||||
then
|
||||
echo "Newman could not be found. Run 'npm install -g newman' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Creating database and schema ..."
|
||||
sqlite3 wakapi_testing.db < schema.sql
|
||||
|
||||
echo "Importing seed data ..."
|
||||
sqlite3 wakapi_testing.db < data.sql
|
||||
|
||||
echo "Running Wakapi testing instance in background ..."
|
||||
../wakapi -config config.testing.yml &
|
||||
pid=$!
|
||||
|
||||
echo "Waiting for Wakapi to come up ..."
|
||||
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
|
||||
printf '.'
|
||||
sleep 1
|
||||
for i in "$@"; do
|
||||
case $i in
|
||||
--migration)
|
||||
MIGRATION=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
script_path=$(realpath "${BASH_SOURCE[0]}")
|
||||
script_dir=$(dirname "$script_path")
|
||||
|
||||
echo "Running test collection ..."
|
||||
newman run "wakapi_api_tests.postman_collection.json"
|
||||
exit_code=$?
|
||||
echo "Compiling."
|
||||
(cd "$script_dir/.." || exit 1; CGO_ENABLED=0 go build)
|
||||
|
||||
cd "$script_dir" || exit 1
|
||||
|
||||
# Download previous release (when upgrade testing)
|
||||
initial_run_exe="../wakapi"
|
||||
if [[ $MIGRATION -eq 1 ]]; then
|
||||
if [ ! -f wakapi_linux_amd64.zip ]; then
|
||||
echo "Downloading latest release"
|
||||
curl https://github.com/muety/wakapi/releases/latest/download/wakapi_linux_amd64.zip -O -L
|
||||
fi
|
||||
unzip -o wakapi_linux_amd64.zip
|
||||
initial_run_exe="./wakapi"
|
||||
echo "Running tests with release version"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$pid" ] && ps -p "$pid" > /dev/null; then
|
||||
kill -TERM "$pid"
|
||||
fi
|
||||
if [ "${docker_down-0}" -eq 1 ]; then
|
||||
docker compose -f "$script_dir/docker-compose.yml" down
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Initialise test data
|
||||
case $1 in
|
||||
postgres|mysql|mariadb)
|
||||
docker compose -f "$script_dir/docker-compose.yml" down
|
||||
docker volume rm "testing_wakapi-$1"
|
||||
|
||||
docker_down=1
|
||||
docker compose -f "$script_dir/docker-compose.yml" up --wait -d "$1"
|
||||
|
||||
config="config.$1.yml"
|
||||
if [ "$1" == "mariadb" ]; then
|
||||
config="config.mysql.yml"
|
||||
fi
|
||||
|
||||
db_port=0
|
||||
if [ "$1" == "postgres" ]; then
|
||||
db_port=55432
|
||||
else
|
||||
db_port=53306
|
||||
fi
|
||||
|
||||
for _ in $(seq 0 30); do
|
||||
if netstat -tulpn 2>/dev/null | grep "LISTEN" | tr -s ' ' | cut -d' ' -f4 | grep -E ":$db_port$" > /dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
;;
|
||||
|
||||
sqlite|*)
|
||||
rm -f wakapi_testing.db
|
||||
|
||||
echo "Creating database and schema ..."
|
||||
sqlite3 wakapi_testing.db < schema.sql
|
||||
|
||||
echo "Importing seed data ..."
|
||||
sqlite3 wakapi_testing.db < data.sql
|
||||
|
||||
config="config.sqlite.yml"
|
||||
;;
|
||||
esac
|
||||
|
||||
wait_for_wakapi () {
|
||||
counter=0
|
||||
echo "Waiting for Wakapi to come up ..."
|
||||
until curl --output /dev/null --silent --get --fail http://localhost:3000/api/health; do
|
||||
if [ "$counter" -ge 5 ]; then
|
||||
echo "Waited for 5s, but Wakapi failed to come up ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '.'
|
||||
sleep 1
|
||||
counter=$((counter+1))
|
||||
done
|
||||
sleep 1
|
||||
printf "\n"
|
||||
}
|
||||
|
||||
# Run tests
|
||||
echo "Running Wakapi testing instance in background ..."
|
||||
echo "Configuration file: $config"
|
||||
"$initial_run_exe" -config "$config" &
|
||||
pid=$!
|
||||
wait_for_wakapi
|
||||
|
||||
if [ "$1" == "sqlite" ] || [ -z "$1" ]; then
|
||||
echo "Running test collection ..."
|
||||
newman run "wakapi_api_tests.postman_collection.json"
|
||||
exit_code=$?
|
||||
else
|
||||
exit_code=0
|
||||
fi
|
||||
|
||||
echo "Shutting down Wakapi ..."
|
||||
kill -TERM $pid
|
||||
|
||||
echo "Deleting database ..."
|
||||
rm wakapi_testing.db
|
||||
# Run upgrade tests
|
||||
if [[ $MIGRATION -eq 1 ]]; then
|
||||
echo "Running migrations with build"
|
||||
../wakapi -config "$config" &
|
||||
pid=$!
|
||||
|
||||
wait_for_wakapi
|
||||
echo "Shutting down Wakapi ..."
|
||||
kill -TERM $pid
|
||||
fi
|
||||
|
||||
echo "Exiting with status $exit_code"
|
||||
exit $exit_code
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user