feat(compat): implement wakatime user agents endpoint (resolve #833)

This commit is contained in:
Ferdinand Mütsch
2025-09-05 14:06:32 +02:00
parent 57ddbfbd8b
commit eb169695d7
13 changed files with 202 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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