diff --git a/config/config.go b/config/config.go index 4d412ec..4d18c64 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "flag" "fmt" + "github.com/muety/wakapi/utils" "github.com/robfig/cron/v3" "io/ioutil" "net/http" @@ -229,19 +230,19 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc { } 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) GetAggregationTimeCron() string { @@ -261,14 +262,14 @@ func (c *appConfig) GetAggregationTimeCron() string { return fmt.Sprintf("0 %d %d * * *", m, h) } - return cronPadToSecondly(c.AggregationTime) + return utils.CronPadToSecondly(c.AggregationTime) } func (c *appConfig) GetWeeklyReportCron() string { if strings.Contains(c.ReportTimeWeekly, ",") { // old gocron format, e.g. "fri,18:00" split := strings.Split(c.ReportTimeWeekly, ",") - weekday := parseWeekday(split[0]) + weekday := utils.ParseWeekday(split[0]) timeParts := strings.Split(split[1], ":") h, err := strconv.Atoi(timeParts[0]) @@ -284,7 +285,7 @@ func (c *appConfig) GetWeeklyReportCron() string { return fmt.Sprintf("0 %d %d * * %d", m, h, weekday) } - return cronPadToSecondly(c.ReportTimeWeekly) + return utils.CronPadToSecondly(c.ReportTimeWeekly) } func (c *appConfig) GetLeaderboardGenerationTimeCron() []string { @@ -310,11 +311,11 @@ func (c *appConfig) GetLeaderboardGenerationTimeCron() []string { } } else { parse = func(s string) string { - return cronPadToSecondly(s) + return utils.CronPadToSecondly(s) } } - for _, s := range strings.Split(c.LeaderboardGenerationTime, ";") { + for _, s := range utils.SplitMulti(c.LeaderboardGenerationTime, ",", ";") { crons = append(crons, parse(strings.TrimSpace(s))) } @@ -393,43 +394,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 cronPadToSecondly(expr string) string { - parts := strings.Split(expr, " ") - if len(parts) == 6 { - return expr - } - return "0 " + expr -} - func Set(config *Config) { cfg = config } @@ -489,7 +453,7 @@ 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.ParseDuration(config.App.HeartbeatMaxAge); err != nil { diff --git a/config/utils.go b/config/utils.go deleted file mode 100644 index 942c66d..0000000 --- a/config/utils.go +++ /dev/null @@ -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 -} diff --git a/utils/common.go b/helpers/date.go similarity index 71% rename from utils/common.go rename to helpers/date.go index 69f5b20..c099d40 100644 --- a/utils/common.go +++ b/helpers/date.go @@ -1,9 +1,8 @@ -package utils +package helpers import ( - "errors" + "fmt" "github.com/muety/wakapi/config" - "regexp" "time" ) @@ -41,22 +40,10 @@ func FormatDateHuman(date time.Time) string { return date.Format("Mon, 02 Jan 2006") } -func Add(i, j int) int { - return i + j -} - -func ParseUserAgent(ua string) (string, string, error) { - re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`) - groups := re.FindAllStringSubmatch(ua, -1) - if len(groups) == 0 || len(groups[0]) != 3 { - return "", "", errors.New("failed to parse user agent string") - } - return groups[0][1], groups[0][2], nil -} - -func SubSlice[T any](slice []T, from, to uint) []T { - if int(to) > len(slice) { - to = uint(len(slice)) - } - return slice[from:int(to)] +func FmtWakatimeDuration(d time.Duration) string { + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + return fmt.Sprintf("%d hrs %d mins", h, m) } diff --git a/helpers/helpers.go b/helpers/helpers.go new file mode 100644 index 0000000..f6c2dbf --- /dev/null +++ b/helpers/helpers.go @@ -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 diff --git a/helpers/http.go b/helpers/http.go new file mode 100644 index 0000000..4318832 --- /dev/null +++ b/helpers/http.go @@ -0,0 +1,20 @@ +package helpers + +import ( + "encoding/json" + "github.com/muety/wakapi/config" + "github.com/muety/wakapi/utils" + "net/http" +) + +func ExtractCookieAuth(r *http.Request) (username *string, err error) { + return utils.ExtractCookieAuth(r, config.Get().Security.SecureCookie) +} + +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) + } +} diff --git a/utils/summary.go b/helpers/summary.go similarity index 86% rename from utils/summary.go rename to helpers/summary.go index e2f62e9..842c6d2 100644 --- a/utils/summary.go +++ b/helpers/summary.go @@ -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 +} diff --git a/middlewares/authenticate.go b/middlewares/authenticate.go index 82417b4..3972b28 100644 --- a/middlewares/authenticate.go +++ b/middlewares/authenticate.go @@ -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) if err != nil { return nil, err } diff --git a/models/compat/shields/v1/badge.go b/models/compat/shields/v1/badge.go index 39f6dc1..6ef754d 100644 --- a/models/compat/shields/v1/badge.go +++ b/models/compat/shields/v1/badge.go @@ -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, } } diff --git a/models/compat/wakatime/v1/all_time.go b/models/compat/wakatime/v1/all_time.go index 8d200d4..55dc68f 100644 --- a/models/compat/wakatime/v1/all_time.go +++ b/models/compat/wakatime/v1/all_time.go @@ -1,8 +1,8 @@ package v1 import ( + "github.com/muety/wakapi/helpers" "github.com/muety/wakapi/models" - "github.com/muety/wakapi/utils" "time" ) @@ -33,13 +33,13 @@ func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel { return &AllTimeViewModel{ Data: &AllTimeData{ TotalSeconds: float32(total.Seconds()), - Text: utils.FmtWakatimeDuration(total), + Text: helpers.FmtWakatimeDuration(total), IsUpToDate: true, Range: &AllTimeRange{ End: summary.ToTime.T().Format(time.RFC3339), - EndDate: utils.FormatDate(summary.ToTime.T()), + EndDate: helpers.FormatDate(summary.ToTime.T()), Start: summary.FromTime.T().Format(time.RFC3339), - StartDate: utils.FormatDate(summary.FromTime.T()), + StartDate: helpers.FormatDate(summary.FromTime.T()), Timezone: tzName, }, }, diff --git a/models/compat/wakatime/v1/summaries.go b/models/compat/wakatime/v1/summaries.go index 7716e3a..cbbffa4 100644 --- a/models/compat/wakatime/v1/summaries.go +++ b/models/compat/wakatime/v1/summaries.go @@ -2,8 +2,8 @@ package v1 import ( "fmt" + "github.com/muety/wakapi/helpers" "github.com/muety/wakapi/models" - "github.com/muety/wakapi/utils" "math" "sync" "time" @@ -96,7 +96,7 @@ func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel { Decimal: fmt.Sprintf("%.2f", totalHrs), Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)), Seconds: totalSecs, - Text: utils.FmtWakatimeDuration(totalTime), + Text: helpers.FmtWakatimeDuration(totalTime), }, } } @@ -119,7 +119,7 @@ func newDataFrom(s *models.Summary) *SummariesData { Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Hours: totalHrs, Minutes: totalMins, - Text: utils.FmtWakatimeDuration(total), + Text: helpers.FmtWakatimeDuration(total), TotalSeconds: total.Seconds(), }, Range: &SummariesRange{ @@ -201,7 +201,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEn Name: e.Key, Percent: percentage, Seconds: secs, - Text: utils.FmtWakatimeDuration(total), + Text: helpers.FmtWakatimeDuration(total), TotalSeconds: total.Seconds(), } } diff --git a/routes/api/diagnostics.go b/routes/api/diagnostics.go index 219204d..5b3b8de 100644 --- a/routes/api/diagnostics.go +++ b/routes/api/diagnostics.go @@ -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{}{}) } diff --git a/routes/api/heartbeat.go b/routes/api/heartbeat.go index 58574d8..c923d2c 100644 --- a/routes/api/heartbeat.go +++ b/routes/api/heartbeat.go @@ -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) diff --git a/routes/api/metrics.go b/routes/api/metrics.go index fcab86e..404c1bd 100644 --- a/routes/api/metrics.go +++ b/routes/api/metrics.go @@ -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" @@ -129,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 { diff --git a/routes/api/summary.go b/routes/api/summary.go index ac013e8..a5926e8 100644 --- a/routes/api/summary.go +++ b/routes/api/summary.go @@ -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) } diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index 854d9d2..600cb4b 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -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 } diff --git a/routes/compat/wakatime/v1/all_time.go b/routes/compat/wakatime/v1/all_time.go index 8da804e..431eaec 100644 --- a/routes/compat/wakatime/v1/all_time.go +++ b/routes/compat/wakatime/v1/all_time.go @@ -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) { diff --git a/routes/compat/wakatime/v1/heartbeat.go b/routes/compat/wakatime/v1/heartbeat.go index c21f797..1a30bad 100644 --- a/routes/compat/wakatime/v1/heartbeat.go +++ b/routes/compat/wakatime/v1/heartbeat.go @@ -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) } diff --git a/routes/compat/wakatime/v1/projects.go b/routes/compat/wakatime/v1/projects.go index 5e2ebb6..acdc12e 100644 --- a/routes/compat/wakatime/v1/projects.go +++ b/routes/compat/wakatime/v1/projects.go @@ -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) } diff --git a/routes/compat/wakatime/v1/stats.go b/routes/compat/wakatime/v1/stats.go index b625eaa..fa03c1e 100644 --- a/routes/compat/wakatime/v1/stats.go +++ b/routes/compat/wakatime/v1/stats.go @@ -1,6 +1,7 @@ package v1 import ( + "github.com/muety/wakapi/helpers" "net/http" "time" @@ -10,7 +11,6 @@ import ( "github.com/muety/wakapi/models" v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" - "github.com/muety/wakapi/utils" ) type StatsHandler struct { @@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { rangeParam = (*models.IntervalPast7Days)[0] } - err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ()) + err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ()) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid range")) @@ -94,7 +94,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { return } - summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, utils.ParseSummaryFilters(r)) + summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, helpers.ParseSummaryFilters(r)) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) @@ -120,7 +120,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { stats.Data.Machines = nil } - utils.RespondJSON(w, r, http.StatusOK, stats) + helpers.RespondJSON(w, r, http.StatusOK, stats) } func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) { diff --git a/routes/compat/wakatime/v1/statusbar.go b/routes/compat/wakatime/v1/statusbar.go index 9fade55..a71a791 100644 --- a/routes/compat/wakatime/v1/statusbar.go +++ b/routes/compat/wakatime/v1/statusbar.go @@ -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], }) diff --git a/routes/compat/wakatime/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go index 62be34c..5a45b7b 100644 --- a/routes/compat/wakatime/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -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())) diff --git a/routes/compat/wakatime/v1/users.go b/routes/compat/wakatime/v1/users.go index 72601bc..b1ae7ab 100644 --- a/routes/compat/wakatime/v1/users.go +++ b/routes/compat/wakatime/v1/users.go @@ -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}) } diff --git a/routes/routes.go b/routes/routes.go index 1a8d508..7b281b0 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "fmt" + "github.com/muety/wakapi/helpers" "html/template" "net/http" "strings" @@ -24,16 +25,16 @@ func Init() { func DefaultTemplateFuncs() template.FuncMap { return template.FuncMap{ "json": utils.Json, - "date": utils.FormatDateHuman, - "datetime": utils.FormatDateTimeHuman, - "simpledate": utils.FormatDate, - "simpledatetime": utils.FormatDateTime, - "duration": utils.FmtWakatimeDuration, + "date": helpers.FormatDateHuman, + "datetime": helpers.FormatDateTimeHuman, + "simpledate": helpers.FormatDate, + "simpledatetime": helpers.FormatDateTime, + "duration": helpers.FmtWakatimeDuration, "floordate": datetime.BeginOfDay, "ceildate": utils.CeilDate, "title": strings.Title, "join": strings.Join, - "add": utils.Add, + "add": add, "capitalize": utils.Capitalize, "lower": strings.ToLower, "toRunes": utils.ToRunes, @@ -106,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 +} diff --git a/routes/summary.go b/routes/summary.go index 268da1d..709c4c7 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -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) diff --git a/routes/utils/badge_utils.go b/routes/utils/badge_utils.go index 327296b..5b012b6 100644 --- a/routes/utils/badge_utils.go +++ b/routes/utils/badge_utils.go @@ -2,8 +2,8 @@ package utils import ( "errors" + "github.com/muety/wakapi/helpers" "github.com/muety/wakapi/models" - "github.com/muety/wakapi/utils" "net/http" "regexp" ) @@ -31,12 +31,12 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI var intervalKey = models.IntervalPast30Days if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { - if i, err := utils.ParseInterval(groups[1]); err == nil { + if i, err := helpers.ParseInterval(groups[1]); err == nil { intervalKey = i } } - _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ()) + _, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ()) interval := &models.KeyedInterval{ Interval: models.Interval{Start: rangeFrom, End: rangeTo}, Key: intervalKey, diff --git a/routes/utils/summary_utils.go b/routes/utils/summary_utils.go index d07304c..dfee8bf 100644 --- a/routes/utils/summary_utils.go +++ b/routes/utils/summary_utils.go @@ -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 } diff --git a/services/leaderboard.go b/services/leaderboard.go index 8bea809..a2e1015 100644 --- a/services/leaderboard.go +++ b/services/leaderboard.go @@ -5,9 +5,9 @@ import ( "github.com/leandro-lugaresi/hub" "github.com/muety/artifex" "github.com/muety/wakapi/config" + "github.com/muety/wakapi/helpers" "github.com/muety/wakapi/models" "github.com/muety/wakapi/repositories" - "github.com/muety/wakapi/utils" "github.com/patrickmn/go-cache" "reflect" "strconv" @@ -212,7 +212,7 @@ func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.I } func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) { - err, from, to := utils.ResolveIntervalTZ(interval, user.TZ()) + err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ()) if err != nil { return nil, err } @@ -231,7 +231,7 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model } func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) { - err, from, to := utils.ResolveIntervalTZ(interval, user.TZ()) + err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ()) if err != nil { return nil, err } diff --git a/services/mail/mail.go b/services/mail/mail.go index 6f1c638..33c917c 100644 --- a/services/mail/mail.go +++ b/services/mail/mail.go @@ -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) diff --git a/utils/auth.go b/utils/auth.go index ab96242..28ae791 100644 --- a/utils/auth.go +++ b/utils/auth.go @@ -3,7 +3,7 @@ package utils import ( "encoding/base64" "errors" - "github.com/muety/wakapi/config" + "github.com/gorilla/securecookie" "github.com/muety/wakapi/models" "golang.org/x/crypto/bcrypt" "net/http" @@ -44,13 +44,13 @@ 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) { +func ExtractCookieAuth(r *http.Request, secureCookie *securecookie.SecureCookie) (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 { + if err := secureCookie.Decode(models.AuthCookieKey, cookie.Value, &username); err != nil { return nil, errors.New("cookie is invalid") } diff --git a/utils/collection.go b/utils/collection.go new file mode 100644 index 0000000..b079e4a --- /dev/null +++ b/utils/collection.go @@ -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 +} diff --git a/utils/common_test.go b/utils/collection_test.go similarity index 100% rename from utils/common_test.go rename to utils/collection_test.go diff --git a/utils/date.go b/utils/date.go index 4a0a64e..2fc1717 100644 --- a/utils/date.go +++ b/utils/date.go @@ -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 +} diff --git a/utils/date_test.go b/utils/date_test.go index 02d85db..a5d676c 100644 --- a/utils/date_test.go +++ b/utils/date_test.go @@ -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 diff --git a/utils/http.go b/utils/http.go index faebb7e..5ee9b29 100644 --- a/utils/http.go +++ b/utils/http.go @@ -1,8 +1,7 @@ package utils import ( - "encoding/json" - "github.com/muety/wakapi/config" + "errors" "github.com/muety/wakapi/models" "net/http" "regexp" @@ -23,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") { @@ -67,3 +58,12 @@ func ParsePageParamsWithDefault(r *http.Request, page, size int) *models.PagePar } 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 +} diff --git a/utils/strings.go b/utils/strings.go index 3175d79..ef78ffc 100644 --- a/utils/strings.go +++ b/utils/strings.go @@ -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 +}