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

Compare commits

..

66 Commits
2.5.4 ... 2.6.1

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

TODO: The cookie still needs to be set on the client
with a `Set-Cookie` response header.
2022-11-05 19:30:42 +01:00
ba81c07345 Display persistent summary interval into the front-end time picker 2022-11-05 19:23:39 +01:00
83 changed files with 3389 additions and 1490 deletions

View File

@ -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: |
@ -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

5
.gitignore vendored
View File

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

View File

@ -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
@ -140,15 +140,19 @@ You can specify configuration options either via a config file (default: `config
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
| `app.leaderboard_generation_time` /<br>`WAKAPI_LEADERBOARD_GENERATION_TIME` | `0 0 6 * * *,0 0 18 * * *` | One or multiple times of day at which to re-calculate the leaderboard |
| `app.data_cleanup_time` /<br>`WAKAPI_DATA_CLEANUP_TIME` | `0 0 6 * * 0` | When to perform data cleanup operations (see `app.data_retention_months`) |
| `app.import_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.custom_languages` | - | Map from file endings to language names |
| `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `app.support_contact` /<br>`WAKAPI_SUPPORT_CONTACT` | `hostmaster@wakapi.dev` | E-Mail address to display as a support contact on the page |
| `app.data_retention_months` /<br>`WAKAPI_DATA_RETENTION_MONTHS` | `-1` | Maximum retention period in months for user data (heartbeats) (-1 for unlimited) |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` /<br> `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `server.listen_socket_mode` /<br> `WAKAPI_LISTEN_SOCKET_MODE` | `0666` | Permission mode to create UNIX socket with |
| `server.timeout_sec` /<br> `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
@ -161,6 +165,7 @@ You can specify configuration options either via a config file (default: `config
| `security.expose_metrics` /<br> `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` /<br> `WAKAPI_DB_HOST` | - | Database host |
| `db.port` /<br> `WAKAPI_DB_PORT` | - | Database port |
| `db.socket` /<br> `WAKAPI_DB_SOCKET` | - | Database UNIX socket (alternative to `host`) (for MySQL only) |
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
@ -303,12 +308,13 @@ However, if you want to expose your wakapi instance to the public anyway, you ne
### Unit tests
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
#### How to run
```bash
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./...
$ CGO_ENABLED=0 go test `go list ./... | grep -v 'github.com/muety/wakapi/scripts'` -json -coverprofile=coverage/coverage.out ./... -run ./...
```
### API tests

View File

@ -1,9 +1,12 @@
env: production
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
listen_socket: # leave blank to disable unix sockets
listen_socket_mode: 0666 # permission mode to create unix socket with
timeout_sec: 30 # request timeout
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
@ -15,7 +18,7 @@ app:
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)
data_cleanup_time: '0 0 6 * * 0' # time at which to run old data cleanup (if enabled through data_retention_months)
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
@ -36,6 +39,7 @@ app:
db:
host: # leave blank when using sqlite3
port: # leave blank when using sqlite3
socket: # alternative to db.host (leave blank when using sqlite3)
user: # leave blank when using sqlite3
password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
@ -43,7 +47,7 @@ db:
charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security:
password_salt: # change this
@ -59,6 +63,15 @@ sentry:
sample_rate: 0.75 # probability of tracing a request
sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments
subscriptions:
enabled: false
expiry_notifications: true
stripe_api_key:
stripe_secret_key:
stripe_endpoint_secret:
standard_price_id:
mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
@ -76,7 +89,4 @@ mail:
mailwhale:
url:
client_id:
client_secret:
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
client_secret:

View File

