Skip to content

Commit

Permalink
✨ feat: add enhanced analytics with user agent and geolocation tracking
Browse files Browse the repository at this point in the history
  • Loading branch information
watzon committed Nov 18, 2024
1 parent 866bc31 commit ce77336
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 6 deletions.
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ require (
gorm.io/gorm v1.25.12
)

require github.com/watzon/hdur v1.0.0
require (
github.com/fogleman/gg v1.3.0
github.com/mileusna/useragent v1.3.5
github.com/watzon/hdur v1.0.0
)

require (
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.22.0 // indirect
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
Expand Down
28 changes: 24 additions & 4 deletions internal/models/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package models
import (
"time"

"github.com/mileusna/useragent"
"github.com/watzon/0x45/internal/utils"
"gorm.io/gorm"
)

Expand All @@ -29,24 +31,42 @@ type AnalyticsEvent struct {
ResourceType string `gorm:"type:varchar(32);index;not null"` // "shortlink" or "paste"

// Request information
UserAgent string `gorm:"type:text"`
IPAddress string `gorm:"type:varchar(45)"` // IPv6 addresses can be up to 45 chars
RefererURL string `gorm:"type:text"`
CountryCode string `gorm:"type:varchar(2)"`
UserAgent string `gorm:"type:text"`
IPAddress string `gorm:"type:varchar(45)"` // IPv6 addresses can be up to 45 chars
RefererURL string `gorm:"type:text"`
Browser string `gorm:"type:varchar(32)"`
OS string `gorm:"type:varchar(32)"`
Device string `gorm:"type:varchar(32)"`

// Location information
City string `gorm:"type:varchar(255)"`
Region string `gorm:"type:varchar(255)"`
ZipCode string `gorm:"type:varchar(10)"`
Country string `gorm:"type:varchar(2)"`

// Additional data
Metadata JSON `gorm:"type:jsonb"`
}

// CreateEvent is a helper function to create a new analytics event
func CreateEvent(db *gorm.DB, eventType EventType, resourceType string, resourceID string, userAgent string, ipAddress string, refererURL string) error {
ua := useragent.Parse(userAgent)
locationInfo := utils.GetLocationInfo(ipAddress)

event := &AnalyticsEvent{
EventType: eventType,
ResourceType: resourceType,
ResourceID: resourceID,
UserAgent: userAgent,
IPAddress: ipAddress,
RefererURL: refererURL,
Browser: ua.Name,
OS: ua.OS,
Device: ua.Device,
City: locationInfo.City,
Region: locationInfo.Region,
ZipCode: locationInfo.ZipCode,
Country: locationInfo.Country,
}

return db.Create(event).Error
Expand Down
40 changes: 40 additions & 0 deletions internal/utils/geoip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package utils

import (
"encoding/json"
"net/http"
)

type LocationInfo struct {
City string `json:"city"`
Region string `json:"regionName"`
ZipCode string `json:"zip"`
Country string `json:"countryCode"`
}

// GetLocationInfo fetches the location info for an IP address using ip-api.com
func GetLocationInfo(ipAddress string) LocationInfo {
return GetLocationInfoWithClient(ipAddress, http.DefaultClient)
}

// GetLocationInfoWithClient fetches the location info using a custom HTTP client
func GetLocationInfoWithClient(ipAddress string, client *http.Client) LocationInfo {
resp, err := client.Get("http://ip-api.com/json/" + ipAddress)
if err != nil {
return LocationInfo{}
}
defer resp.Body.Close()

var result struct {
City string `json:"city"`
Region string `json:"regionName"`
ZipCode string `json:"zip"`
Country string `json:"countryCode"`
}

if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return LocationInfo{}
}

return result
}
94 changes: 94 additions & 0 deletions internal/utils/geoip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package utils

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

type mockTransport struct {
response string
}

func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(t.response)),
}, nil
}

func TestGetLocationInfo(t *testing.T) {
tests := []struct {
name string
ip string
mock string
expected LocationInfo
}{
{
name: "valid ip address",
ip: "136.36.156.245",
mock: `{"status":"success","country":"United States","countryCode":"US","region":"UT","regionName":"Utah","city":"Salt Lake City","zip":"84106","lat":40.6982,"lon":-111.841,"timezone":"America/Denver","isp":"Google Fiber Inc.","org":"Google Fiber Inc","as":"AS16591 Google Fiber Inc.","query":"136.36.156.245"}`,
expected: LocationInfo{
City: "Salt Lake City",
Region: "Utah",
ZipCode: "84106",
Country: "US",
},
},
{
name: "invalid ip address",
ip: "invalid",
mock: `{"status":"fail","message":"invalid query","query":"invalid"}`,
expected: LocationInfo{
City: "",
Region: "",
ZipCode: "",
Country: "",
},
},
{
name: "server error",
ip: "error",
mock: `{"error": "internal server error"}`,
expected: LocationInfo{
City: "",
Region: "",
ZipCode: "",
Country: "",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a custom client with our mock transport
client := &http.Client{
Transport: &mockTransport{response: tt.mock},
}

// Create a test server just to get a valid URL
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer server.Close()

// Call the function being tested
result := GetLocationInfoWithClient(tt.ip, client)

// Check the results
if result.City != tt.expected.City {
t.Errorf("City = %v, want %v", result.City, tt.expected.City)
}
if result.Region != tt.expected.Region {
t.Errorf("Region = %v, want %v", result.Region, tt.expected.Region)
}
if result.ZipCode != tt.expected.ZipCode {
t.Errorf("ZipCode = %v, want %v", result.ZipCode, tt.expected.ZipCode)
}
if result.Country != tt.expected.Country {
t.Errorf("Country = %v, want %v", result.Country, tt.expected.Country)
}
})
}
}

0 comments on commit ce77336

Please sign in to comment.