tracking prototype
This commit is contained in:
BIN
data/tracking.db
Normal file
BIN
data/tracking.db
Normal file
Binary file not shown.
164
docs/tracking-api.md
Normal file
164
docs/tracking-api.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Tracking API Documentation
|
||||||
|
|
||||||
|
The tracking service provides analytics about endpoint usage with time-based filtering.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
In production, all tracking endpoints require Bearer token authentication:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your-api-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /api/tracking/stats
|
||||||
|
|
||||||
|
Get endpoint statistics with optional time period filtering.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `period` (optional): `day`, `week`, `month`, `year`, `all` (default: `all`)
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Get all-time stats
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats
|
||||||
|
|
||||||
|
# Get last week's stats
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats?period=week
|
||||||
|
|
||||||
|
# Get last 30 days
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats?period=month
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"endpoint": "/",
|
||||||
|
"method": "GET",
|
||||||
|
"hit_count": 245,
|
||||||
|
"unique_users": 89,
|
||||||
|
"last_access": "2025-08-13T10:30:00Z",
|
||||||
|
"first_access": "2025-08-06T14:22:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"period": "week",
|
||||||
|
"total_endpoints": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/tracking/summary
|
||||||
|
|
||||||
|
Get overall tracking summary for a time period.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `period` (optional): `day`, `week`, `month`, `year`, `all` (default: `all`)
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Get today's summary
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/summary?period=day
|
||||||
|
|
||||||
|
# Get yearly summary
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/summary?period=year
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"period": "day",
|
||||||
|
"total_hits": 1247,
|
||||||
|
"total_endpoints": 12,
|
||||||
|
"total_unique_users": 234,
|
||||||
|
"oldest_visit": "2025-08-13T00:15:00Z",
|
||||||
|
"newest_visit": "2025-08-13T23:45:00Z",
|
||||||
|
"tracking_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/tracking/trends
|
||||||
|
|
||||||
|
Get visit trends over time with configurable granularity.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `granularity` (optional): `hour`, `day`, `week`, `month` (default: `day`)
|
||||||
|
- `period` (optional): `24h`, `7d`, `30d`, `1y` (default: `7d`)
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Hourly trends for last 24 hours
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=hour&period=24h
|
||||||
|
|
||||||
|
# Daily trends for last 30 days
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=day&period=30d
|
||||||
|
|
||||||
|
# Weekly trends for last year
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=week&period=1y
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trends": [
|
||||||
|
{
|
||||||
|
"time_bucket": "2025-08-13",
|
||||||
|
"hits": 567,
|
||||||
|
"unique_users": 123,
|
||||||
|
"unique_endpoints": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time_bucket": "2025-08-12",
|
||||||
|
"hits": 445,
|
||||||
|
"unique_users": 98,
|
||||||
|
"unique_endpoints": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"granularity": "day",
|
||||||
|
"period": "7d"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/tracking/visits
|
||||||
|
|
||||||
|
Get recent individual visits with optional filtering.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `limit` (optional): Number of visits to return (default: `50`)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/visits?limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"visits": [
|
||||||
|
{
|
||||||
|
"user_hash": "a1b2c3d4e5f6...",
|
||||||
|
"endpoint": "/boxes",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"referer": "https://google.com",
|
||||||
|
"timestamp": "2025-08-13T15:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of This Approach
|
||||||
|
|
||||||
|
1. **No Duplication**: Single source of truth in `user_visits` table
|
||||||
|
2. **Flexible Querying**: Any time period can be queried on-demand
|
||||||
|
3. **Rich Analytics**: Detailed trends and user behavior insights
|
||||||
|
4. **Efficient Storage**: Only raw visits stored, stats calculated in real-time
|
||||||
|
5. **Historical Analysis**: Can analyze any historical period without pre-aggregation
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Indexes are created on `timestamp`, `endpoint`, and `user_hash` for fast queries
|
||||||
|
- For very high traffic, consider adding materialized views for common time periods
|
||||||
|
- The SQLite database handles thousands of requests efficiently with proper indexing
|
||||||
9
go.mod
9
go.mod
@@ -9,12 +9,21 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
golang.org/x/time v0.12.0 // indirect
|
golang.org/x/time v0.12.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
18
go.sum
18
go.sum
@@ -1,5 +1,9 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
@@ -8,8 +12,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
@@ -18,6 +26,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -29,3 +39,11 @@ golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
|||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
|||||||
19
src/main.go
19
src/main.go
@@ -50,8 +50,16 @@ var (
|
|||||||
func main() {
|
func main() {
|
||||||
e.Renderer = NewTemplates()
|
e.Renderer = NewTemplates()
|
||||||
|
|
||||||
|
// Initialize tracking service
|
||||||
|
trackingService, err := util.NewTrackingService("data/tracking.db")
|
||||||
|
if err != nil {
|
||||||
|
e.Logger.Fatal("Failed to initialize tracking service:", err)
|
||||||
|
}
|
||||||
|
defer trackingService.Close()
|
||||||
|
|
||||||
e.Logger.SetLevel(log.DEBUG)
|
e.Logger.SetLevel(log.DEBUG)
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(trackingService.TrackingMiddleware())
|
||||||
if util.IsProd() {
|
if util.IsProd() {
|
||||||
e.Use(middleware.Gzip())
|
e.Use(middleware.Gzip())
|
||||||
e.Use(middleware.HTTPSRedirect())
|
e.Use(middleware.HTTPSRedirect())
|
||||||
@@ -81,6 +89,17 @@ func main() {
|
|||||||
e.GET("/", func(c echo.Context) error {
|
e.GET("/", func(c echo.Context) error {
|
||||||
return c.Render(200, "index", newPage(boxes.GetBoxes()))
|
return c.Render(200, "index", newPage(boxes.GetBoxes()))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tracking API endpoints (protected by bearer auth if needed)
|
||||||
|
api := e.Group("/api")
|
||||||
|
if util.IsProd() {
|
||||||
|
api.Use(util.BearerAuthMiddleware())
|
||||||
|
}
|
||||||
|
api.GET("/tracking/stats", trackingService.GetEndpointStats)
|
||||||
|
api.GET("/tracking/visits", trackingService.GetRecentVisits)
|
||||||
|
api.GET("/tracking/summary", trackingService.GetTrackingSummary)
|
||||||
|
api.GET("/tracking/trends", trackingService.GetTimeTrends)
|
||||||
|
|
||||||
e.GET("/boxes/ws", boxes.HandleBoxesWs)
|
e.GET("/boxes/ws", boxes.HandleBoxesWs)
|
||||||
|
|
||||||
e.GET("/draw", draw.Page)
|
e.GET("/draw", draw.Page)
|
||||||
|
|||||||
401
src/util/tracking.go
Normal file
401
src/util/tracking.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackingService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserVisit struct {
|
||||||
|
ID int `json:"id" db:"id"`
|
||||||
|
UserHash string `json:"user_hash" db:"user_hash"`
|
||||||
|
Endpoint string `json:"endpoint" db:"endpoint"`
|
||||||
|
Method string `json:"method" db:"method"`
|
||||||
|
IPAddress string `json:"ip_address" db:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||||
|
Referer string `json:"referer" db:"referer"`
|
||||||
|
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrackingService(dbPath string) (*TrackingService, error) {
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open tracking database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &TrackingService{db: db}
|
||||||
|
if err := service.initDB(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize tracking database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) initDB() error {
|
||||||
|
query := `
|
||||||
|
CREATE TABLE IF NOT EXISTS user_visits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_hash TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
referer TEXT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_hash ON user_visits(user_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoint_method ON user_visits(endpoint, method);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timestamp ON user_visits(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoint_timestamp ON user_visits(endpoint, timestamp);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := ts.db.Exec(query)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) generateUserHash(c echo.Context) string {
|
||||||
|
// Create a hash from IP + User-Agent for basic user identification
|
||||||
|
ip := c.RealIP()
|
||||||
|
userAgent := c.Request().UserAgent()
|
||||||
|
|
||||||
|
// Combine IP and User-Agent for a basic fingerprint
|
||||||
|
fingerprint := fmt.Sprintf("%s|%s", ip, userAgent)
|
||||||
|
|
||||||
|
// MD5 hash for privacy (not cryptographically secure but fine for tracking)
|
||||||
|
hash := md5.Sum([]byte(fingerprint))
|
||||||
|
return fmt.Sprintf("%x", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) shouldTrack(path string) bool {
|
||||||
|
// Skip tracking for certain endpoints
|
||||||
|
skipPrefixes := []string{
|
||||||
|
"/css/",
|
||||||
|
"/js/",
|
||||||
|
"/images/",
|
||||||
|
"/swagger/",
|
||||||
|
"/favicon",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range skipPrefixes {
|
||||||
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) TrackingMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Execute the request first
|
||||||
|
err := next(c)
|
||||||
|
|
||||||
|
// Only track successful requests and trackable paths
|
||||||
|
if c.Response().Status < 400 && ts.shouldTrack(c.Request().URL.Path) {
|
||||||
|
go ts.trackRequest(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) trackRequest(c echo.Context) {
|
||||||
|
endpoint := c.Request().URL.Path
|
||||||
|
method := c.Request().Method
|
||||||
|
userHash := ts.generateUserHash(c)
|
||||||
|
|
||||||
|
// Record the visit - this is all we need now
|
||||||
|
ts.recordVisit(userHash, endpoint, method, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) recordVisit(userHash, endpoint, method string, c echo.Context) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO user_visits (user_hash, endpoint, method, ip_address, user_agent, referer)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := ts.db.Exec(query,
|
||||||
|
userHash,
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
c.RealIP(),
|
||||||
|
c.Request().UserAgent(),
|
||||||
|
c.Request().Referer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEndpointStats returns statistics for all endpoints with time period support
|
||||||
|
func (ts *TrackingService) GetEndpointStats(c echo.Context) error {
|
||||||
|
period := c.QueryParam("period") // day, week, month, year, all
|
||||||
|
if period == "" {
|
||||||
|
period = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeCondition string
|
||||||
|
switch period {
|
||||||
|
case "day":
|
||||||
|
timeCondition = "AND timestamp >= datetime('now', '-1 day')"
|
||||||
|
case "week":
|
||||||
|
timeCondition = "AND timestamp >= datetime('now', '-7 days')"
|
||||||
|
case "month":
|
||||||
|
timeCondition = "AND timestamp >= datetime('now', '-1 month')"
|
||||||
|
case "year":
|
||||||
|
timeCondition = "AND timestamp >= datetime('now', '-1 year')"
|
||||||
|
default:
|
||||||
|
timeCondition = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
COUNT(*) as hit_count,
|
||||||
|
COUNT(DISTINCT user_hash) as unique_users,
|
||||||
|
MAX(timestamp) as last_access,
|
||||||
|
MIN(timestamp) as first_access
|
||||||
|
FROM user_visits
|
||||||
|
WHERE 1=1 %s
|
||||||
|
GROUP BY endpoint, method
|
||||||
|
ORDER BY hit_count DESC
|
||||||
|
`, timeCondition)
|
||||||
|
|
||||||
|
rows, err := ts.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to retrieve endpoint stats",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type PeriodStats struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
HitCount int `json:"hit_count"`
|
||||||
|
UniqueUsers int `json:"unique_users"`
|
||||||
|
LastAccess time.Time `json:"last_access"`
|
||||||
|
FirstAccess time.Time `json:"first_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats []PeriodStats
|
||||||
|
for rows.Next() {
|
||||||
|
var stat PeriodStats
|
||||||
|
err := rows.Scan(&stat.Endpoint, &stat.Method, &stat.HitCount,
|
||||||
|
&stat.UniqueUsers, &stat.LastAccess, &stat.FirstAccess)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats = append(stats, stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"stats": stats,
|
||||||
|
"period": period,
|
||||||
|
"total_endpoints": len(stats),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentVisits returns recent visits with optional filtering
|
||||||
|
func (ts *TrackingService) GetRecentVisits(c echo.Context) error {
|
||||||
|
limit := c.QueryParam("limit")
|
||||||
|
if limit == "" {
|
||||||
|
limit = "50"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT user_hash, endpoint, method, ip_address, user_agent, referer, timestamp
|
||||||
|
FROM user_visits
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := ts.db.Query(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to retrieve recent visits",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var visits []UserVisit
|
||||||
|
for rows.Next() {
|
||||||
|
var visit UserVisit
|
||||||
|
err := rows.Scan(&visit.UserHash, &visit.Endpoint, &visit.Method,
|
||||||
|
&visit.IPAddress, &visit.UserAgent, &visit.Referer, &visit.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visits = append(visits, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"visits": visits,
|
||||||
|
"count": len(visits),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackingSummary returns overall tracking summary with period support
|
||||||
|
func (ts *TrackingService) GetTrackingSummary(c echo.Context) error {
|
||||||
|
period := c.QueryParam("period")
|
||||||
|
if period == "" {
|
||||||
|
period = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeCondition string
|
||||||
|
switch period {
|
||||||
|
case "day":
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
|
||||||
|
case "week":
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-7 days')"
|
||||||
|
case "month":
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 month')"
|
||||||
|
case "year":
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 year')"
|
||||||
|
default:
|
||||||
|
timeCondition = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalHits, totalUniqueUsers int
|
||||||
|
var oldestVisit, newestVisit time.Time
|
||||||
|
|
||||||
|
// Get total hits for the period
|
||||||
|
query := fmt.Sprintf("SELECT COUNT(*) FROM user_visits %s", timeCondition)
|
||||||
|
err := ts.db.QueryRow(query).Scan(&totalHits)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to get summary stats",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique users for the period
|
||||||
|
query = fmt.Sprintf("SELECT COUNT(DISTINCT user_hash) FROM user_visits %s", timeCondition)
|
||||||
|
err = ts.db.QueryRow(query).Scan(&totalUniqueUsers)
|
||||||
|
if err != nil {
|
||||||
|
totalUniqueUsers = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get oldest and newest visits for the period
|
||||||
|
query = fmt.Sprintf("SELECT MIN(timestamp), MAX(timestamp) FROM user_visits %s", timeCondition)
|
||||||
|
ts.db.QueryRow(query).Scan(&oldestVisit, &newestVisit)
|
||||||
|
|
||||||
|
// Get unique endpoints for the period
|
||||||
|
query = fmt.Sprintf("SELECT COUNT(DISTINCT endpoint || method) FROM user_visits %s", timeCondition)
|
||||||
|
var totalEndpoints int
|
||||||
|
ts.db.QueryRow(query).Scan(&totalEndpoints)
|
||||||
|
|
||||||
|
summary := map[string]interface{}{
|
||||||
|
"period": period,
|
||||||
|
"total_hits": totalHits,
|
||||||
|
"total_endpoints": totalEndpoints,
|
||||||
|
"total_unique_users": totalUniqueUsers,
|
||||||
|
"oldest_visit": oldestVisit,
|
||||||
|
"newest_visit": newestVisit,
|
||||||
|
"tracking_active": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimeTrends returns visit trends over time
|
||||||
|
func (ts *TrackingService) GetTimeTrends(c echo.Context) error {
|
||||||
|
granularity := c.QueryParam("granularity") // hour, day, week, month
|
||||||
|
period := c.QueryParam("period") // last 24h, 7d, 30d, 1y
|
||||||
|
|
||||||
|
if granularity == "" {
|
||||||
|
granularity = "day"
|
||||||
|
}
|
||||||
|
if period == "" {
|
||||||
|
period = "7d"
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeFormat, timeCondition string
|
||||||
|
|
||||||
|
// Set time formatting based on granularity
|
||||||
|
switch granularity {
|
||||||
|
case "hour":
|
||||||
|
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
|
||||||
|
case "day":
|
||||||
|
timeFormat = "strftime('%Y-%m-%d', timestamp)"
|
||||||
|
case "week":
|
||||||
|
timeFormat = "strftime('%Y-W%W', timestamp)"
|
||||||
|
case "month":
|
||||||
|
timeFormat = "strftime('%Y-%m', timestamp)"
|
||||||
|
default:
|
||||||
|
timeFormat = "strftime('%Y-%m-%d', timestamp)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set time condition based on period
|
||||||
|
switch period {
|
||||||
|
case "24h":
|
||||||
|
timeCondition = "timestamp >= datetime('now', '-1 day')"
|
||||||
|
case "7d":
|
||||||
|
timeCondition = "timestamp >= datetime('now', '-7 days')"
|
||||||
|
case "30d":
|
||||||
|
timeCondition = "timestamp >= datetime('now', '-30 days')"
|
||||||
|
case "1y":
|
||||||
|
timeCondition = "timestamp >= datetime('now', '-1 year')"
|
||||||
|
default:
|
||||||
|
timeCondition = "timestamp >= datetime('now', '-7 days')"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
%s as time_bucket,
|
||||||
|
COUNT(*) as hits,
|
||||||
|
COUNT(DISTINCT user_hash) as unique_users,
|
||||||
|
COUNT(DISTINCT endpoint) as unique_endpoints
|
||||||
|
FROM user_visits
|
||||||
|
WHERE %s
|
||||||
|
GROUP BY time_bucket
|
||||||
|
ORDER BY time_bucket DESC
|
||||||
|
`, timeFormat, timeCondition)
|
||||||
|
|
||||||
|
rows, err := ts.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to retrieve time trends",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type TrendPoint struct {
|
||||||
|
TimeBucket string `json:"time_bucket"`
|
||||||
|
Hits int `json:"hits"`
|
||||||
|
UniqueUsers int `json:"unique_users"`
|
||||||
|
UniqueEndpoints int `json:"unique_endpoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var trends []TrendPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var trend TrendPoint
|
||||||
|
err := rows.Scan(&trend.TimeBucket, &trend.Hits, &trend.UniqueUsers, &trend.UniqueEndpoints)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trends = append(trends, trend)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"trends": trends,
|
||||||
|
"granularity": granularity,
|
||||||
|
"period": period,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) Close() error {
|
||||||
|
return ts.db.Close()
|
||||||
|
}
|
||||||
@@ -5,7 +5,11 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetBuildNumber() string {
|
func GetBuildNumber() string {
|
||||||
@@ -64,3 +68,37 @@ func CalculateAssetIntegrities() *AssetIntegrity {
|
|||||||
|
|
||||||
return integrity
|
return integrity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BearerAuthMiddleware provides simple bearer token authentication
|
||||||
|
func BearerAuthMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Get the authorization header
|
||||||
|
auth := c.Request().Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it starts with "Bearer "
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the token
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
|
||||||
|
// Get expected token from environment
|
||||||
|
expectedToken := os.Getenv("API_TOKEN")
|
||||||
|
if expectedToken == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Server configuration error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare tokens
|
||||||
|
if token != expectedToken {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user