mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
feat(compat): implement wakatime user agents endpoint (resolve #833)
This commit is contained in:
3
main.go
3
main.go
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/condition"
|
||||
|
||||
_ "github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -229,6 +228,7 @@ func main() {
|
||||
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
|
||||
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
|
||||
wakatimeV1LeadersHandler := wtV1Routes.NewLeadersHandler(userService, leaderboardService)
|
||||
wakatimeV1UserAgentsHandler := wtV1Routes.NewUserAgentsHandler(userService, heartbeatService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// MVC Handlers
|
||||
@@ -302,6 +302,7 @@ func main() {
|
||||
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1LeadersHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1UserAgentsHandler.RegisterRoutes(apiRouter)
|
||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||
captchaHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatServiceMock struct {
|
||||
@@ -100,3 +101,8 @@ func (m *HeartbeatServiceMock) GetUserProjectStats(u *models.User, t, t2 time.Ti
|
||||
args := m.Called(u, t, t2, p, b)
|
||||
return args.Get(0).([]*models.ProjectStats), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetUserAgentsByUser(u *models.User) ([]*models.UserAgent, error) {
|
||||
args := m.Called(u)
|
||||
return args.Get(0).([]*models.UserAgent), args.Error(0)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type UserAgentsViewModel struct {
|
||||
Data []*UserAgentEntry `json:"data"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type UserAgentEntry struct {
|
||||
Id string `json:"id"`
|
||||
Editor string `json:"editor"`
|
||||
Os string `json:"os"`
|
||||
Value string `json:"value"`
|
||||
Id string `json:"id"`
|
||||
Editor string `json:"editor"`
|
||||
Os string `json:"os"`
|
||||
Value string `json:"value"`
|
||||
Version string `json:"version"` // currently not implemented
|
||||
IsBrowserExtension bool `json:"is_browser_extension"` // currently not implemented
|
||||
IsDesktopApp bool `json:"is_desktop_app"` // currently not implemented
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
}
|
||||
|
||||
func (e *UserAgentEntry) FromModel(userAgent *models.UserAgent) *UserAgentEntry {
|
||||
e.Id = userAgent.Id
|
||||
e.Editor = userAgent.Editor
|
||||
e.Os = userAgent.Os
|
||||
e.Value = userAgent.Value
|
||||
e.FirstSeen = userAgent.FirstSeen.Format(time.RFC3339)
|
||||
e.LastSeen = userAgent.LastSeen.Format(time.RFC3339)
|
||||
return e
|
||||
}
|
||||
|
||||
21
models/user_agent.go
Normal file
21
models/user_agent.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
type UserAgent struct {
|
||||
Id string `json:"id"`
|
||||
Value string `json:"value"`
|
||||
Os string `json:"os"`
|
||||
Editor string `json:"editor"`
|
||||
FirstSeen time.Time `gorm:"column:first_seen"`
|
||||
LastSeen time.Time `gorm:"column:last_seen"`
|
||||
}
|
||||
|
||||
func (ua *UserAgent) WithId() *UserAgent {
|
||||
ua.Id, _ = utils.UUIDFromSeed(ua.Value)
|
||||
return ua
|
||||
}
|
||||
@@ -354,3 +354,17 @@ func (r *HeartbeatRepository) GetUserProjectStats(user *models.User, from, to ti
|
||||
|
||||
return projectStats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetUserAgentsByUser(user *models.User) ([]*models.UserAgent, error) {
|
||||
var results []*models.UserAgent
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("user_agent as value, operating_system as os, editor, min(time) as first_seen, max(time) as last_seen").
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Not("user_agent = ''").
|
||||
Group("user_agent, operating_system, editor").
|
||||
Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type IHeartbeatRepository interface {
|
||||
DeleteByUser(*models.User) error
|
||||
DeleteByUserBefore(*models.User, time.Time) error
|
||||
GetUserProjectStats(*models.User, time.Time, time.Time, int, int) ([]*models.ProjectStats, error)
|
||||
GetUserAgentsByUser(user *models.User) ([]*models.UserAgent, error)
|
||||
}
|
||||
|
||||
type IDurationRepository interface {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/muety/wakapi/helpers"
|
||||
"github.com/rs/cors"
|
||||
|
||||
"net/http"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
customMiddleware "github.com/muety/wakapi/middlewares/custom"
|
||||
@@ -13,7 +15,6 @@ import (
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
75
routes/compat/wakatime/v1/user_agents.go
Normal file
75
routes/compat/wakatime/v1/user_agents.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
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"
|
||||
)
|
||||
|
||||
type UserAgentsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
heartbeatsSrvc services.IHeartbeatService
|
||||
}
|
||||
|
||||
func NewUserAgentsHandler(userService services.IUserService, heartbeatsService services.IHeartbeatService) *UserAgentsHandler {
|
||||
return &UserAgentsHandler{
|
||||
userSrvc: userService,
|
||||
heartbeatsSrvc: heartbeatsService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserAgentsHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler)
|
||||
r.Get("/compat/wakatime/v1/users/{user}/user_agents", h.Get)
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary List of unique user agents for given user.
|
||||
// @Description Mimics https://wakatime.com/developers#user_agents
|
||||
// @ID get-wakatime-useragents
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} v1.UserAgentsViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/user_agents [get]
|
||||
func (h *UserAgentsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
userAgents, err := h.heartbeatsSrvc.GetUserAgentsByUser(user)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("failed to get user agents for user", "user", user.ID, "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("something went wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
vm := h.buildViewModel(userAgents)
|
||||
helpers.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *UserAgentsHandler) buildViewModel(userAgents []*models.UserAgent) *v1.UserAgentsViewModel {
|
||||
vm := &v1.UserAgentsViewModel{
|
||||
Data: make([]*v1.UserAgentEntry, len(userAgents)),
|
||||
TotalPages: 1,
|
||||
}
|
||||
|
||||
for i, ua := range userAgents {
|
||||
vm.Data[i] = (&v1.UserAgentEntry{}).FromModel(ua)
|
||||
}
|
||||
|
||||
return vm
|
||||
}
|
||||
@@ -2,6 +2,11 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
@@ -9,10 +14,6 @@ import (
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
@@ -266,6 +267,18 @@ func (srv *HeartbeatService) GetUserProjectStats(user *models.User, from, to tim
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetUserAgentsByUser returns a list of all user agents that have been recorded for the given user.
|
||||
func (srv *HeartbeatService) GetUserAgentsByUser(user *models.User) ([]*models.UserAgent, error) {
|
||||
userAgents, err := srv.repository.GetUserAgentsByUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ua := range userAgents {
|
||||
ua.WithId()
|
||||
}
|
||||
return userAgents, nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/api/v1/users/current/machine_names
|
||||
|
||||
@@ -51,6 +51,7 @@ type IHeartbeatService interface {
|
||||
DeleteByUser(*models.User) error
|
||||
DeleteByUserBefore(*models.User, time.Time) error
|
||||
GetUserProjectStats(*models.User, time.Time, time.Time, *utils.PageParams, bool) ([]*models.ProjectStats, error)
|
||||
GetUserAgentsByUser(*models.User) ([]*models.UserAgent, error)
|
||||
}
|
||||
|
||||
type IDiagnosticsService interface {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/duke-git/lancet/v2/condition"
|
||||
"github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/mileusna/useragent"
|
||||
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -21,12 +22,12 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
userAgent *regexp.Regexp
|
||||
userAgentRe *regexp.Regexp
|
||||
cacheMaxAgeRe *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
userAgent = regexp.MustCompile(userAgentPattern)
|
||||
userAgentRe = regexp.MustCompile(userAgentPattern)
|
||||
cacheMaxAgeRe = regexp.MustCompile(cacheMaxAgePattern)
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ func ParseUserAgent(ua string) (string, string, error) { // os, editor, err
|
||||
osAllCaps bool
|
||||
)
|
||||
|
||||
if groups := userAgent.FindAllStringSubmatch(ua, -1); len(groups) > 0 && len(groups[0]) == 4 {
|
||||
if groups := userAgentRe.FindAllStringSubmatch(ua, -1); len(groups) > 0 && len(groups[0]) == 4 {
|
||||
// extract os
|
||||
os = groups[0][1]
|
||||
if os == "win" {
|
||||
|
||||
30
utils/random.go
Normal file
30
utils/random.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math/rand"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func RandFromSeedString(seed string) (*rand.Rand, error) {
|
||||
hash := fnv.New64a()
|
||||
if _, err := io.WriteString(hash, seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rand.New(rand.NewSource(int64(hash.Sum64()))), nil
|
||||
}
|
||||
|
||||
func UUIDFromSeed(seed string) (string, error) {
|
||||
rng, err := RandFromSeedString(seed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gen := uuid.NewGenWithOptions(uuid.WithRandomReader(rng))
|
||||
id, err := gen.NewV4()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user