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

Compare commits

...

61 Commits
2.4.0 ... 2.5.4

Author SHA1 Message Date
0f3b41c2dd fix(ci): adapt docker and gha build to use go 1.19 2022-12-03 00:32:30 +01:00
5ae7527b7b feat: implement data retention mechanism 2022-12-01 20:26:03 +01:00
2db065d47a Merge branch 'muety/427-job-processing' 2022-12-01 15:55:40 +01:00
0e5c5a56d2 chore: dependency upgrades 2022-12-01 15:31:19 +01:00
a4b89d3a69 fix: concurrency bugs with summary aggregation and user counting 2022-12-01 14:13:52 +01:00
aab9e98ebd fix: error handling for user counting
fix: make user counting thread-safe
2022-12-01 13:46:06 +01:00
d4945c982f fix: tests 2022-12-01 11:11:45 +01:00
964405f349 chore: refine report scheduling 2022-12-01 10:57:51 +01:00
21f6809f05 refactor: split utility functions into utils and helpers 2022-12-01 10:57:07 +01:00
c5fda02900 docs: update default cron expressions 2022-12-01 10:10:39 +01:00
10e432c185 Merge pull request #431 from rummik/patch-1
Increase avatar entropy
2022-11-29 23:07:28 +01:00
f121112d09 Increase avatar entropy 2022-11-26 16:26:03 -05:00
c13fc96a16 refactor: use job queue for data imports 2022-11-20 11:09:51 +01:00
61f13fce20 fix: prometheus metrics types 2022-11-20 10:59:06 +01:00
99e50b1062 chore: logging 2022-11-20 10:12:34 +01:00
4ce75c2acb chore: clean up dependencies 2022-11-20 10:11:23 +01:00
fcca881cfc refactor: move more background jobs to using job queue 2022-11-20 10:10:24 +01:00
e2ef54152d refactor(wip): introduce job processing system
refactor: adapt report generation scheduling
2022-11-19 22:21:51 +01:00
b1a12a5759 Merge pull request #430 from xiecang/bugfix/send_smtp_mail
fix: ignore error "starttls command has been already sent"
2022-11-17 08:31:24 +01:00
ae407fffca fix: ignore error "starttls command has been already sent" 2022-11-16 12:29:20 +08:00
94e0d06e5d fix: user agents and machine names in wakatime import 2022-11-15 23:53:30 +01:00
088bd17803 chore: update iconify 2022-11-13 20:20:41 +01:00
2976203ecc fix: missing icons 2022-11-13 20:11:53 +01:00
e75bd94531 fix: include cumulative total key in wakatime summary compat endpoint (resolve #426) 2022-11-13 19:52:53 +01:00
4cc8c21f67 fix: importing data from wakapi instance (resolve #428) 2022-11-13 19:27:44 +01:00
f182b804bb chore: add additional language icons
fix: support ipynb, cjs, tsx file endings
2022-11-11 16:13:41 +01:00
9586dbf781 fix: make intervals robust to daylight saving time shift 2022-10-31 23:24:54 +01:00
c8ea1a503f Merge pull request #424 from f0x52/postgres-dsn
Add postgres DSN config option
2022-10-31 19:22:03 +01:00
f0x
ebbc21f0b1 add postgres DSN config option 2022-10-31 18:07:16 +01:00
6e5bc38e5e fix: index migration for sqlite 2022-10-28 10:32:47 +02:00
9424c49760 fix: composite index on heartbeats table 2022-10-28 09:54:11 +02:00
efd6ba36e3 fix: errors during leaderboard generation 2022-10-20 08:33:12 +02:00
b1d7f87095 chore: add maximum default leaderboard length 2022-10-19 18:28:30 +02:00
ffbcfc7467 fix: cache key 2022-10-19 17:23:40 +02:00
41f6db8f34 feat(wip): leaderboard pagination (resolve #417) [ci-skip] 2022-10-16 19:38:43 +02:00
8a21be4306 fix: ignore rank column in migrations 2022-10-16 18:59:00 +02:00
31ca4a1e02 chore: logging 2022-10-16 17:42:32 +02:00
7cab2b0be7 chore: add clarification on relaying to other wakapi instance (resolve #420) [skip-ci] 2022-10-15 11:08:44 +02:00
777997c883 fix: swagger ui (resolve #421) 2022-10-14 12:00:56 +02:00
060a33263a chore: update dependencies 2022-10-09 10:16:27 +02:00
33d259592c chore: improve summary id fixing migration (see #416) 2022-10-09 10:16:18 +02:00
fbae5f8757 Tailwind 3 & Footer alignment (#419)
* ui: footer alignment
* chore: upgrade tailwind to v3
* fix: tailwind 3 class renames
* ui(fix): alias green to emerald for tailwind 3
2022-10-09 10:53:52 +11:00
bc99dc990a fix: case sensitivity with leaderboard languages (resolve #418) 2022-10-07 08:58:51 +02:00
1e9d3f9e80 Merge branch '182-leaderboards' 2022-10-06 20:43:55 +02:00
2ce720c20f fix: leaderboard responsiveness 2022-10-06 20:42:05 +02:00
ef87445e43 chore: display leaderboard update time 2022-10-06 15:30:32 +02:00
dec5849661 fix: replace mysql backticks 2022-10-06 15:23:59 +02:00
5609c0ada3 chore: empty leaderboard placeholder 2022-10-06 15:17:37 +02:00
1632cea949 fix: clear leaderboard after user opted out 2022-10-06 14:52:06 +02:00
23759d526a feat: settings option to opt in to leaderboards 2022-10-06 14:47:22 +02:00
82a565738f test: adapt mocks 2022-10-06 14:34:46 +02:00
1989a69926 feat: show users top languages
feat: language icons
2022-10-05 23:36:57 +02:00
7a07c9d4fc feat: top languages by user 2022-10-05 21:52:10 +02:00
a27fe04919 feat: leaderboard aggregation functionality
feat: leaderboard ui design
2022-10-03 23:53:47 +02:00
1d7ff4bc2a refactor: use query param for leaderboard controls 2022-10-03 20:38:19 +02:00
b3fa032bde feat(wip): leaderboard ui 2022-10-03 10:53:27 +02:00
94377a8dea fix: summary items id type (see #416) 2022-10-02 11:31:32 +02:00
dba4da8641 chore: caching for leaderboard 2022-10-02 10:31:01 +02:00
4a22a19cb0 chore: generate leaderboard when enabled in user settings 2022-10-02 10:13:39 +02:00
13a3d9f03a feat: leaderboard generation and querying 2022-10-02 00:01:39 +02:00
beffe71ea6 feat: add leaderboard data model 2022-09-30 17:19:32 +02:00
106 changed files with 3700 additions and 2238 deletions

View File

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

View File

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

View File

@ -1,4 +1,4 @@
FROM golang:1.18-alpine AS build-env
FROM golang:1.19-alpine AS build-env
WORKDIR /src
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \

View File

@ -137,8 +137,9 @@ You can specify configuration options either via a config file (default: `config
| YAML key / Env. variable | Default | Description |
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `0 15 2 * * *` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `0 0 18 * * 5` | Week day and time at which to send e-mail reports |
| `app.leaderboard_generation_time` /<br>`WAKAPI_LEADERBOARD_GENERATION_TIME` | `0 0 6 * * *,0 0 18 * * *` | One or multiple times of day at which to re-calculate the leaderboard |
| `app.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)) |

View File

@ -12,14 +12,20 @@ server:
public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail
app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
aggregation_time: '0 15 2 * * *' # time at which to run daily aggregation batch jobs
leaderboard_generation_time: '0 0 6 * * *,0 0 18 * * *' # times at which to re-calculate the leaderboard
report_time_weekly: '0 0 18 * * 5' # time at which to fan out weekly reports (extended cron)
data_cleanup_time: '0 0 6 * * 7' # time at which to run old data cleanup (if enabled through data_retention_months)
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
data_retention_months: -1 # maximum retention period on months for user data (heartbeats) (-1 for infinity)
custom_languages:
vue: Vue
jsx: JSX
tsx: TSX
cjs: JavaScript
ipynb: Python
svelte: Svelte
# url template for user avatar images (to be used with services like gravatar or dicebear)

View File

@ -4,10 +4,13 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/muety/wakapi/utils"
"github.com/robfig/cron/v3"
"io/ioutil"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -65,16 +68,19 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
var env string
type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
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"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
AggregationTime string `yaml:"aggregation_time" default:"0 15 2 * * *" env:"WAKAPI_AGGREGATION_TIME"`
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"0 0 6 * * *,0 0 18 * * *" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"0 0 18 * * 5" env:"WAKAPI_REPORT_TIME_WEEKLY"`
DataCleanupTime string `yaml:"data_cleanup_time" default:"0 0 6 * * 7" env:"WAKAPI_DATA_CLEANUP_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
DataRetentionMonths int `yaml:"data_retention_months" default:"-1" env:"WAKAPI_DATA_RETENTION_MONTHS"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
type securityConfig struct {
@ -97,6 +103,7 @@ type dbConfig struct {
Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
DSN string `yaml:"DSN" default:"" env:"WAKAPI_DB_DSN"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
@ -216,34 +223,105 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
}
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false)
return utils.CloneStringMap(c.CustomLanguages, false)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.Colors["languages"], true)
return utils.CloneStringMap(c.Colors["languages"], true)
}
func (c *appConfig) GetEditorColors() map[string]string {
return cloneStringMap(c.Colors["editors"], true)
return utils.CloneStringMap(c.Colors["editors"], true)
}
func (c *appConfig) GetOSColors() map[string]string {
return cloneStringMap(c.Colors["operating_systems"], true)
return utils.CloneStringMap(c.Colors["operating_systems"], true)
}
func (c *appConfig) GetWeeklyReportDay() time.Weekday {
s := strings.Split(c.ReportTimeWeekly, ",")[0]
return parseWeekday(s)
func (c *appConfig) GetAggregationTimeCron() string {
if strings.Contains(c.AggregationTime, ":") {
// old gocron format, e.g. "15:04"
timeParts := strings.Split(c.AggregationTime, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
return utils.CronPadToSecondly(c.AggregationTime)
}
func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
func (c *appConfig) GetWeeklyReportCron() string {
if strings.Contains(c.ReportTimeWeekly, ",") {
// old gocron format, e.g. "fri,18:00"
split := strings.Split(c.ReportTimeWeekly, ",")
weekday := utils.ParseWeekday(split[0])
timeParts := strings.Split(split[1], ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * %d", m, h, weekday)
}
return utils.CronPadToSecondly(c.ReportTimeWeekly)
}
func (c *appConfig) GetLeaderboardGenerationTimeCron() []string {
crons := []string{}
var parse func(string) string
if strings.Contains(c.LeaderboardGenerationTime, ":") {
// old gocron format, e.g. "15:04"
parse = func(s string) string {
timeParts := strings.Split(s, ":")
h, err := strconv.Atoi(timeParts[0])
if err != nil {
logbuch.Fatal(err.Error())
}
m, err := strconv.Atoi(timeParts[1])
if err != nil {
logbuch.Fatal(err.Error())
}
return fmt.Sprintf("0 %d %d * * *", m, h)
}
} else {
parse = func(s string) string {
return utils.CronPadToSecondly(s)
}
}
for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") {
crons = append(crons, parse(strings.TrimSpace(s)))
}
return crons
}
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
@ -318,35 +396,6 @@ func resolveDbDialect(dbType string) string {
return dbType
}
func findString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}
func parseWeekday(s string) time.Weekday {
switch strings.ToLower(s) {
case "mon", strings.ToLower(time.Monday.String()):
return time.Monday
case "tue", strings.ToLower(time.Tuesday.String()):
return time.Tuesday
case "wed", strings.ToLower(time.Wednesday.String()):
return time.Wednesday
case "thu", strings.ToLower(time.Thursday.String()):
return time.Thursday
case "fri", strings.ToLower(time.Friday.String()):
return time.Friday
case "sat", strings.ToLower(time.Saturday.String()):
return time.Saturday
case "sun", strings.ToLower(time.Sunday.String()):
return time.Sunday
}
return time.Monday
}
func Set(config *Config) {
cfg = config
}
@ -395,6 +444,12 @@ func Load(version string) *Config {
initSentry(config.Sentry, config.IsDev())
}
if config.App.DataRetentionMonths <= 0 {
logbuch.Info("disabling data retention policy, keeping data forever")
} else {
logbuch.Info("data retention policy set to keep data for %d months at max", config.App.DataRetentionMonths)
}
// some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
@ -406,19 +461,38 @@ func Load(version string) *Config {
logbuch.Warn("with sqlite, only a single connection is supported") // otherwise 'PRAGMA foreign_keys=ON' would somehow have to be set for every connection in the pool
config.Db.MaxConn = 1
}
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
if config.Mail.Provider != "" && utils.FindString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
}
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
logbuch.Fatal("invalid interval set for report_time_weekly")
}
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
logbuch.Fatal("invalid duration set for heartbeat_max_age")
}
cronParser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
if _, err := cronParser.Parse(config.App.GetWeeklyReportCron()); err != nil {
logbuch.Fatal("invalid cron expression for report_time_weekly")
}
if _, err := cronParser.Parse(config.App.GetAggregationTimeCron()); err != nil {
logbuch.Fatal("invalid cron expression for aggregation_time")
}
for _, c := range config.App.GetLeaderboardGenerationTimeCron() {
if _, err := cronParser.Parse(c); err != nil {
logbuch.Fatal("invalid cron expression for leaderboard_generation_time")
}
}
// deprecation notices
if strings.Contains(config.App.AggregationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'aggregation_time', please change it to a valid cron expression")
}
if strings.Contains(config.App.ReportTimeWeekly, ":") {
logbuch.Warn("you're using deprecated syntax for 'report_time_weekly', please change it to a valid cron expression")
}
if strings.Contains(config.App.LeaderboardGenerationTime, ":") {
logbuch.Warn("you're using deprecated syntax for 'leaderboard_generation_time', please change it to a semicolon-separated list if valid cron expressions")
}
Set(config)
return Get()
}

View File

@ -66,6 +66,10 @@ func mysqlConnectionString(config *dbConfig) string {
}
func postgresConnectionString(config *dbConfig) string {
if len(config.DSN) > 0 {
return config.DSN
}
sslmode := "disable"
if config.Ssl {
sslmode = "require"

83
config/jobqueue.go Normal file
View File

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

View File

@ -9,4 +9,5 @@ const (
ResetPasswordTemplate = "reset-password.tpl.html"
SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html"
LeaderboardTemplate = "leaderboard.tpl.html"
)

View File

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

File diff suppressed because it is too large Load Diff

44
go.mod
View File

@ -1,16 +1,15 @@
module github.com/muety/wakapi
go 1.18
go 1.19
require (
codeberg.org/Codeberg/avatars v1.0.0
github.com/duke-git/lancet/v2 v2.1.6
github.com/duke-git/lancet/v2 v2.1.10
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.2.0
github.com/getsentry/sentry-go v0.13.0
github.com/glebarez/sqlite v1.4.7
github.com/go-co-op/gocron v1.17.0
github.com/getsentry/sentry-go v0.15.0
github.com/glebarez/sqlite v1.5.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
@ -20,27 +19,29 @@ require (
github.com/leandro-lugaresi/hub v1.1.1
github.com/lpar/gzipped/v2 v2.1.0
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.8.0
github.com/swaggo/http-swagger v1.3.3
github.com/swaggo/swag v1.8.6
github.com/swaggo/swag v1.8.8
go.uber.org/atomic v1.10.0
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
gorm.io/driver/mysql v1.3.6
gorm.io/driver/postgres v1.3.10
gorm.io/driver/sqlite v1.3.6
gorm.io/gorm v1.23.10
golang.org/x/crypto v0.3.0
golang.org/x/sync v0.1.0
gorm.io/driver/mysql v1.4.4
gorm.io/driver/postgres v1.4.5
gorm.io/driver/sqlite v1.4.3
gorm.io/gorm v1.24.2
)
require (
github.com/BurntSushi/toml v1.2.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/glebarez/go-sqlite v1.18.2 // 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
@ -65,18 +66,17 @@ require (
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
golang.org/x/net v0.0.0-20220927171203-f486391704dc // indirect
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.12 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/net v0.2.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/tools v0.3.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.20.0 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.19.1 // indirect
modernc.org/sqlite v1.20.0 // indirect
)

207
go.sum
View File

@ -1,17 +1,15 @@
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69 h1:/XvI42KX57UTpeIOIt7IfM+pmEFTL8FGtiIUGcGDOIU=
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@ -21,14 +19,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/duke-git/lancet/v2 v2.0.4 h1:IvMurTpL0cGhQmGPtkCge2eCkuiu3USQtglZJnKXxEo=
github.com/duke-git/lancet/v2 v2.0.4/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/duke-git/lancet/v2 v2.1.10 h1:q6YKhbYg6KChBS+T41e/IhK+sTDPVk2wRhWLTevCeuY=
github.com/duke-git/lancet/v2 v2.1.10/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/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=
@ -36,21 +30,16 @@ github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVR
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o=
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
github.com/go-co-op/gocron v1.13.0 h1:BjkuNImPy5NuIPEifhWItFG7pYyr27cyjS6BN9w/D4c=
github.com/go-co-op/gocron v1.13.0/go.mod h1:GD5EIEly1YNW+LovFVx5dzbYVcIc8544K99D8UVRpGo=
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
github.com/glebarez/go-sqlite v1.19.5 h1:krEVjICcImFNi+X81GmEkSe/brhzLL3Csbkb/ihi8sI=
github.com/glebarez/go-sqlite v1.19.5/go.mod h1:IjVxx3ezfL9clKLLSzVgv2sGZe28yIa116YyLTIvp84=
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
github.com/go-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=
@ -58,14 +47,10 @@ github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUe
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
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/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
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/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.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
github.com/go-openapi/swag v0.21.1/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=
@ -76,7 +61,9 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -90,7 +77,7 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@ -101,8 +88,6 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.11.0 h1:HiHArx4yFbwl91X3qqIHtUFoiIfLNJXCQRsnzkiwwaQ=
github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
@ -113,7 +98,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
@ -121,8 +105,6 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvW
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns=
github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
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=
@ -131,22 +113,17 @@ github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01C
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.10.0 h1:ILnBWrRMSXGczYvmkYD6PsYyVFUNLTnIUJHHDLmqk38=
github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.12.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.15.0 h1:B7dTkXsdILD3MF987WGGCcg+tvLW6bZJdEcqVFeU//w=
github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw=
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/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.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
@ -158,12 +135,12 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d/go.mod h1:3FSWkzk9h42opyV0o357Fq6gsLF/A6MI/qOca9kKobY=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@ -177,8 +154,6 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lpar/gzipped/v2 v2.0.2 h1:y7FjyTH07f8dX0YQ5o0sg2DTbRnmS3oT1pUvxViQ//o=
github.com/lpar/gzipped/v2 v2.0.2/go.mod h1:qb7pLOGFgqz5w9xGGiiRFPxuGZ7GqWEuXUKXSbgonkQ=
github.com/lpar/gzipped/v2 v2.1.0 h1:87/ug239roEqXLVOnXZg6NjDfFvMwmkGTKnFWJPUA9U=
github.com/lpar/gzipped/v2 v2.1.0/go.mod h1:G3UlFoFYzjCx6NV4zDmD1BIWMNBaJuKoUvxrEWJuZ3Y=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@ -193,26 +168,23 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2FBwSwiWCUh6s0TkPKgOU6GyVBciNdlo=
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@ -231,7 +203,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
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=
@ -240,29 +211,23 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
github.com/swaggo/swag v1.8.8 h1:/GgJmrJ8/c0z4R4hoEPZ5UeEhVGdvsII4JbVDLbR7Xc=
github.com/swaggo/swag v1.8.8/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
@ -282,21 +247,17 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -305,16 +266,14 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -330,19 +289,23 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -353,21 +316,19 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
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/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=
@ -375,72 +336,60 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.3.3 h1:jXG9ANrwBc4+bMvBcSl8zCfPBaVoPyBEBshA8dA93X8=
gorm.io/driver/mysql v1.3.3/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U=
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/postgres v1.3.4 h1:evZ7plF+Bp+Lr1mO5NdPvd6M/N98XtwHixGB+y7fdEQ=
gorm.io/driver/postgres v1.3.4/go.mod h1:y0vEuInFKJtijuSGu9e5bs5hzzSzPK+LancpKpvbRBw=
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
gorm.io/driver/sqlite v1.3.1 h1:bwfE+zTEWklBYoEodIOIBwuWHpnx52Z9zJFW5F33WLk=
gorm.io/driver/sqlite v1.3.1/go.mod h1:wJx0hJspfycZ6myN38x1O/AqLtNS6c5o9TndewFbELg=
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ=
gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM=
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
modernc.org/libc v1.18.0 h1:EKpC8eyhOcxpstYjohs7vxni7BoQBUVWXsf5rAZzlgk=
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.3.0 h1:6ZIOLb5ronARPxEPxtZz1WbSRllgA09FCvNNyql5kZg=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.2 h1:S2uFiaNPd/vTAP/4EmyY8Qe2Quzu26A2L1e25xRNTio=
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
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/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

View File

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

4
helpers/helpers.go Normal file
View File

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

30
helpers/http.go Normal file
View File

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

View File

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

15
main.go
View File

@ -35,6 +35,8 @@ import (
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
_ "github.com/muety/wakapi/static/docs"
)
// Embed version.txt
@ -59,6 +61,7 @@ var (
languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository
leaderboardRepository *repositories.LeaderboardRepository
keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
@ -72,11 +75,13 @@ var (
projectLabelService services.IProjectLabelService
durationService services.IDurationService
summaryService services.ISummaryService
leaderboardService services.ILeaderboardService
aggregationService services.IAggregationService
mailService services.IMailService
keyValueService services.IKeyValueService
reportService services.IReportService
diagnosticsService services.IDiagnosticsService
housekeepingService services.IHousekeepingService
miscService services.IMiscService
)
@ -159,6 +164,7 @@ func main() {
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
leaderboardRepository = repositories.NewLeaderboardRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
@ -172,16 +178,20 @@ func main() {
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
leaderboardService = services.NewLeaderboardService(leaderboardRepository, summaryService, userService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
go leaderboardService.Schedule()
go reportService.Schedule()
go housekeepingService.Schedule()
go miscService.ScheduleCountTotalTime()
routes.Init()
@ -207,6 +217,7 @@ func main() {
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
@ -241,6 +252,7 @@ func main() {
loginHandler.RegisterRoutes(rootRouter)
imprintHandler.RegisterRoutes(rootRouter)
summaryHandler.RegisterRoutes(rootRouter)
leaderboardHandler.RegisterRoutes(rootRouter)
settingsHandler.RegisterRoutes(rootRouter)
relayHandler.RegisterRoutes(rootRouter)
@ -275,6 +287,7 @@ func main() {
router.PathPrefix("/contribute.json").Handler(staticFileServer)
router.PathPrefix("/assets").Handler(assetsFileServer)
router.Path("/swagger-ui").Handler(http.RedirectHandler("swagger-ui/", http.StatusMovedPermanently)) // https://github.com/swaggo/http-swagger/issues/44
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
// Listen HTTP

View File

@ -2,6 +2,7 @@ package middlewares
import (
"fmt"
"github.com/muety/wakapi/helpers"
"net/http"
"strings"
@ -121,7 +122,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
}
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
username, err := utils.ExtractCookieAuth(r, m.config)
username, err := helpers.ExtractCookieAuth(r, m.config)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,88 @@
package migrations
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"strings"
)
// fix for https://github.com/muety/wakapi/issues/416
func init() {
const name = "20221002-fix_summary_id_types"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if cfg.Db.Dialect != config.SQLDialectMysql {
return nil
}
if !db.Migrator().HasTable(&models.Summary{}) || !db.Migrator().HasTable(&models.SummaryItem{}) {
return nil
}
var currentType string
if err := db.
Table("information_schema.columns").
Select("data_type").
Where("table_name = ?", "summary_items").
Where("column_name = ?", "summary_id").
Limit(1).
Row().Scan(&currentType); err != nil {
return err
}
if strings.ToLower(currentType) != "int" {
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_editors") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_editors"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_languages") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_languages"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_machines") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_machines"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_projects") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_projects"); err != nil {
return err
}
}
// https://github.com/muety/wakapi/issues/416#issuecomment-1271674792
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summary_items_summary") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summary_items_summary"); err != nil {
return err
}
}
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_labels") {
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_labels"); err != nil {
return err
}
}
if err := db.Migrator().AlterColumn(&models.Summary{}, "id"); err != nil {
return err
}
if err := db.Migrator().AlterColumn(&models.SummaryItem{}, "summary_id"); err != nil {
return err
}
}
return nil
},
}
registerPreMigration(f)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,21 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetMany(s []string) ([]*models.User, error) {
args := m.Called(s)
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetManyMapped(s []string) (map[string]*models.User, error) {
args := m.Called()
return args.Get(0).(map[string]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAllByLeaderboard(b bool) ([]*models.User, error) {
//TODO implement me
panic("implement me")
}
func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1)

View File

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

View File

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

View File

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

View File

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

109
models/leaderboard.go Normal file
View File

@ -0,0 +1,109 @@
package models
import (
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/slice"
"strings"
"time"
)
type LeaderboardItem struct {
ID uint `json:"-" gorm:"primary_key; size:32"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
Key *string `json:"key" gorm:"size:255"` // pointer because nullable
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
// https://github.com/go-gorm/gorm/issues/5789
// https://github.com/go-gorm/gorm/issues/5284#issuecomment-1107775806
type LeaderboardItemRanked struct {
LeaderboardItem
Rank uint
}
func (l1 *LeaderboardItemRanked) Equals(l2 *LeaderboardItemRanked) bool {
return l1.ID == l2.ID
}
type Leaderboard []*LeaderboardItemRanked
func (l *Leaderboard) Add(item *LeaderboardItemRanked) {
if _, found := slice.Find[*LeaderboardItemRanked](*l, func(i int, item2 *LeaderboardItemRanked) bool {
return item.Equals(item2)
}); !found {
*l = append(*l, item)
}
}
func (l *Leaderboard) AddMany(items []*LeaderboardItemRanked) {
for _, item := range items {
l.Add(item)
}
}
func (l Leaderboard) UserIDs() []string {
return slice.Unique[string](slice.Map[*LeaderboardItemRanked, string](l, func(i int, item *LeaderboardItemRanked) string {
return item.UserID
}))
}
func (l Leaderboard) HasUser(userId string) bool {
return slice.Contain(l.UserIDs(), userId)
}
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
return slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
})
}
func (l Leaderboard) TopKeys(by uint8) []string {
type keyTotal struct {
Key string
Total time.Duration
}
totalsMapped := make(map[string]*keyTotal, len(l))
for _, item := range l {
if item.Key == nil || item.By == nil || *item.By != by {
continue
}
key := strings.ToLower(*item.Key)
if _, ok := totalsMapped[key]; !ok {
totalsMapped[key] = &keyTotal{Key: *item.Key, Total: 0}
}
totalsMapped[key].Total += item.Total
}
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
return *item
})
if err := slice.SortByField(totals, "Total", "desc"); err != nil {
return []string{} // TODO
}
return slice.Map[keyTotal, string](totals, func(i int, item keyTotal) string {
return item.Key
})
}
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
return Leaderboard(slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.UserID == userId
})).TopKeys(by)
}
func (l Leaderboard) LastUpdate() time.Time {
lastUpdate := time.Time{}
for _, item := range l {
if item.CreatedAt.T().After(lastUpdate) {
lastUpdate = item.CreatedAt.T()
}
}
return lastUpdate
}

View File

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

View File

@ -34,6 +34,11 @@ 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
@ -99,3 +104,17 @@ 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

@ -66,9 +66,10 @@ type CredentialsReset struct {
}
type UserDataUpdate struct {
Email string `schema:"email"`
Location string `schema:"location"`
ReportsWeekly bool `schema:"reports_weekly"`
Email string `schema:"email"`
Location string `schema:"location"`
ReportsWeekly bool `schema:"reports_weekly"`
PublicLeaderboard bool `schema:"public_leaderboard"`
}
type TimeByUser struct {

View File

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

View File

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

View File

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

108
repositories/leaderboard.go Normal file
View File

@ -0,0 +1,108 @@
package repositories
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type LeaderboardRepository struct {
db *gorm.DB
}
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
return &LeaderboardRepository{db: db}
}
func (r *LeaderboardRepository) InsertBatch(items []*models.LeaderboardItem) error {
if err := r.db.
Clauses(clause.OnConflict{DoNothing: true}).
Create(&items).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Where("user_id = ?", userId).
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) CountUsers() (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Distinct("user_id").
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
// TODO: distinct by (user, key) to filter out potential duplicates ?
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key)
subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq).Where("user_id = ?", userId)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *LeaderboardRepository) DeleteByUser(userId string) error {
if err := r.db.
Where("user_id = ?", userId).
Delete(models.LeaderboardItem{}).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *models.IntervalKey) error {
if err := r.db.
Where("user_id = ?", userId).
Where("\"interval\" in ?", *key).
Delete(models.LeaderboardItem{}).Error; err != nil {
return err
}
return nil
}
func (r *LeaderboardRepository) withPaging(q *gorm.DB, limit, skip int) *gorm.DB {
if limit > 0 {
q = q.Where("\"rank\" <= ?", skip+limit)
}
if skip > 0 {
q = q.Where("\"rank\" > ?", skip)
}
return q
}

View File

@ -31,6 +31,7 @@ type IHeartbeatRepository interface {
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
DeleteByUserBefore(*models.User, time.Time) error
}
type IDiagnosticsRepository interface {
@ -66,6 +67,7 @@ type ISummaryRepository interface {
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
DeleteByUserBefore(string, time.Time) error
}
type IUserRepository interface {
@ -75,7 +77,9 @@ type IUserRepository interface {
GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetAllByLeaderboard(bool) ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error)
GetByLastActiveAfter(time.Time) ([]*models.User, error)
Count() (int64, error)
@ -84,3 +88,13 @@ type IUserRepository interface {
UpdateField(*models.User, string, interface{}) (*models.User, error)
Delete(*models.User) error
}
type ILeaderboardRepository interface {
InsertBatch([]*models.LeaderboardItem) error
CountAllByUser(string) (int64, error)
CountUsers() (int64, error)
DeleteByUser(string) error
DeleteByUserAndInterval(string, *models.IntervalKey) error
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
}

View File

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

View File

@ -77,6 +77,17 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
return users, nil
}
func (r *UserRepository) GetMany(ids []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Where("id in ?", ids).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
var users []*models.User
if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil {
@ -85,6 +96,14 @@ func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, e
return users, nil
}
func (r *UserRepository) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
var users []*models.User
if err := r.db.Where(&models.User{PublicLeaderboard: leaderboardEnabled}).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User
if err := r.db.
@ -156,6 +175,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"reset_token": user.ResetToken,
"location": user.Location,
"reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
}
result := r.db.Model(user).Updates(updateMap)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package v1
import (
"errors"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/helpers"
"net/http"
"strings"
"time"
@ -76,7 +77,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
}
vm := v1.NewSummariesFrom(summaries)
utils.RespondJSON(w, r, http.StatusOK, vm)
helpers.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
@ -94,24 +95,24 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
var start, end time.Time
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
} else if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
// also accept start param to be a range param
start, end = parsedFrom, parsedTo
} else {
// eventually, consider start and end params a date
var err error
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
start, err = helpers.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
end, err = helpers.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
@ -133,7 +134,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals))
// filtering
filters := utils.ParseSummaryFilters(r)
filters := helpers.ParseSummaryFilters(r)
for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))

View File

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

View File

@ -3,6 +3,7 @@ package routes
import (
"encoding/json"
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
@ -66,7 +67,9 @@ func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
}
if kv, err := h.keyValueSrvc.GetString(conf.KeyNewsbox); err == nil && kv != nil && kv.Value != "" {
json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox)
if err := json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox); err != nil {
logbuch.Error("failed to decode newsbox message - %v", err)
}
}
return &view.HomeViewModel{

144
routes/leaderboard.go Normal file
View File

@ -0,0 +1,144 @@
package routes
import (
"fmt"
"github.com/duke-git/lancet/v2/slice"
"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"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
type LeaderboardHandler struct {
config *conf.Config
userService services.IUserService
leaderboardService services.ILeaderboardService
}
var allowedAggregations = map[string]uint8{
"language": models.SummaryLanguage,
}
func NewLeaderboardHandler(userService services.IUserService, leaderboardService services.ILeaderboardService) *LeaderboardHandler {
return &LeaderboardHandler{
config: conf.Get(),
userService: userService,
leaderboardService: leaderboardService,
}
}
func (h *LeaderboardHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/leaderboard").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userService).
WithRedirectTarget(defaultErrorRedirectTarget()).
WithOptionalFor([]string{"/"}).
Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}
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 {
logbuch.Error(err.Error())
}
}
func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardViewModel {
user := middlewares.GetPrincipal(r)
byParam := strings.ToLower(r.URL.Query().Get("by"))
keyParam := strings.ToLower(r.URL.Query().Get("key"))
pageParams := utils.ParsePageParamsWithDefault(r, 1, 100)
// note: pagination is not fully implemented, yet
// count function to get total item / total pages is missing
// and according ui (+ optionally search bar) is missing, too
var err error
var leaderboard models.Leaderboard
var userLanguages map[string][]string
var topKeys []string
if byParam == "" {
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}
}
// regardless of page, always show own rank
if user != nil && !leaderboard.HasUser(user.ID) {
// but only if leaderboard spans multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetByIntervalAndUser(models.IntervalPast7Days, user.ID, true); err == nil && len(l) > 0 {
leaderboard = append(leaderboard, l[0])
}
}
}
} else {
if by, ok := allowedAggregations[byParam]; ok {
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, pageParams, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
}
// regardless of page, always show own rank
if user != nil {
// but only if leaderboard could, in theory, span multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetAggregatedByIntervalAndUser(models.IntervalPast7Days, user.ID, &by, true); err == nil {
leaderboard.AddMany(l)
} else {
conf.Log().Request(r).Error("error while fetching own aggregated user leaderboard - %v", err)
}
}
}
userLeaderboards := slice.GroupWith[*models.LeaderboardItemRanked, string](leaderboard, func(item *models.LeaderboardItemRanked) string {
return item.UserID
})
userLanguages = map[string][]string{}
for u, items := range userLeaderboards {
userLanguages[u] = models.Leaderboard(items).TopKeysByUser(models.SummaryLanguage, u)
}
topKeys = leaderboard.TopKeys(by)
if len(topKeys) > 0 {
if keyParam == "" {
keyParam = topKeys[0]
}
keyParam = strings.ToLower(keyParam)
leaderboard = leaderboard.TopByKey(by, keyParam)
}
} else {
return &view.LeaderboardViewModel{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)}
}
}
var apiKey string
if user != nil {
apiKey = user.ApiKey
}
return &view.LeaderboardViewModel{
User: user,
By: byParam,
Key: keyParam,
Items: leaderboard,
UserLanguages: userLanguages,
TopKeys: topKeys,
ApiKey: apiKey,
PageParams: pageParams,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/muety/wakapi/helpers"
"html/template"
"net/http"
"strings"
@ -24,20 +25,22 @@ func Init() {
func DefaultTemplateFuncs() template.FuncMap {
return template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"datetime": utils.FormatDateTimeHuman,
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"date": helpers.FormatDateHuman,
"datetime": helpers.FormatDateTimeHuman,
"simpledate": helpers.FormatDate,
"simpledatetime": helpers.FormatDateTime,
"duration": helpers.FmtWakatimeDuration,
"floordate": datetime.BeginOfDay,
"ceildate": utils.CeilDate,
"title": strings.Title,
"join": strings.Join,
"add": utils.Add,
"add": add,
"capitalize": utils.Capitalize,
"lower": strings.ToLower,
"toRunes": utils.ToRunes,
"localTZOffset": utils.LocalTZOffset,
"entityTypes": models.SummaryTypes,
"strslice": utils.SubSlice[string],
"typeName": typeName,
"isDev": func() bool {
return config.Get().IsDev()
@ -54,6 +57,9 @@ func DefaultTemplateFuncs() template.FuncMap {
"htmlSafe": func(html string) template.HTML {
return template.HTML(html)
},
"urlSafe": func(s string) template.URL {
return template.URL(s)
},
"avatarUrlTemplate": func() string {
return config.Get().App.AvatarURLTemplate
},
@ -101,3 +107,7 @@ func loadTemplates() {
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}
func add(i, j int) int {
return i + j
}

View File

@ -146,6 +146,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionAddLanguageMapping
case "update_sharing":
return h.actionUpdateSharing
case "update_leaderboard":
return h.actionUpdateLeaderboard
case "toggle_wakatime":
return h.actionSetWakatimeApiKey
case "import_wakatime":
@ -182,6 +184,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
user.Email = payload.Email
user.Location = payload.Location
user.ReportsWeekly = payload.ReportsWeekly
user.PublicLeaderboard = payload.PublicLeaderboard
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError
@ -251,6 +254,26 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
return http.StatusOK, msg, ""
}
func (h *SettingsHandler) actionUpdateLeaderboard(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
var err error
user := middlewares.GetPrincipal(r)
defer h.userSrvc.FlushCache()
user.PublicLeaderboard, err = strconv.ParseBool(r.PostFormValue("enable_leaderboard"))
if err != nil {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal sever error"
}
return http.StatusOK, "settings updated", ""
}
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
@ -441,7 +464,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
// Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?"
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key or endpoint URL invalid?"
}
if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil {
@ -634,12 +657,12 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string, baseUrl string) boo
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
logbuch.Info("clearing summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
logbuch.Error("failed to clear summaries: %v", err)
conf.Log().Error("failed to clear summaries: %v", err)
return err
}
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
if err := h.aggregationSrvc.AggregateSummaries(datastructure.NewSet(user.ID)); err != nil {
conf.Log().Error("failed to regenerate summaries: %v", err)
return err
}

View File

@ -3,6 +3,7 @@ package routes
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
@ -47,7 +48,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode()
}
summaryParams, _ := utils.ParseSummaryParams(r)
summaryParams, _ := helpers.ParseSummaryParams(r)
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)

View File

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

View File

@ -1,14 +1,14 @@
package utils
import (
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
summaryParams, err := helpers.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
}

View File

@ -10,6 +10,7 @@
const fs = require('fs')
const path = require('path')
const { Collection } = require('@iconify/json-tools')
const { locate } = require("@iconify/json");
let icons = [
'fxemoji:key',
@ -53,7 +54,29 @@ let icons = [
'ion:rocket',
'heroicons-solid:server',
'eva:checkmark-circle-2-fill',
'fluent:key-24-filled'
'fluent:key-24-filled',
'mdi:language-c',
'mdi:language-cpp',
'mdi:language-go',
'mdi:language-haskell',
'mdi:language-html5',
'mdi:language-java',
'mdi:language-javascript',
'mdi:language-kotlin',
'mdi:language-lua',
'mdi:language-php',
'mdi:language-python',
'mdi:language-r',
'mdi:language-ruby',
'mdi:language-rust',
'mdi:language-swift',
'mdi:language-typescript',
'mdi:language-markdown',
'mdi:vuejs',
'mdi:react',
'mdi:code-json',
'mdi:bash',
'twemoji:frowning-face',
]
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
@ -85,7 +108,7 @@ icons.forEach(icon => {
let code = ''
Object.keys(filtered).forEach(prefix => {
let collection = new Collection()
if (!collection.loadIconifyCollection(prefix)) {
if (!collection.loadFromFile(locate(prefix))) {
console.error('Error loading collection', prefix)
return
}

View File

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

View File

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

81
services/housekeeping.go Normal file
View File

@ -0,0 +1,81 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
)
type HousekeepingService struct {
config *config.Config
userSrvc IUserService
heartbeatSrvc IHeartbeatService
summarySrvc ISummaryService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewHousekeepingService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService) *HousekeepingService {
return &HousekeepingService{
config: config.Get(),
userSrvc: userService,
heartbeatSrvc: heartbeatService,
summarySrvc: summaryService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueHousekeeping),
}
}
func (s *HousekeepingService) Schedule() {
if s.config.App.DataRetentionMonths <= 0 {
return
}
logbuch.Info("scheduling data cleanup")
// 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()
if err != nil {
config.Log().Error("failed to get users for data cleanup, %v", err)
return
}
// schedule jobs
for _, u := range users {
user := *u
s.queueWorkers.Dispatch(func() {
if err := s.ClearOldUserData(&user, retentionDuration); err != nil {
config.Log().Error("failed to clear old user data for '%s'", user.ID)
}
})
}
}, s.config.App.DataCleanupTime)
if err != nil {
config.Log().Error("failed to dispatch data cleanup jobs, %v", err)
}
}
func (s *HousekeepingService) ClearOldUserData(user *models.User, maxAge time.Duration) error {
before := time.Now().Add(-maxAge)
logbuch.Warn("cleaning up user data for '%s' older than %v", user.ID, before)
// clear old heartbeats
if err := s.heartbeatSrvc.DeleteByUserBefore(user, before); err != nil {
return err
}
// clear old summaries
logbuch.Info("clearing summaries for user '%s' older than %v", user.ID, before)
if err := s.summarySrvc.DeleteByUserBefore(user.ID, before); err != nil {
return err
}
return nil
}

View File

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

271
services/leaderboard.go Normal file
View File

@ -0,0 +1,271 @@
package services
import (
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"reflect"
"strconv"
"strings"
"time"
)
type LeaderboardService struct {
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.ILeaderboardRepository
summaryService ISummaryService
userService IUserService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
srv := &LeaderboardService{
config: config.Get(),
cache: cache.New(6*time.Hour, 6*time.Hour),
eventBus: config.EventBus(),
repository: leaderboardRepo,
summaryService: summaryService,
userService: userService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
}
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
// generate leaderboard for updated user, if leaderboard enabled and none present, yet
user := m.Fields[config.FieldPayload].(*models.User)
exists, err := srv.ExistsAnyByUser(user.ID)
if err != nil {
config.Log().Error("failed to check existing leaderboards upon user update - %v", err)
}
if user.PublicLeaderboard && !exists {
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
srv.ComputeLeaderboard([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
} else if !user.PublicLeaderboard && exists {
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
if err := srv.repository.DeleteByUser(user.ID); err != nil {
config.Log().Error("failed to clear leaderboard for user '%s' - %v", user.ID, err)
}
srv.cache.Flush()
}
}
}(&onUserUpdate)
return srv
}
func (srv *LeaderboardService) Schedule() {
logbuch.Info("scheduling leaderboard generation")
generate := func() {
users, err := srv.userService.GetAllByLeaderboard(true)
if err != nil {
config.Log().Error("failed to get users for leaderboard generation - %v", err)
return
}
srv.ComputeLeaderboard(users, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
}
for _, cronExp := range srv.config.App.GetLeaderboardGenerationTimeCron() {
if _, err := srv.queueDefault.DispatchCron(generate, cronExp); err != nil {
config.Log().Error("failed to schedule leaderboard generation (%s), %v", cronExp, err)
}
}
}
func (srv *LeaderboardService) ComputeLeaderboard(users []*models.User, interval *models.IntervalKey, by []uint8) error {
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
for _, user := range users {
if err := srv.repository.DeleteByUserAndInterval(user.ID, interval); err != nil {
config.Log().Error("failed to delete leaderboard items for user %s (interval %s) - %v", user.ID, (*interval)[0], err)
continue
}
item, err := srv.GenerateByUser(user, interval)
if err != nil {
config.Log().Error("failed to generate general leaderboard for user %s - %v", user.ID, err)
continue
}
if err := srv.repository.InsertBatch([]*models.LeaderboardItem{item}); err != nil {
config.Log().Error("failed to persist general leaderboard for user %s - %v", user.ID, err)
continue
}
for _, by := range by {
items, err := srv.GenerateAggregatedByUser(user, interval, by)
if err != nil {
config.Log().Error("failed to generate aggregated (by %s) leaderboard for user %s - %v", models.GetEntityColumn(by), user.ID, err)
continue
}
if len(items) == 0 {
continue
}
if err := srv.repository.InsertBatch(items); err != nil {
config.Log().Error("failed to persist aggregated (by %s) leaderboard for user %s - %v", models.GetEntityColumn(by), user.ID, err)
continue
}
}
}
srv.cache.Flush()
logbuch.Info("finished leaderboard generation")
return nil
}
func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
count, err := srv.repository.CountAllByUser(userId)
return count > 0, err
}
func (srv *LeaderboardService) CountUsers() (int64, error) {
// check cache
cacheKey := "count_total"
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(int64), nil
}
count, err := srv.repository.CountUsers()
if err != nil {
srv.cache.SetDefault(cacheKey, count)
}
return count, err
}
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, pageParams, resolveUsers)
}
func (srv *LeaderboardService) GetByIntervalAndUser(interval *models.IntervalKey, userId string, resolveUser bool) (models.Leaderboard, error) {
return srv.GetAggregatedByIntervalAndUser(interval, userId, nil, resolveUser)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, "", pageParams)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAllAggregatedByInterval(interval, by, pageParams.Limit(), pageParams.Offset())
if err != nil {
return nil, err
}
if resolveUsers {
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
if err != nil {
config.Log().Error("failed to resolve users for leaderboard item - %v", err)
} else {
for _, item := range items {
if u, ok := users[item.UserID]; ok {
item.User = u
}
}
}
}
srv.cache.SetDefault(cacheKey, items)
return items, nil
}
func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.IntervalKey, userId string, by *uint8, resolveUser bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, userId, nil)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAggregatedByUserAndInterval(userId, interval, by, 0, 0)
if err != nil {
return nil, err
}
if resolveUser {
u, err := srv.userService.GetUserById(userId)
if err != nil {
config.Log().Error("failed to resolve user for leaderboard item - %v", err)
} else {
for _, item := range items {
item.User = u
}
}
}
srv.cache.SetDefault(cacheKey, items)
return items, nil
}
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err
}
summary, err := srv.summaryService.Aliased(from, to, user, srv.summaryService.Retrieve, nil, false)
if err != nil {
return nil, err
}
return &models.LeaderboardItem{
User: user,
UserID: user.ID,
Interval: (*interval)[0],
Total: summary.TotalTime(),
}, nil
}
func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) {
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err
}
summary, err := srv.summaryService.Aliased(from, to, user, srv.summaryService.Retrieve, nil, false)
if err != nil {
return nil, err
}
summaryItems := *summary.ItemsByType(by)
items := make([]*models.LeaderboardItem, summaryItems.Len())
for i := 0; i < summaryItems.Len(); i++ {
key := summaryItems[i].Key
items[i] = &models.LeaderboardItem{
User: user,
UserID: user.ID,
Interval: (*interval)[0],
By: &by,
Total: summary.TotalTimeByKey(by, key),
Key: &key,
}
}
return items, nil
}
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *models.PageParams) string {
k := strings.Join(*interval, "__") + "__" + user
if by != nil && !reflect.ValueOf(by).IsNil() {
k += "__" + models.GetEntityColumn(*by)
}
if pageParams != nil {
k += "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
}
return k
}

View File

@ -3,6 +3,7 @@ package mail
import (
"bytes"
"fmt"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
@ -115,7 +116,7 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: fmt.Sprintf(subjectReport, utils.FormatDateHuman(time.Now().In(recipient.TZ()))),
Subject: fmt.Sprintf(subjectReport, helpers.FormatDateHuman(time.Now().In(recipient.TZ()))),
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)

View File

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

View File

@ -2,20 +2,30 @@ package services
import (
"github.com/emvi/logbuch"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
"runtime"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"strconv"
"sync"
"time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
const (
countUsersEvery = 1 * time.Hour
)
var countLock = sync.Mutex{}
type MiscService struct {
config *config.Config
userService IUserService
summaryService ISummaryService
keyValueService IKeyValueService
queueDefault *artifex.Dispatcher
queueWorkers *artifex.Dispatcher
}
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
@ -24,81 +34,72 @@ func NewMiscService(userService IUserService, summaryService ISummaryService, ke
userService: userService,
summaryService: summaryService,
keyValueService: keyValueService,
queueDefault: config.GetDefaultQueue(),
queueWorkers: config.GetQueue(config.QueueProcessing),
}
}
type CountTotalTimeJob struct {
UserID string
NumJobs int
}
type CountTotalTimeResult struct {
UserId string
Total time.Duration
}
func (srv *MiscService) ScheduleCountTotalTime() {
s := gocron.NewScheduler(time.Local)
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime)
s.StartBlocking()
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)
}
}
func (srv *MiscService) runCountTotalTime() error {
func (srv *MiscService) CountTotalTime() {
logbuch.Info("counting users total time")
if ok := countLock.TryLock(); !ok {
config.Log().Warn("couldn't acquire lock for counting users total time, job is still pending")
}
defer countLock.Unlock()
users, err := srv.userService.GetAll()
if err != nil {
return err
config.Log().Error("failed to fetch users for time counting, %v", err)
}
jobs := make(chan *CountTotalTimeJob, len(users))
results := make(chan *CountTotalTimeResult, len(users))
var totalTime = atomic.NewDuration(0)
var pendingJobs sync.WaitGroup
pendingJobs.Add(len(users))
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
user := *u
if err := srv.queueWorkers.Dispatch(func() {
defer pendingJobs.Done()
totalTime.Add(srv.countUserTotalTime(user.ID))
}); err != nil {
config.Log().Error("failed to enqueue counting job for user '%s'", user.ID)
pendingJobs.Done()
}
}
close(jobs)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results)
}
// persist
var i int
var total time.Duration
for i = 0; i < len(users); i++ {
result := <-results
total += result.Total
}
close(results)
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
logbuch.Error("failed to save total time count: %v", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(i),
}); err != nil {
logbuch.Error("failed to save total users count: %v", err)
}
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil {
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
} else {
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
go func(wg *sync.WaitGroup) {
if !utils.WaitTimeout(&pendingJobs, 2*countUsersEvery) {
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: totalTime.Load().String(),
}); err != nil {
config.Log().Error("failed to save total time count: %v", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(len(users)),
}); err != nil {
config.Log().Error("failed to save total users count: %v", err)
}
} else {
config.Log().Error("waiting for user counting jobs timed out")
}
}
}(&pendingJobs)
}
func (srv *MiscService) countUserTotalTime(userId string) time.Duration {
result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false)
if err != nil {
config.Log().Error("failed to count total for user %s: %v", userId, err)
return 0
}
return result.TotalTime()
}

View File

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

View File

@ -8,11 +8,12 @@ import (
type IAggregationService interface {
Schedule()
Run(set datastructure.Set[string]) error
AggregateSummaries(set datastructure.Set[string]) error
}
type IMiscService interface {
ScheduleCountTotalTime()
CountTotalTime()
}
type IAliasService interface {
@ -41,6 +42,7 @@ type IHeartbeatService interface {
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
DeleteByUserBefore(*models.User, time.Time) error
}
type IDiagnosticsService interface {
@ -88,13 +90,31 @@ type ISummaryService interface {
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
DeleteByUserBefore(string, time.Time) error
Insert(*models.Summary) error
}
type IReportService interface {
Schedule()
SyncSchedule(user *models.User) bool
Run(*models.User, time.Duration) error
SendReport(*models.User, time.Duration) error
}
type IHousekeepingService interface {
Schedule()
ClearOldUserData(*models.User, time.Duration) error
}
type ILeaderboardService interface {
Schedule()
ComputeLeaderboard([]*models.User, *models.IntervalKey, []uint8) error
ExistsAnyByUser(string) (bool, error)
CountUsers() (int64, error)
GetByInterval(*models.IntervalKey, *models.PageParams, bool) (models.Leaderboard, error)
GetByIntervalAndUser(*models.IntervalKey, string, bool) (models.Leaderboard, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8, *models.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)
}
type IUserService interface {
@ -103,7 +123,10 @@ type IUserService interface {
GetUserByEmail(string) (*models.User, error)
GetUserByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetManyMapped([]string) (map[string]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetAllByLeaderboard(bool) ([]*models.User, error)
GetActive(bool) ([]*models.User, error)
Count() (int64, error)
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)

View File

@ -208,6 +208,11 @@ func (srv *SummaryService) DeleteByUser(userId string) error {
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) DeleteByUserBefore(userId string, t time.Time) error {
srv.invalidateUserCache(userId)
return srv.repository.DeleteByUserBefore(userId, t)
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
srv.invalidateUserCache(summary.UserID)
return srv.repository.Insert(summary)

View File

@ -2,6 +2,7 @@ package services
import (
"fmt"
"github.com/duke-git/lancet/v2/convertor"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
@ -96,10 +97,28 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
return srv.repository.GetAll()
}
func (srv *UserService) GetMany(ids []string) ([]*models.User, error) {
return srv.repository.GetMany(ids)
}
func (srv *UserService) GetManyMapped(ids []string) (map[string]*models.User, error) {
users, err := srv.repository.GetMany(ids)
if err != nil {
return nil, err
}
return convertor.ToMap[*models.User, string, *models.User](users, func(u *models.User) (string, *models.User) {
return u.ID, u
}), nil
}
func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
return srv.repository.GetAllByReports(reportsEnabled)
}
func (srv *UserService) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
return srv.repository.GetAllByLeaderboard(leaderboardEnabled)
}
func (srv *UserService) GetActive(exact bool) ([]*models.User, error) {
minDate := time.Now().AddDate(0, 0, -1*srv.config.App.InactiveDays)
if !exact {

View File

@ -69,6 +69,10 @@ body {
@apply py-2 px-4 font-semibold rounded bg-red-600 hover:bg-red-700 text-white text-sm;
}
.btn-small {
@apply py-1 px-2;
}
.input-default {
@apply appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4;
}
@ -109,7 +113,37 @@ body {
@apply border-red-700;
}
.leaderboard-default {
@apply border-gray-700;
}
.leaderboard-self {
margin-left: -10px;
margin-right: -10px;
padding-left: calc(1rem + 10px);
padding-right: calc(1rem + 10px);
@apply border-green-700 bg-gray-800;
}
.leaderboard-gold {
border-color: #ffd700;
}
.leaderboard-silver {
border-color: #c0c0c0;
}
.leaderboard-bronze {
border-color: #cd7f32;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
.max-available {
max-width: -moz-available;
max-width: -webkit-fill-available;
max-width: fill-available;
}

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

@ -1,12 +1,24 @@
const colors = require('tailwindcss/colors')
module.exports = {
purge: {
enabled: true,
mode: 'all',
content: ['./views/*.tpl.html'],
safelist: [
'newsbox-default',
'newsbox-warning',
'newsbox-danger',
]
theme: {
extend: {
colors: {
green: colors.emerald,
}
}
},
}
content: [
'./views/*.tpl.html',
],
safelist: [
'newsbox-default',
'newsbox-warning',
'newsbox-danger',
'leaderboard-self',
'leaderboard-default',
'leaderboard-gold',
'leaderboard-silver',
'leaderboard-bronze',
]
}

View File

@ -1,15 +1,16 @@
BEGIN TRANSACTION;
INSERT INTO "key_string_values" VALUES ('20210213-add_has_data_field','done');
INSERT INTO "key_string_values" VALUES ('20210221-add_created_date_column','done');
INSERT INTO "key_string_values" VALUES ('imprint','no content here');
INSERT INTO "key_string_values" VALUES ('20210411-add_imprint_content','done');
INSERT INTO "key_string_values" VALUES ('20210806-remove_persisted_project_labels','done');
INSERT INTO "key_string_values" VALUES ('20211215-migrate_id_to_bigint-add_has_data_field','done');
INSERT INTO "key_string_values" VALUES ('latest_total_time','0s');
INSERT INTO "key_string_values" VALUES ('latest_total_users','0');
INSERT INTO "key_string_values" ("key","value") VALUES ('20210213-add_has_data_field','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20210221-add_created_date_column','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('imprint','no content here');
INSERT INTO "key_string_values" ("key","value") VALUES ('20210411-add_imprint_content','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20210806-remove_persisted_project_labels','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20211215-migrate_id_to_bigint-add_has_data_field','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20212212-total_summary_heartbeats','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20220317-align_num_heartbeats','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('20220318-mysql_timestamp_precision','done');
INSERT INTO "key_string_values" ("key","value") VALUES ('202203191-drop_diagnostics_user','done');
COMMIT;
BEGIN TRANSACTION;
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",

View File

@ -7,29 +7,31 @@ BEGIN TRANSACTION;
DROP TABLE IF EXISTS "aliases";
CREATE TABLE `aliases` (`id` integer,`type` integer NOT NULL,`user_id` text NOT NULL,`key` text NOT NULL,`value` text NOT NULL,PRIMARY KEY (`id`),CONSTRAINT `fk_aliases_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "diagnostics";
CREATE TABLE `diagnostics` (`id` integer,`user_id` text NOT NULL,`platform` text,`architecture` text,`plugin` text,`cli_version` text,`logs` text,`stack_trace` text,PRIMARY KEY (`id`),CONSTRAINT `fk_diagnostics_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
CREATE TABLE `diagnostics` (`id` integer,`platform` text,`architecture` text,`plugin` text,`cli_version` text,`logs` text,`stack_trace` text,PRIMARY KEY (`id`));
DROP TABLE IF EXISTS "heartbeats";
CREATE TABLE `heartbeats` (`id` integer,`user_id` text NOT NULL,`entity` text NOT NULL,`type` text,`category` text,`project` text,`branch` text,`language` text,`is_write` numeric,`editor` text,`operating_system` text,`machine` text,`user_agent` text,`time` timestamp,`hash` varchar(17),`origin` text,`origin_id` text,`created_at` timestamp,PRIMARY KEY (`id`),CONSTRAINT `fk_heartbeats_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
CREATE TABLE `heartbeats` (`id` integer,`user_id` text NOT NULL,`entity` text NOT NULL,`type` text,`category` text,`project` text,`branch` text,`language` text,`is_write` numeric,`editor` text,`operating_system` text,`machine` text,`user_agent` varchar(255),`time` timestamp(3),`hash` varchar(17),`origin` varchar(255),`origin_id` varchar(255),`created_at` timestamp(3),PRIMARY KEY (`id`),CONSTRAINT `fk_heartbeats_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "key_string_values";
CREATE TABLE `key_string_values` (`key` text,`value` text,PRIMARY KEY (`key`));
DROP TABLE IF EXISTS "language_mappings";
CREATE TABLE `language_mappings` (`id` integer,`user_id` text NOT NULL,`extension` varchar(16),`language` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_language_mappings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "leaderboard_items";
CREATE TABLE `leaderboard_items` (`id` integer,`user_id` text NOT NULL,`rank` integer,`interval` text NOT NULL,`by` integer,`total` integer NOT NULL,`key` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),CONSTRAINT `fk_leaderboard_items_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "project_labels";
CREATE TABLE `project_labels` (`id` integer,`user_id` text NOT NULL,`project_key` text,`label` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_project_labels_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "summaries";
CREATE TABLE "summaries" (`id` integer,`user_id` text NOT NULL,`from_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`to_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
CREATE TABLE "summaries" (`id` integer,`user_id` text NOT NULL,`from_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`to_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`num_heartbeats` integer DEFAULT 0,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "summary_items";
CREATE TABLE `summary_items` (`id` integer,`summary_id` integer,`type` integer,`key` text,`total` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_editors` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_operating_systems` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_machines` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_languages` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
CREATE TABLE `summary_items` (`id` integer,`summary_id` integer,`type` integer,`key` text,`total` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_machines` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_languages` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_editors` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_operating_systems` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "users";
CREATE TABLE `users` (`id` text,`api_key` text UNIQUE,`email` text,`location` text,`password` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,`last_logged_in_at` timestamp DEFAULT CURRENT_TIMESTAMP,`share_data_max_days` integer DEFAULT 0,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,PRIMARY KEY (`id`));
CREATE TABLE "users" (`id` text,`api_key` text UNIQUE DEFAULT NULL,`email` text,`location` text,`password` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,`last_logged_in_at` timestamp DEFAULT CURRENT_TIMESTAMP,`share_data_max_days` integer DEFAULT 0,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`wakatime_api_url` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,`public_leaderboard` numeric DEFAULT false,PRIMARY KEY (`id`));
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX `idx_alias_type_key` ON `aliases`(`type`,`key`);
DROP INDEX IF EXISTS "idx_alias_user";
CREATE INDEX `idx_alias_user` ON `aliases`(`user_id`);
DROP INDEX IF EXISTS "idx_diagnostics_user";
CREATE INDEX `idx_diagnostics_user` ON `diagnostics`(`user_id`);
DROP INDEX IF EXISTS "idx_entity";
CREATE INDEX `idx_entity` ON `heartbeats`(`entity`);
DROP INDEX IF EXISTS "idx_branch";
CREATE INDEX `idx_branch` ON `heartbeats`(`branch`);
DROP INDEX IF EXISTS "idx_editor";
CREATE INDEX `idx_editor` ON `heartbeats`(`editor`);
DROP INDEX IF EXISTS "idx_heartbeats_hash";
CREATE UNIQUE INDEX `idx_heartbeats_hash` ON `heartbeats`(`hash`);
DROP INDEX IF EXISTS "idx_language";
@ -38,6 +40,16 @@ DROP INDEX IF EXISTS "idx_language_mapping_composite";
CREATE UNIQUE INDEX `idx_language_mapping_composite` ON `language_mappings`(`user_id`,`extension`);
DROP INDEX IF EXISTS "idx_language_mapping_user";
CREATE INDEX `idx_language_mapping_user` ON `language_mappings`(`user_id`);
DROP INDEX IF EXISTS "idx_leaderboard_combined";
CREATE INDEX `idx_leaderboard_combined` ON `leaderboard_items`(`interval`,`by`);
DROP INDEX IF EXISTS "idx_leaderboard_user";
CREATE INDEX `idx_leaderboard_user` ON `leaderboard_items`(`user_id`);
DROP INDEX IF EXISTS "idx_machine";
CREATE INDEX `idx_machine` ON `heartbeats`(`machine`);
DROP INDEX IF EXISTS "idx_operating_system";
CREATE INDEX `idx_operating_system` ON `heartbeats`(`operating_system`);
DROP INDEX IF EXISTS "idx_project";
CREATE INDEX `idx_project` ON `heartbeats`(`project`);
DROP INDEX IF EXISTS "idx_project_label_user";
CREATE INDEX `idx_project_label_user` ON `project_labels`(`user_id`);
DROP INDEX IF EXISTS "idx_time";
@ -50,4 +62,6 @@ DROP INDEX IF EXISTS "idx_type";
CREATE INDEX `idx_type` ON `summary_items`(`type`);
DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX `idx_user_email` ON `users`(`email`);
DROP INDEX IF EXISTS "idx_user_project";
CREATE INDEX `idx_user_project` ON `heartbeats`(`user_id`,`project`);
COMMIT;

View File

@ -3,8 +3,6 @@ package utils
import (
"encoding/base64"
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"golang.org/x/crypto/bcrypt"
"net/http"
"regexp"
@ -44,19 +42,6 @@ func ExtractBearerAuth(r *http.Request) (key string, err error) {
return string(keyBytes), err
}
func ExtractCookieAuth(r *http.Request, config *config.Config) (username *string, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.Security.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &username); err != nil {
return nil, errors.New("cookie is invalid")
}
return username, nil
}
func CompareBcrypt(wanted, actual, pepper string) bool {
plainPassword := []byte(strings.TrimSpace(actual) + pepper)
err := bcrypt.CompareHashAndPassword([]byte(wanted), plainPassword)

21
utils/collection.go Normal file
View File

@ -0,0 +1,21 @@
package utils
import "strings"
func SubSlice[T any](slice []T, from, to uint) []T {
if int(to) > len(slice) {
to = uint(len(slice))
}
return slice[from:int(to)]
}
func CloneStringMap(m map[string]string, keysToLower bool) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
if keysToLower {
k = strings.ToLower(k)
}
m2[k] = v
}
return m2
}

11
utils/cron.go Normal file
View File

@ -0,0 +1,11 @@
package utils
import "strings"
func CronPadToSecondly(expr string) string {
parts := strings.Split(expr, " ")
if len(parts) == 6 {
return expr
}
return "0 " + expr
}

View File

@ -1,8 +1,8 @@
package utils
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"strings"
"time"
)
@ -47,16 +47,28 @@ func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
return intervals
}
func FmtWakatimeDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
return fmt.Sprintf("%d hrs %d mins", h, m)
}
// LocalTZOffset returns the time difference between server local time and UTC
func LocalTZOffset() time.Duration {
_, offset := time.Now().Zone()
return time.Duration(offset * int(time.Second))
}
func ParseWeekday(s string) time.Weekday {
switch strings.ToLower(s) {
case "mon", strings.ToLower(time.Monday.String()):
return time.Monday
case "tue", strings.ToLower(time.Tuesday.String()):
return time.Tuesday
case "wed", strings.ToLower(time.Wednesday.String()):
return time.Wednesday
case "thu", strings.ToLower(time.Thursday.String()):
return time.Thursday
case "fri", strings.ToLower(time.Friday.String()):
return time.Friday
case "sat", strings.ToLower(time.Saturday.String()):
return time.Saturday
case "sun", strings.ToLower(time.Sunday.String()):
return time.Sunday
}
return time.Monday
}

View File

@ -2,7 +2,6 @@ package utils
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
"testing"
"time"
@ -23,8 +22,8 @@ func init() {
}
func TestDate_SplitRangeByDays(t *testing.T) {
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
df1, _ := time.Parse("2006-01-02 15:04:05", "2021-04-25 20:25:00")
dt1, _ := time.Parse("2006-01-02 15:04:05", "2021-04-28 06:45:00")
df2 := df1
dt2 := datetime.EndOfDay(df1)
df3 := df1

View File

@ -1,8 +1,10 @@
package utils
import (
"fmt"
"github.com/emvi/logbuch"
"gorm.io/gorm"
"reflect"
)
func IsCleanDB(db *gorm.DB) bool {
@ -30,3 +32,20 @@ func HasConstraints(db *gorm.DB) bool {
logbuch.Warn("HasForeignKeyConstraints is not yet implemented for dialect '%s'", db.Dialector.Name())
return false
}
func WhereNullable(query *gorm.DB, col string, val any) *gorm.DB {
if val == nil || reflect.ValueOf(val).IsNil() {
return query.Where(fmt.Sprintf("%s is null", col))
}
return query.Where(fmt.Sprintf("%s = ?", col), val)
}
func WithPaging(query *gorm.DB, limit, skip int) *gorm.DB {
if limit >= 0 {
query = query.Limit(limit)
}
if skip >= 0 {
query = query.Offset(skip)
}
return query
}

View File

@ -1,8 +1,8 @@
package utils
import (
"encoding/json"
"github.com/muety/wakapi/config"
"errors"
"github.com/muety/wakapi/models"
"net/http"
"regexp"
"strconv"
@ -22,14 +22,6 @@ func init() {
cacheMaxAgeRe = regexp.MustCompile(cacheMaxAgePattern)
}
func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(object); err != nil {
config.Log().Request(r).Error("error while writing json response: %v", err)
}
}
func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
cacheControl := r.Header.Get("cache-control")
if strings.Contains(cacheControl, "no-cache") {
@ -42,3 +34,36 @@ func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
}
return false
}
func ParsePageParams(r *http.Request) *models.PageParams {
pageParams := &models.PageParams{}
page := r.URL.Query().Get("page")
pageSize := r.URL.Query().Get("page_size")
if p, err := strconv.Atoi(page); err == nil {
pageParams.Page = p
}
if p, err := strconv.Atoi(pageSize); err == nil && pageParams.Page > 0 {
pageParams.PageSize = p
}
return pageParams
}
func ParsePageParamsWithDefault(r *http.Request, page, size int) *models.PageParams {
pageParams := ParsePageParams(r)
if pageParams.Page == 0 {
pageParams.Page = page
}
if pageParams.PageSize == 0 {
pageParams.PageSize = size
}
return pageParams
}
func ParseUserAgent(ua string) (string, string, error) {
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
groups := re.FindAllStringSubmatch(ua, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string")
}
return groups[0][1], groups[0][2], nil
}

View File

@ -8,3 +8,23 @@ import (
func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
}
func SplitMulti(s string, delimiters ...string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
for _, d := range delimiters {
if string(r) == d {
return true
}
}
return false
})
}
func FindString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}

23
utils/sync.go Normal file
View File

@ -0,0 +1,23 @@
package utils
import (
"sync"
"time"
)
// WaitTimeout waits for the waitgroup for the specified max timeout.
// Returns true if waiting timed out.
// See // https://stackoverflow.com/a/32843750/3112139.
func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
c := make(chan struct{})
go func() {
defer close(c)
wg.Wait()
}()
select {
case <-c:
return false // completed normally
case <-time.After(timeout):
return true // timed out
}
}

View File

@ -1,12 +1,12 @@
{{ 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 flex-grow max-w-lg">
<div class="p-4 font-semibold text-white text-sm bg-red-500 rounded mt-16 shadow grow max-w-lg">
Error: {{ .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 flex-grow max-w-lg">
<div class="p-4 font-semibold text-white text-sm bg-green-500 rounded mt-16 shadow grow max-w-lg">
{{ .Success | capitalize }}
</div>
</div>

View File

@ -1,10 +1,11 @@
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-20">
<footer class="flex justify-between w-full text-gray-500 mt-20 items-center gap-x-4">
<div class="text-xs font-mono font-semibold">
{{ getVersion }} @ {{ getDbType }}
</div>
<div class="font-semibold text-sm hidden sm:inline-block">
Made with &nbsp; <span class="iconify inline" data-icon="bi:heart-fill"></span> &nbsp; by <a href="https://muetsch.io" class="text-gray-400 hover:text-gray-300">Ferdinand Mütsch</a> as <a
href="https://github.com/muety/wakapi" class="text-gray-400 hover:text-gray-300">open-source</a> software
<div class="font-semibold text-sm">
Made with &nbsp; <span class="iconify inline" data-icon="bi:heart-fill"></span>&nbsp; by
<a href="https://muetsch.io" class="text-gray-400 hover:text-gray-300">Ferdinand Mütsch</a> as
<a href="https://github.com/muety/wakapi" class="text-gray-400 hover:text-gray-300">open-source</a> software
</div>
<div class="text-sm">
<a href="imprint" class="font-semibold hover:text-gray-400">Imprint, Cookies & Data Privacy</a>

View File

@ -7,8 +7,8 @@
{{ template "header.tpl.html" . }}
<main class="mt-10 flex-grow flex w-full">
<div class="flex-grow max-w-4xl flex flex-col">
<main class="mt-10 grow flex w-full">
<div class="grow max-w-4xl flex flex-col">
<h1 class="h1">Imprint & Data Privacy</h1>
<p>
{{ htmlSafe .HtmlText }}

View File

@ -9,14 +9,9 @@
{{ template "alerts.tpl.html" . }}
<div class="absolute flex top-0 right-0 mr-4 mt-10 py-2">
<div class="mx-1">
<a href="login" class="btn-primary">
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a>
</div>
</div>
{{ template "login-btn.tpl.html" . }}
<main class="mt-10 px-4 md:px-10 lg:px-24 flex-grow flex justify-center w-full">
<main class="mt-10 px-4 md:px-10 lg:px-24 grow flex justify-center w-full">
<div class="flex flex-col text-white">
{{ if and .Newsbox .Newsbox.Text }}
<div class="mb-14 -mt-4 newsbox newsbox-{{ .Newsbox.Type }}">
@ -74,6 +69,7 @@
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; 100 % free and open-source</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Built by developers for developers</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Fancy statistics and plots</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Public leaderboards</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Cool badges for readmes</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Weekly e-mail reports</li>
<li><span class="iconify inline text-green-700" data-icon="eva:checkmark-circle-2-fill"></span> &nbsp; Intuitive REST API</li>

View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
<script>
const defaultTab = 'total'
</script>
<body class="relative bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen {{ if .User }} max-w-screen-xl {{ else }} max-w-screen-lg {{end}} mx-auto justify-center">
{{ template "alerts.tpl.html" . }}
{{ if .User }}
{{ template "menu-main.tpl.html" . }}
{{ else }}
{{ template "header.tpl.html" . }}
{{ template "login-btn.tpl.html" . }}
{{ end }}
<main class="mt-10 grow flex justify-center w-full" id="leaderboard-page">
<div class="flex flex-col grow mt-10 max-available">
<h1 class="h1" style="margin-bottom: 0.5rem">Leaderboard</h1>
<p class="block text-sm text-gray-300 w-full lg:w-3/4 mb-8">
Wakapi's leaderboard shows a ranking of the most active users on this server, given they opted in to get listed on the public leaderboard. Statistics are updated at least every 12 hours and are based on the users' total coding time in the past seven days.
To participate, log in, go to <a class="link" href="settings#permissions">Settings 🠒 Permissions</a> and enable leaderboards.
</p>
<ul class="flex space-x-4 mb-4 text-gray-600">
<li class="font-semibold text-xl {{ if eq .By "" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<a href="leaderboard">Total</a>
</li>
<li class="font-semibold text-xl {{ if eq .By "language" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<a href="leaderboard?by=language">By Language</a>
</li>
</ul>
{{ if ne .By "" }}
<div class="flex flex-wrap space-x-2 mb-4">
{{ range $i, $key := (strslice .TopKeys 0 10) }}
<div class="inline-block mb-4">
<a href="leaderboard?by={{ $.By }}&key={{ lower $key }}" class="{{ if eq (lower $.Key) (lower $key) }} btn-primary {{ else }} btn-default {{ end }} btn-small cursor-pointer whitespace-nowrap">
{{ if and (eq (lower $.By) "language") ($.LangIcon $key) }}
<span class="align-middle leading-none"><span class="iconify inline text-white text-base" data-icon="{{ ($.LangIcon $key) | urlSafe }}"></span>&nbsp;</span>
{{ end }}
<span>{{ $key }}</span>
</a>
</div>
{{ end }}
</div>
{{ end }}
<div class="flex flex-col space-y-4 mt-4 text-gray-300 w-full lg:w-3/4">
{{ if len .Items }}
<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">
{{ if avatarUrlTemplate }}
<img src="{{ $item.User.AvatarURL avatarUrlTemplate }}" width="24px" class="rounded-full border-green-700" alt="User Profile Avatar"/>
{{ else }}
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-green-700" style="width: 24px; height: 24px" data-icon="ic:round-person"></span>
{{ end }}
<strong class="text-ellipsis truncate">@{{ $item.UserID }}</strong>
</div>
<div class="w-5/12 mx-1 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>
{{ end }}
<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>
</li>
{{ end }}
</ol>
<p class="text-sm pt-8">Last Updated: {{ .LastUpdate | datetime }}</p>
{{ else }}
<p>
<span class="iconify inline text-white text-base" data-icon="twemoji:frowning-face"></span>&nbsp;
The leaderboard is currently empty ...
</p>
{{ end }}
</div>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

10
views/login-btn.tpl.html Normal file
View File

@ -0,0 +1,10 @@
<div class="absolute flex top-0 right-0 mr-4 mt-10 py-2">
<div class="mx-1">
<a href="leaderboard" class="btn-default">
<span class="iconify inline" data-icon="fluent:data-bar-horizontal-24-filled"></span> &nbsp;Leaderboard</a>
</div>
<div class="mx-1">
<a href="login" class="btn-primary">
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a>
</div>
</div>

View File

@ -9,8 +9,8 @@
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-10">
<main class="mt-10 grow flex justify-center w-full">
<div class="grow max-w-lg mt-10">
<div class="mb-8">
<h1 class="h1">Welcome!</h1>
<span class="h1-subcaption">Log in to continue using Wakapi</span>

View File

@ -1,7 +1,7 @@
<script type="module" src="assets/js/components/menu-main.js"></script>
<div class="flex justify-between space-x-4 items-center relative" id="main-menu" v-scope @vue:mounted="mounted">
<div class="mr-8 hidden lg:inline-block flex-shrink-0">
<div class="mr-8 hidden lg:inline-block shrink-0">
{{ template "logo.tpl.html" }}
</div>
@ -10,6 +10,11 @@
<span class="text-gray-300 hidden lg:inline-block">Dashboard</span>
</a>
<a class="menu-item" href="leaderboard">
<span class="iconify inline text-2xl text-gray-400" data-icon="fluent:data-bar-horizontal-24-filled"></span>
<span class="text-gray-300 hidden lg:inline-block">Leaderboard</span>
</a>
<div class="menu-item hidden sm:flex imp:cursor-not-allowed">
<span class="iconify inline text-2xl text-gray-700" data-icon="bi:people-fill"></span>
<a class="text-gray-600 leading-none hidden lg:inline-block">Team<br>
@ -17,20 +22,13 @@
</a>
</div>
<div class="menu-item hidden sm:flex imp:cursor-not-allowed">
<span class="iconify inline text-2xl text-gray-700" data-icon="fluent:data-bar-horizontal-24-filled"></span>
<a class="text-gray-600 leading-none hidden lg:inline-block">Leaderboard<br>
<span class="text-xxs whitespace-nowrap">(coming soon)</span>
</a>
</div>
<div class="menu-item relative" @click="state.showDropdownResources = !state.showDropdownResources" data-trigger-for="showDropdownResources">
<span class="iconify inline text-2xl text-gray-400" data-icon="ph:books-bold"></span>
<a class="text-gray-400 hidden lg:inline-block">Resources</a>
<span class="iconify inline text-xl text-gray-400" data-icon="akar-icons:chevron-down"></span>
<div v-cloak v-show="state.showDropdownResources" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup mt-12 w-full" id="resources-menu-dropdown" style="min-width: 128px">
<div class="flex-grow flex flex-col">
<div class="grow flex flex-col">
<div class="submenu-item">
<a class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold" href="https://github.com/muety/wakapi" target="_blank" rel="noreferrer noopener" @click="state.showDropdownResources = !state.showDropdownResources" data-trigger-for="showDropdownResources">
<span class="text-sm">GitHub</span>
@ -64,9 +62,9 @@
<span class="text-gray-400 hidden lg:inline-block">Settings</span>
</a>
<div class="flex-grow"></div>
<div class="grow"></div>
<div class="flex-shrink-0 menu-item relative" @click="state.showDropdownUser = !state.showDropdownUser" data-trigger-for="showDropdownUser">
<div class="shrink-0 menu-item relative" @click="state.showDropdownUser = !state.showDropdownUser" data-trigger-for="showDropdownUser">
<div class="hidden md:flex flex flex-col text-right">
<a class="text-gray-300">{{ .User.ID }}</a>
{{ if .User.Email }}
@ -80,7 +78,7 @@
{{ end }}
<div v-cloak v-show="state.showDropdownUser" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup mt-16 w-full" id="user-menu-popup" style="min-width: 156px;">
<div class="flex-grow flex flex-col">
<div class="grow flex flex-col">
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
<button class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold" @click="state.showApiKey = true" data-trigger-for="showApiKey">
<span class="text-sm">Show API Key</span>
@ -88,7 +86,7 @@
</button>
</div>
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
<form action="logout" method="post" class="flex-grow">
<form action="logout" method="post" class="grow">
<button type="submit" class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold">
<span class="text-sm">Logout</span>
<span class="iconify inline" data-icon="ls:logout"></span>
@ -100,7 +98,7 @@
</div>
<div v-cloak v-show="state.showApiKey" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup" id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
<div class="grow flex flex-col px-2">
<span class="text-xxs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
value="{{ .ApiKey }}" style="min-width: 330px">

View File

@ -9,8 +9,8 @@
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-10">
<main class="mt-10 grow flex justify-center w-full">
<div class="grow max-w-lg mt-10">
<div class="mb-8">
<h1 class="h1">Reset Password</h1>
<span class="h1-subcaption">

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