@ -4,11 +4,9 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/muety/wakapi/utils"
"github.com/robfig/cron/v3"
"io/ioutil"
"net/http"
"os"
"regexp"
"strconv"
"strings"
@ -18,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 (
@ -30,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"
@ -71,14 +72,16 @@ type appConfig struct {
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"`
DataCleanupTime string `yaml:"data_cleanup_time" default:"0 0 6 * * 0" env:"WAKAPI_DATA_CLEANUP_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
DataRetentionMonths int `yaml:"data_retention_months" default:"-1" env:"WAKAPI_DATA_RETENTION_MONTHS"`
DataCleanupDryRun bool `yaml:"data_cleanup_dry_run" default:"false" env:"WAKAPI_DATA_CLEANUP_DRY_RUN"` // for debugging only
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
SupportContact string `yaml:"support_contact" default:"hostmaster@wakapi.dev" env:"WAKAPI_SUPPORT_CONTACT"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
@ -92,10 +95,12 @@ type securityConfig struct {
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"`
SessionKey []byte `yaml:"-"`
}
type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"`
Socket string `env:"WAKAPI_DB_SOCKET"`
Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"`
@ -110,15 +115,26 @@ type dbConfig struct {
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
ListenSocketMode uint32 `yaml:"listen_socket_mode" default:"0666" env:"WAKAPI_LISTEN_SOCKET_MODE"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type subscriptionsConfig struct {
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
ExpiryNotifications bool `yaml:"expiry_notifications" default:"true" env:"WAKAPI_SUBSCRIPTIONS_EXPIRY_NOTIFICATIONS"`
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"`
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
StandardPrice string `yaml:"-"`
}
type sentryConfig struct {
@ -160,6 +176,7 @@ type Config struct {
Security securityConfig
Db dbConfig
Server serverConfig
Subscriptions subscriptionsConfig
Sentry sentryConfig
Mail mailConfig
}
@ -180,7 +197,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
SameSite: http.SameSiteLaxMode,
}
}
@ -192,45 +209,6 @@ 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 utils.CloneStringMap(c.CustomLanguages, false)
}
@ -376,13 +354,6 @@ func readColors() map[string]map[string]string {
return colors
}
func mustReadConfigLocation() string {
if _, err := os.Stat(*cFlag); err != nil {
logbuch.Fatal("failed to find config file at '%s'", *cFlag)
}
return *cFlag
}
func resolveDbDialect(dbType string) string {
if dbType == "cockroach" {
return "postgres"
@ -409,7 +380,7 @@ func Load(version string) *Config {
flag.Parse()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
if err := configor.New(&configor.Config{}).Load(config, *cFlag); err != nil {
logbuch.Fatal("failed to read config: %v", err)
}
@ -428,6 +399,7 @@ func Load(version string) *Config {
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
config.Security.SessionKey = securecookie.GenerateRandomKey(32)
if strings.HasSuffix(config.Server.BasePath, "/") {
config.Server.BasePath = config.Server.BasePath[:len(config.Server.BasePath)-1]
@ -447,11 +419,15 @@ func Load(version string) *Config {
if config.App.DataRetentionMonths <= 0 {
logbuch.Info("disabling data retention policy, keeping data forever")
} else {
logbuch.Info("data retention policy set to keep data for %d months at max", config.App.DataRetentionMonths)
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 {

View File

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

View File

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

35
config/db_opts.go Normal file
View File

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

View File

@ -15,6 +15,7 @@ const (
QueueDefault = "wakapi.default"
QueueProcessing = "wakapi.processing"
QueueReports = "wakapi.reports"
QueueMails = "wakapi.mail"
QueueImports = "wakapi.imports"
QueueHousekeeping = "wakapi.housekeeping"
)
@ -31,6 +32,7 @@ func init() {
InitQueue(QueueDefault, 1)
InitQueue(QueueProcessing, halfCPUs())
InitQueue(QueueReports, 1)
InitQueue(QueueMails, 1)
InitQueue(QueueImports, 1)
InitQueue(QueueHousekeeping, halfCPUs())
}

View File

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

14
config/session.go Normal file
View File

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

File diff suppressed because it is too large Load Diff

63
go.mod
View File

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

91
go.sum
View File

@ -21,12 +21,18 @@ 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.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/duke-git/lancet/v2 v2.1.13 h1:KOCCVrfh4pjuwl6td5MQ4OqvV73qFdoGxv20HWmyPaM=
github.com/duke-git/lancet/v2 v2.1.13/go.mod h1:hNcc06mV7qr+crH/0nP+rlC3TB0Q9g5OrVnO8/TGD4c=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
@ -34,27 +40,41 @@ github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBd
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
github.com/getsentry/sentry-go v0.17.0 h1:UustVWnOoDFHBS7IJUB2QK/nB5pap748ZEp0swnQJak=
github.com/getsentry/sentry-go v0.17.0/go.mod h1:B82dxtBvxG0KaPD8/hfSV+VcHD+Lg/xUS4JuQn1P4cM=
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
github.com/glebarez/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
github.com/glebarez/go-sqlite v1.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8=
github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak=
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
github.com/glebarez/sqlite v1.6.0 h1:ZpvDLv4zBi2cuuQPitRiVz/5Uh6sXa5d8eBu0xNTpAo=
github.com/glebarez/sqlite v1.6.0/go.mod h1:6D6zPU/HTrFlYmVDKqBJlmQvma90P6r7sRRdkUUZOYk=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU=
github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@ -75,9 +95,12 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@ -98,6 +121,7 @@ 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=
@ -109,22 +133,29 @@ github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.13.0 h1:XkIc7A+1BmZD19bB2NxrtjJweHxQ9agqvM+9URc68Cg=
github.com/jackc/pgtype v1.13.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -142,6 +173,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@ -168,6 +202,8 @@ 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-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@ -177,6 +213,8 @@ github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/narqo/go-badge v0.0.0-20221212191103-ba83bed45a1a h1:G6Kjw+HNpJUZY1bfBkd8XOZ7nuDWmXLaJukeiM2Xv7o=
github.com/narqo/go-badge v0.0.0-20221212191103-ba83bed45a1a/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
@ -191,6 +229,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qq
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
@ -206,6 +245,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -215,12 +256,22 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.3.0 h1:8ymGwZvMnpWMCRNomc9dVGcJ5j8L/ubwhQvpIpcmcOA=
github.com/stripe/stripe-go/v74 v74.3.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/swaggo/swag v1.8.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/swaggo/swag v1.8.9 h1:kHtaBe/Ob9AZzAANfcn5c6RyCke9gG9QpH0jky0I/sA=
github.com/swaggo/swag v1.8.9/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
@ -248,10 +299,18 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
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-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@ -262,6 +321,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
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=
@ -269,9 +329,13 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -284,6 +348,7 @@ 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=
@ -295,17 +360,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -319,6 +393,8 @@ golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -329,6 +405,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -340,15 +417,23 @@ 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.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
gorm.io/driver/mysql v1.4.5 h1:u1lytId4+o9dDaNcPCFzNv7h6wvmc92UjNk3z8enSBU=
gorm.io/driver/mysql v1.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
@ -371,6 +456,8 @@ modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
@ -379,12 +466,16 @@ modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/sqlite v1.20.2 h1:9AaVzJH1Yf0u9iOZRjjuvqxLoGqybqVFbAUC5rvi9u8=
modernc.org/sqlite v1.20.2/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=

59
main.go
View File

@ -2,7 +2,7 @@ package main
import (
"embed"
"github.com/muety/wakapi/static/docs"
"github.com/lpar/gzipped/v2"
"io/fs"
"log"
"net"
@ -11,11 +11,12 @@ import (
"strconv"
"time"
"github.com/muety/wakapi/static/docs"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/lpar/gzipped/v2"
"github.com/swaggo/http-swagger"
httpSwagger "github.com/swaggo/http-swagger"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@ -131,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()
@ -184,14 +183,14 @@ func main() {
reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService)
// Schedule background tasks
go aggregationService.Schedule()
go leaderboardService.Schedule()
go reportService.Schedule()
go housekeepingService.Schedule()
go miscService.ScheduleCountTotalTime()
go miscService.Schedule()
routes.Init()
@ -215,8 +214,9 @@ func main() {
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
summaryHandler := routes.NewSummaryHandler(summaryService, userService, keyValueService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService)
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService)
@ -254,6 +254,7 @@ func main() {
summaryHandler.RegisterRoutes(rootRouter)
leaderboardHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
subscriptionHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter)
// API route registrations
@ -278,12 +279,12 @@ func main() {
embeddedStatic, _ := fs.Sub(staticFiles, "static")
static := conf.ChooseFS("static", embeddedStatic)
assetsFileServer := gzipped.FileServer(fsutils.NewExistsHttpFS(
fsutils.NewExistsFS(static).WithCache(!config.IsDev()),
))
staticFileServer := http.FileServer(http.FS(
fsutils.NeuteredFileSystem{FS: static},
))
assetsStaticFs := fsutils.NewExistsHttpFS(fsutils.NewExistsFS(static).WithCache(!config.IsDev()))
assetsFileServer := http.FileServer(assetsStaticFs)
if !config.IsDev() {
assetsFileServer = gzipped.FileServer(assetsStaticFs)
}
staticFileServer := http.FileServer(http.FS(fsutils.NeuteredFileSystem{FS: static}))
router.PathPrefix("/contribute.json").Handler(staticFileServer)
router.PathPrefix("/assets").Handler(assetsFileServer)
@ -298,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,
@ -309,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,
@ -320,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())
}
@ -337,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())
@ -345,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())
@ -353,12 +354,15 @@ func listen(handler http.Handler) {
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
logbuch.Info("👉 Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := os.Chmod(config.Server.ListenSocket, os.FileMode(config.Server.ListenSocketMode)); err != nil {
logbuch.Warn("failed to set user permissions for unix socket, %v", err)
}
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
@ -366,7 +370,7 @@ func listen(handler http.Handler) {
}
} else {
if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
logbuch.Info("👉 Listening for HTTP on %s... ✅", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
@ -374,7 +378,7 @@ func listen(handler http.Handler) {
}()
}
if s6 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr)
logbuch.Info("👉 Listening for HTTP on %s... ✅", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
logbuch.Fatal(err.Error())
@ -382,12 +386,15 @@ func listen(handler http.Handler) {
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
logbuch.Info("👉 Listening for HTTP on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := os.Chmod(config.Server.ListenSocket, os.FileMode(config.Server.ListenSocketMode)); err != nil {
logbuch.Warn("failed to set user permissions for unix socket, %v", err)
}
if err := sSocket.Serve(unixListener); err != nil {
logbuch.Fatal(err.Error())
}

View File

@ -22,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 {
@ -46,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)
@ -73,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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18
models/init_test.go Normal file
View File

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

View File

@ -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
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -2,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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,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 {
@ -71,11 +72,8 @@ type ISummaryRepository interface {
}
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)

View File

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

View File

@ -64,12 +64,12 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summaries, err, status := h.loadUserSummaries(r)
summaries, err, status := h.loadUserSummaries(r, user)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -80,8 +80,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
helpers.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
func (h *SummariesHandler) loadUserSummaries(r *http.Request, user *models.User) ([]*models.Summary, error, int) {
params := r.URL.Query()
rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")

View File

@ -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)
}

View File

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

View File

@ -9,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)
}

View File

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

View File

@ -1,7 +1,6 @@
package routes
import (
"fmt"
"github.com/muety/wakapi/helpers"
"html/template"
"net/http"
@ -105,7 +104,7 @@ 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 {

View File

@ -69,7 +69,10 @@ func NewSettingsHandler(
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
@ -79,7 +82,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
@ -89,7 +92,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing form values"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("missing form values"))
return
}
@ -100,7 +103,7 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if actionFunc == nil {
logbuch.Warn("failed to dispatch action '%s'", action)
w.WriteHeader(http.StatusBadRequest)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("unknown action requests"))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError("unknown action requests"))
return
}
@ -113,15 +116,15 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
if errorMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithError(errorMsg))
return
}
if successMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w).WithSuccess(successMsg))
return
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r, w))
}
func (h *SettingsHandler) dispatchAction(action string) action {
@ -178,7 +181,11 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
}
if !payload.IsValid() {
return http.StatusBadRequest, "", "invalid parameters"
return http.StatusBadRequest, "", "invalid parameters - perhaps invalid e-mail address?"
}
if payload.Email == "" && user.HasActiveSubscription() {
return http.StatusBadRequest, "", "cannot unset email while subscription is active"
}
user.Email = payload.Email
@ -282,7 +289,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
var err error
user := middlewares.GetPrincipal(r)
defer h.userSrvc.FlushCache()
defer h.userSrvc.FlushUserCache(user.ID)
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
@ -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, "", ""
}
@ -669,7 +677,7 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
return nil
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r)
// mappings
@ -679,7 +687,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
@ -708,7 +716,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
labelMap, err := h.projectLabelSrvc.GetByUserGroupedInverted(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
@ -730,17 +738,33 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
return &view.SettingsViewModel{Messages: view.Messages{Error: criticalError}}
}
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
ApiKey: user.ApiKey,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
// subscriptions
var subscriptionPrice string
if h.config.Subscriptions.Enabled {
subscriptionPrice = h.config.Subscriptions.StandardPrice
}
// user first data
var firstData time.Time
firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID))
if firstDataKv.Value != "" {
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
}
vm := &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
ApiKey: user.ApiKey,
UserFirstData: firstData,
SubscriptionPrice: subscriptionPrice,
SupportContact: h.config.App.SupportContact,
DataRetentionMonths: h.config.App.DataRetentionMonths,
}
return routeutils.WithSessionMessages(vm, r, w)
}

