diff --git a/data/tracking.db b/data/tracking.db new file mode 100644 index 0000000..c8599cc Binary files /dev/null and b/data/tracking.db differ diff --git a/docs/tracking-api.md b/docs/tracking-api.md new file mode 100644 index 0000000..35a2c03 --- /dev/null +++ b/docs/tracking-api.md @@ -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 +``` + +## 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 diff --git a/go.mod b/go.mod index 858480d..ba855bd 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,21 @@ 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-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/fasttemplate v1.2.2 // 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/text v0.27.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 ) diff --git a/go.sum b/go.sum index 2d8c607..9579794 100644 --- a/go.sum +++ b/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/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/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 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-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/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/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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= 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/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/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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= diff --git a/src/main.go b/src/main.go index 4dae346..cb8d770 100644 --- a/src/main.go +++ b/src/main.go @@ -50,8 +50,16 @@ var ( func main() { 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.Use(middleware.Logger()) + e.Use(trackingService.TrackingMiddleware()) if util.IsProd() { e.Use(middleware.Gzip()) e.Use(middleware.HTTPSRedirect()) @@ -81,6 +89,17 @@ func main() { e.GET("/", func(c echo.Context) error { 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("/draw", draw.Page) diff --git a/src/util/tracking.go b/src/util/tracking.go new file mode 100644 index 0000000..fdddfae --- /dev/null +++ b/src/util/tracking.go @@ -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() +} diff --git a/src/util/util.go b/src/util/util.go index 6c22189..cd4053a 100644 --- a/src/util/util.go +++ b/src/util/util.go @@ -5,7 +5,11 @@ import ( "encoding/base64" "fmt" "io" + "net/http" "os" + "strings" + + "github.com/labstack/echo/v4" ) func GetBuildNumber() string { @@ -64,3 +68,37 @@ func CalculateAssetIntegrities() *AssetIntegrity { 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) + } + } +}