347
routes/subscription.go Normal file
View File

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

View File

@ -1,38 +1,48 @@
package routes
import (
"fmt"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type SummaryHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
keyValueSrvc services.IKeyValueService
}
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService) *SummaryHandler {
func NewSummaryHandler(summaryService services.ISummaryService, userService services.IUserService, keyValueService services.IKeyValueService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(),
summarySrvc: summaryService,
userSrvc: userService,
keyValueSrvc: keyValueService,
config: conf.Get(),
}
}
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r1 := router.PathPrefix("/summary").Subrouter()
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
r1.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler)
r1.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r2 := router.PathPrefix("/summary").Subrouter()
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler)
r2.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithRedirectErrorMessage("unauthorized").
Handler)
r2.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
@ -44,8 +54,18 @@ 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, _ := helpers.ParseSummaryParams(r)
@ -53,34 +73,39 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.WriteHeader(status)
conf.Log().Request(r).Error("failed to load summary - %v", err)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError(err.Error()))
return
}
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r, w).WithError("unauthorized"))
return
}
// user first data
var firstData time.Time
firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID))
if firstDataKv.Value != "" {
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
}
vm := view.SummaryViewModel{
Summary: summary,
SummaryParams: summaryParams,
User: user,
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
EditorColors: su.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
LanguageColors: su.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
OSColors: su.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
UserFirstData: firstData,
}
templates[conf.SummaryTemplate].Execute(w, vm)
}
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
return &view.SummaryViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
func (h *SummaryHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.SummaryViewModel {
return su.WithSessionMessages(&view.SummaryViewModel{}, r, w)
}

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

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

View File

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

View File

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

View File

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

View File

@ -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'))

View File

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

55
scripts/email_checker.go Normal file
View File

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

58
scripts/send_mail.py Normal file
View File

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

View File

@ -35,9 +35,6 @@ func (s *HousekeepingService) Schedule() {
logbuch.Info("scheduling data cleanup")
// this is not exactly precise, because of summer / winter time, etc.
retentionDuration := time.Now().Sub(time.Now().AddDate(0, -s.config.App.DataRetentionMonths, 0))
_, err := s.queueDefault.DispatchCron(func() {
// fetch all users
users, err := s.userSrvc.GetAll()
@ -48,9 +45,14 @@ func (s *HousekeepingService) Schedule() {
// 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.ClearOldUserData(&user, retentionDuration); err != nil {
if err := s.CleanUserDataBefore(&user, user.MinDataAge()); err != nil {
config.Log().Error("failed to clear old user data for '%s'", user.ID)
}
})
@ -62,9 +64,12 @@ func (s *HousekeepingService) Schedule() {
}
}
func (s *HousekeepingService) ClearOldUserData(user *models.User, maxAge time.Duration) error {
before := time.Now().Add(-maxAge)
func (s *HousekeepingService) CleanUserDataBefore(user *models.User, before time.Time) error {
logbuch.Warn("cleaning up user data for '%s' older than %v", user.ID, before)
if s.config.App.DataCleanupDryRun {
logbuch.Info("skipping actual data deletion for '%v', because this is just a dry run", user.ID)
return nil
}
// clear old heartbeats
if err := s.heartbeatSrvc.DeleteByUserBefore(user, before); err != nil {

View File

@ -116,7 +116,11 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
}
}
logbuch.Info("scheduling wakatime import for user '%s'", user.ID)
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 {
@ -280,8 +284,8 @@ func mapHeartbeat(
// try to parse id as an actual user agent string (as returned by wakapi)
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
ua = &wakatime.UserAgentEntry{
Editor: opSys,
Os: editor,
Editor: editor,
Os: opSys,
}
} else {
ua = &wakatime.UserAgentEntry{

View File

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

View File

@ -8,6 +8,7 @@ import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"reflect"
"strconv"
@ -147,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)
}
@ -155,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 {
@ -259,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)

View File

@ -19,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 {
@ -122,6 +124,24 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
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)
}
func (m *MailService) getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := m.templates[m.fmtName(tplNamePasswordReset)].Execute(&rendered, data); err != nil {
@ -154,6 +174,14 @@ func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, erro
return &rendered, nil
}
func (m *MailService) getSubscriptionNotificationTemplate(data SubscriptionNotificationTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := m.templates[m.fmtName(tplNameSubscriptionNotification)].Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func (m *MailService) fmtName(name string) string {
return fmt.Sprintf("%s.tpl.html", name)
}

View File

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

View File

@ -1,12 +1,15 @@
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"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"strconv"
"strings"
"sync"
"time"
@ -14,36 +17,78 @@ import (
)
const (
countUsersEvery = 1 * time.Hour
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
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
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,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
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),
}
}
func (srv *MiscService) ScheduleCountTotalTime() {
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)
}
logbuch.Info("scheduling first data computing")
if _, err := srv.queueDefault.DispatchEvery(srv.ComputeOldestHeartbeats, computeOldestDataEvery); err != nil {
config.Log().Error("failed to schedule first data computing jobs, %v", err)
}
if srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
logbuch.Info("scheduling subscription notifications")
if _, err := srv.queueDefault.DispatchEvery(srv.NotifyExpiringSubscription, notifyExpiringSubscriptionsEvery); err != nil {
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
}
}
// run once initially for a fresh instance
if !srv.existsUsersTotalTime() {
if err := srv.queueDefault.Dispatch(srv.CountTotalTime); err != nil {
config.Log().Error("failed to dispatch user counting jobs, %v", err)
}
}
if !srv.existsUsersFirstData() {
if err := srv.queueDefault.Dispatch(srv.ComputeOldestHeartbeats); err != nil {
config.Log().Error("failed to dispatch first data computing jobs, %v", err)
}
}
if !srv.existsSubscriptionNotifications() && srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 {
if err := srv.queueDefault.Dispatch(srv.NotifyExpiringSubscription); err != nil {
config.Log().Error("failed to schedule subscription notification jobs, %v", err)
}
}
}
func (srv *MiscService) CountTotalTime() {
@ -56,6 +101,7 @@ func (srv *MiscService) CountTotalTime() {
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)
@ -95,6 +141,89 @@ func (srv *MiscService) CountTotalTime() {
}(&pendingJobs)
}
func (srv *MiscService) ComputeOldestHeartbeats() {
logbuch.Info("computing users' first data")
if err := srv.queueWorkers.Dispatch(func() {
if ok := firstDataLock.TryLock(); !ok {
config.Log().Warn("couldn't acquire lock for computing users' first data, job is still pending")
return
}
defer firstDataLock.Unlock()
results, err := srv.heartbeatService.GetFirstByUsers()
if err != nil {
config.Log().Error("failed to compute users' first data, %v", err)
return
}
for _, entry := range results {
if entry.Time.T().IsZero() {
continue
}
kvKey := fmt.Sprintf("%s_%s", config.KeyFirstHeartbeat, entry.User)
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: kvKey,
Value: entry.Time.T().Format(time.RFC822Z),
}); err != nil {
config.Log().Error("failed to save user's first heartbeat time: %v", err)
}
}
}); err != nil {
config.Log().Error("failed to enqueue computing first data for user, %v", err)
}
}
// NotifyExpiringSubscription sends a reminder e-mail to all users, notifying them if their subscription has expired or is about to, given these conditions:
// - Data cleanup is enabled on the server (non-zero retention time)
// - Subscriptions are enabled on the server (aka. users can do something about their old data getting cleaned up)
// - User has an e-mail address configured
// - User's subscription has expired or is about to expire soon
// - The user has gotten no such e-mail before recently
// Note: only one mail will be sent for either "expired" or "about to expire" state.
func (srv *MiscService) NotifyExpiringSubscription() {
if srv.config.App.DataRetentionMonths <= 0 || !srv.config.Subscriptions.Enabled {
return
}
logbuch.Info("notifying users about soon to expire subscriptions")
users, err := srv.userService.GetAll()
if err != nil {
config.Log().Error("failed to fetch users for subscription notifications, %v", err)
return
}
var subscriptionReminders map[string][]*models.KeyStringValue
if result, err := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent); err == nil {
subscriptionReminders = slice.GroupWith[*models.KeyStringValue, string](result, func(kv *models.KeyStringValue) string {
return strings.TrimPrefix(kv.Key, config.KeySubscriptionNotificationSent+"_")
})
} else {
config.Log().Error("failed to fetch key-values for subscription notifications, %v", err)
return
}
for _, u := range users {
if u.HasActiveSubscription() && u.Email == "" {
config.Log().Warn("invalid state: user '%s' has active subscription but no e-mail address set", u.ID)
}
// skip users without e-mail address
// skip users who already received a notification before
// skip users who either never had a subscription before or intentionally deleted it
if _, ok := subscriptionReminders[u.ID]; ok || u.Email == "" || u.SubscribedUntil == nil {
continue
}
expired, expiredSince := u.SubscriptionExpiredSince()
if expired || (expiredSince < 0 && expiredSince*-1 <= notifyBeforeSubscriptionExpiry) {
srv.sendSubscriptionNotificationScheduled(u, expired)
}
}
}
func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false)
if err != nil {
@ -103,3 +232,38 @@ func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
}
return result.TotalTime()
}
func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User, hasExpired bool) {
u := *user
srv.queueMails.Dispatch(func() {
logbuch.Info("sending subscription expiry notification mail to %s (expired: %v)", u.ID, hasExpired)
defer time.Sleep(10 * time.Second)
if err := srv.mailService.SendSubscriptionNotification(&u, hasExpired); err != nil {
config.Log().Error("failed to send subscription notification mail to user '%s', %v", u.ID, err)
return
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: fmt.Sprintf("%s_%s", config.KeySubscriptionNotificationSent, u.ID),
Value: time.Now().Format(time.RFC822Z),
}); err != nil {
config.Log().Error("failed to update subscription notification status key-value for user %s, %v", u.ID, err)
}
})
}
func (srv *MiscService) existsUsersTotalTime() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime)
return len(results) > 0
}
func (srv *MiscService) existsUsersFirstData() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat)
return len(results) > 0
}
func (srv *MiscService) existsSubscriptionNotifications() bool {
results, _ := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent)
return len(results) > 0
}

View File

@ -3,6 +3,7 @@ package services
import (
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
@ -12,7 +13,7 @@ type IAggregationService interface {
}
type IMiscService interface {
ScheduleCountTotalTime()
Schedule()
CountTotalTime()
}
@ -52,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
}
@ -78,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 {
@ -101,7 +104,7 @@ type IReportService interface {
type IHousekeepingService interface {
Schedule()
ClearOldUserData(*models.User, time.Duration) error
CleanUserDataBefore(*models.User, time.Time) error
}
type ILeaderboardService interface {
@ -109,9 +112,9 @@ type ILeaderboardService interface {
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)
@ -122,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)
@ -137,4 +141,5 @@ type IUserService interface {
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
GenerateResetToken(*models.User) (*models.User, error)
FlushCache()
FlushUserCache(string)
}

View File

@ -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.

View File

@ -5,6 +5,9 @@ module.exports = {
extend: {
colors: {
green: colors.emerald,
},
width: {
'16': '4rem',
}
}
},

38
testing/config.mysql.yml Normal file
View 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

View 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

View 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: {}

View File

@ -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

View File

@ -27,9 +27,6 @@
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"method": "POST",
"header": [],

View File

@ -1,16 +0,0 @@
package utils
import (
"github.com/muety/wakapi/models"
"strings"
)
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
}

16
utils/dns.go Normal file
View File

@ -0,0 +1,16 @@
package utils
import (
"net"
"strings"
)
// CheckEmailMX takes an e-mail address and verifies that an MX DNS record exists for its domain
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
}

View File

@ -2,7 +2,6 @@ package utils
import (
"errors"
"github.com/muety/wakapi/models"
"net/http"
"regexp"
"strconv"
@ -22,6 +21,25 @@ func init() {
cacheMaxAgeRe = regexp.MustCompile(cacheMaxAgePattern)
}
type PageParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
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
}
func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
cacheControl := r.Header.Get("cache-control")
if strings.Contains(cacheControl, "no-cache") {
@ -35,8 +53,8 @@ func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
return false
}
func ParsePageParams(r *http.Request) *models.PageParams {
pageParams := &models.PageParams{}
func ParsePageParams(r *http.Request) *PageParams {
pageParams := &PageParams{}
page := r.URL.Query().Get("page")
pageSize := r.URL.Query().Get("page_size")
if p, err := strconv.Atoi(page); err == nil {
@ -48,7 +66,7 @@ func ParsePageParams(r *http.Request) *models.PageParams {
return pageParams
}
func ParsePageParamsWithDefault(r *http.Request, page, size int) *models.PageParams {
func ParsePageParamsWithDefault(r *http.Request, page, size int) *PageParams {
pageParams := ParsePageParams(r)
if pageParams.Page == 0 {
pageParams.Page = page

View File

@ -1,13 +1,13 @@
{{ if .Error }}
<div class="flex justify-center w-full">
<div class="p-4 font-semibold text-white text-sm bg-red-500 rounded mt-16 shadow grow max-w-lg">
Error: {{ .Error | capitalize }}
Error: {{ .Messages.Error | capitalize }}
</div>
</div>
{{ else if .Success }}
<div class="flex justify-center w-full">
<div class="p-4 font-semibold text-white text-sm bg-green-500 rounded mt-16 shadow grow max-w-lg">
{{ .Success | capitalize }}
{{ .Messages.Success | capitalize }}
</div>
</div>
{{ end }}

View File

@ -56,8 +56,8 @@
<ol>
{{ range $i, $item := .Items }}
<li class="px-4 py-2 my-2 rounded-md border-2 leaderboard-{{ ($.ColorModifier $item $.User) }} flex justify-between">
<div class="w-1/12 mr-1"><strong># {{ $item.Rank }}</strong></div>
<div class="flex w-3/12 mx-1 justify-start items-center space-x-4 align-middle">
<div class="w-12"><strong># {{ $item.Rank }}</strong></div>
<div class="flex flex-grow w-16 mx-1 justify-start items-center space-x-4 align-middle">
{{ if avatarUrlTemplate }}
<img src="{{ $item.User.AvatarURL avatarUrlTemplate }}" width="24px" class="rounded-full border-green-700" alt="User Profile Avatar"/>
{{ else }}
@ -65,7 +65,7 @@
{{ end }}
<strong class="text-ellipsis truncate">@{{ $item.UserID }}</strong>
</div>
<div class="w-5/12 mx-1 truncate leading-6 align-middle">
<div class="flex-1 mx-1 hidden sm:inline-block truncate leading-6 align-middle">
{{ range $i, $lang := (index $.UserLanguages $item.UserID) }}
{{ if $.LangIcon $lang }}
<span class="align-middle leading-none"><span class="iconify inline text-white text-base" data-icon="{{ ($.LangIcon $lang) | urlSafe }}"></span></span>
@ -73,7 +73,7 @@
<span class="text-sm leading-6">{{ $lang }}{{ if lt $i (add (len (index $.UserLanguages $item.UserID)) -1) }},&nbsp;{{ end }}</span>
{{ end }}
</div>
<div class="w-3/12 ml-1 text-right"><span>{{ $item.Total | duration }}</span></div>
<div class="flex-1 ml-1 text-right"><span>{{ $item.Total | duration }}</span></div>
</li>
{{ end }}
</ol>

View File

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
{{ template "head.tpl.html" . }}
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
{{ template "theader.tpl.html" . }}
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
{{ if .HasExpired }}
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Subscription expired</p>
{{ else }}
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Subscription about to expire</p>
{{ end }}
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
{{ if .HasExpired }}
Your Wakapi subscription has expired.
{{ else }}
Your Wakapi subscription will expire soon.
{{ end }}
All coding activity older than {{ .DataRetentionMonths }} months will be deleted soon. Please refer to <a href="https://github.com/muety/wakapi/discussions/447" target="_blank">this article</a> for further details on subscriptions.
</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .PublicUrl }}/settings#subscription" target="_blank" style="display: inline-block; color: #ffffff; background-color: #2F855A; border: solid 1px #2F855A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #2F855A;">Go to settings</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ template "tfooter.tpl.html" . }}
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -49,6 +49,11 @@
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('integrations') }">
<a href="settings#integrations" @click="updateTab">Integrations</a>
</li>
{{ if .SubscriptionsEnabled }}
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('subscription'), 'hover:text-gray-500': !isActive('subscription') }">
<a href="settings#subscription" @click="updateTab">Subscription</a>
</li>
{{ end }}
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('danger_zone'), 'hover:text-gray-500': !isActive('danger_zone') }">
<a href="settings#danger_zone" @click="updateTab">Danger Zone</a>
</li>
@ -74,13 +79,16 @@
<div class="flex mb-8">
<div class="w-1/2 mr-4 inline-block">
<label class="font-semibold text-gray-300" for="email">E-Mail Address</label>
<span class="block text-sm text-gray-600">Optional in general, but required for weekly reports and for resetting your password.</span>
<span class="block text-sm text-gray-600">
Optional in general, but required for weekly reports and for resetting your password.
</span>
</div>
<div class="w-1/2 ml-4">
<input class="input-default"
<input class="input-default {{ if .User.HasActiveSubscription }}cursor-not-allowed{{ end }}"
type="email" id="email"
name="email" placeholder="Enter your e-mail address"
value="{{ .User.Email }}">
value="{{ .User.Email }}"
>
</div>
</div>
@ -550,7 +558,7 @@
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
<div class="w-full md:w-1/2 mb-4 md:mb-0 inline-block">
<label class="font-semibold text-gray-300" for="select-timezone">WakaTime</label>
<label class="font-semibold text-gray-300 text-lg" for="select-timezone">WakaTime</label>
<span class="block text-sm text-gray-600">
You can connect Wakapi with the official WakaTime (or another Wakapi instance, when optionally specifying a custom API URL) in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. To get started, get <a class="link" href="https://wakatime.com/developers#authentication" rel="noopener noreferrer" target="_blank">your API key</a> and paste it here.<br><br>
To forward data to another Wakapi instance, use <span class="text-xs font-mono">https://&lt;your-server&gt;/api/compat/wakatime/v1</span> as a URL.<br><br>
@ -589,7 +597,7 @@
<div class="w-full lg:w-3/4">
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
<div class="w-full md:w-1/2 mb-4 md:mb-0 inline-block">
<label class="font-semibold text-gray-300" for="select-timezone">Badges</label>
<label class="font-semibold text-gray-300 text-lg" for="select-timezone">Badges</label>
<span class="block text-sm text-gray-600">
This integration with allows to generate badges for README pages or forums. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See <a class="link" href="settings#permissions">Permissions</a>. Adapt the URL's <i>label</i> and <i>color</i> parameters for customized badges.<br><br>
In addition, there is an endpoint compatible with <a class="link" href="https://shields.io" target="_blank" rel="noreferrer noopener">Shields.IO</a> to allow for even more customization (e.g. different <a class="link" href="https://shields.io/#styles" target="_blank" rel="noreferrer noopener">styles</a>). Only available on public instances, not on localhost.
@ -602,14 +610,12 @@
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="api/badge/{{ .User.ID }}/interval:today?label=today"
alt="Badge"
/>
alt="Badge"/>
</div>
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/interval:today?label=today"
readonly
>
readonly>
</div>
<div class="flex space-x-4 mt-4">
@ -622,22 +628,19 @@
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/{{ .User.ID }}/interval:30_days?label=last 30d"
readonly
>
readonly>
</div>
<div class="flex space-x-4 mt-4">
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
alt="Shields.io badge"
/>
alt="Shields.io badge"/>
</div>
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
readonly
>
readonly>
</div>
{{ end }}
</div>
@ -651,7 +654,7 @@
<div class="w-full lg:w-3/4">
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
<div class="w-full md:w-1/2 mb-4 md:mb-0 inline-block">
<label class="font-semibold text-gray-300" for="select-timezone">GitHub Readme Stats</label>
<label class="font-semibold text-gray-300 text-lg" for="select-timezone">GitHub Readme Stats</label>
<span class="block text-sm text-gray-600">
Wakapi intregrates with <a class="link" href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats" target="_blank" rel="noreferrer noopener">GitHub Readme Stats</a> to generate fancy cards for you. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See <a class="link" href="settings#permissions">Permissions</a>.<br><br>
Only available on public instances, not on localhost.
@ -674,6 +677,68 @@
</div>
</div>
{{ if .SubscriptionsEnabled }}
<div v-cloak id="subscription" class="tab flex flex-col space-y-4" v-if="isActive('subscription')">
<div class="w-full lg:w-1/2">
<span class="font-semibold text-gray-300 text-lg">Subscription</span>
<span class="block text-sm text-gray-600">
By default, this Wakapi instance will only store historical coding activity for {{ .DataRetentionMonths }} months.
However, if you want to support the project, you can opt for a paid subscription for {{ .SubscriptionPrice }} / month to get unlimited history with no restrictions.
You can cancel your subscription at any times!<br>
Read more about the idea of adding paid subscriptions to Wakapi <a class="link" href="https://github.com/muety/wakapi/discussions/447" target="_blank" rel="noopener noreferrer">here</a>.
If you are having any issues related to subscriptions, please contact us at <a class="link" href="mailto:{{ .SupportContact }}" target="_blank" rel="noopener noreferrer">{{ .SupportContact }}</a>.<br>
</span>
<br>
{{ if not .User.HasActiveSubscription }}
<span class="font-semibold text-gray-300">How it works</span>
<span class="block text-sm text-gray-600">
Without a subscription, your coding activity older than {{ .DataRetentionMonths }} months will get deleted by a routine that is run every day.
If you do have an active subscription at the time of checking, your data is kept.<br>
In other words, for every point in time <span class="text-xs font-mono">X</span>, where you do not currently have an active subscription, all data older than <span class="text-xs font-mono">X - {{ .DataRetentionMonths }}</span> months gets dropped.
</span>
<br>
<span class="font-semibold text-gray-300">Please note</span>
<span class="block text-sm text-gray-600">If you just purchased a subscription, it might take a moment until it's active. Try refresh this page in a minute. Otherwise, please contact us!</span>
<span class="block text-sm text-gray-600">By purchasing a subscription, you agree that your e-mail address, plus all information optionally passed as billing details, will be processed by Stripe, according to their <a href="https://stripe.com/privacy" class="link" target="_blank" rel="noopener noreferrer">privacy policy.</a></span>
<br>
{{ end }}
{{ if not .UserFirstData.IsZero }}
<span class="block text-sm text-gray-600">
Your currently oldest data point is from <span class="text-gray-300 font-semibold">{{ .UserFirstData | datetime }}</span>.
</span>
<br>
{{ end }}
<span class="font-semibold text-gray-300">Subscription status:</span>
<span class="text-gray-600 ml-1 text-sm">
{{ if .User.HasActiveSubscription }}
<span class="font-semibold text-green-500 text-base">Active</span> (until {{ .User.SubscribedUntil.T | date }})
{{ else }}
<span class="font-semibold text-red-500 text-base">Inactive</span>
{{ end }}
</span>
{{ if not .User.HasActiveSubscription }}
<form action="subscription/checkout" method="post" class="mt-8 mb-8" id="form-subscription-checkout">
{{ if ne .User.Email "" }}
<button type="submit" class="btn-primary mt-4">Subscribe ({{ .SubscriptionPrice }} / mo)</button>
{{ else }}
<button type="submit" class="btn-disabled cursor-pointer mt-4" disabled title="">Subscribe ({{ .SubscriptionPrice }} / mo)</button><br>
<span class="text-xs text-gray-600">You have to provide an e-mail address to purchase a subscription.</span>
{{ end }}
</form>
{{ else }}
<form action="subscription/portal" method="post" class="mt-8 mb-8" id="form-subscription-portal">
<button type="submit" class="btn-danger">Cancel subscription</button>
</form>
{{ end }}
</div>
</div>
{{ end }}
<div v-cloak id="danger_zone" class="tab flex flex-col space-y-4" v-if="isActive('danger_zone')">
<div class="w-full lg:w-3/4">
<form action="" method="post" class="flex mb-8" id="form-regenerate-summaries">

View File

@ -17,8 +17,17 @@
{{ if .User.HasData }}
<div id="summary-page" class="grow" v-scope>
<div class="flex justify-end mt-12 relative">
<div v-scope="TimePicker({
<div class="flex justify-end md:space-x-8 mt-12 flex-wrap md:flex-nowrap relative items-center">
{{ if $.UserDataExpiring }}
<div class="flex-grow justify-start">
<div class="flex-grow p-4 text-sm border-2 border-orange-500 rounded shadow text-gray-300 align-middle mb-4 md:mb-0">
<span class="iconify inline mr-1" data-icon="emojione-v1:warning"></span>
Some of your data is older than this instance's data retention period. This will cause old data to be deleted, unless you opt for a subscription. Go to <a class="font-semibold text-green-700" href="settings#subscription">Settings → Subscription</a> for more details.
</div>
</div>
{{ end }}
<div class="flex-shrink-0" v-scope="TimePicker({
fromDate: '{{ .From | simpledate }}',
toDate: '{{ .To | ceildate | simpledate }}',
timeSelection: '{{ .From | datetime }} - {{ .To | ceildate | datetime }}'