tracking prototype
This commit is contained in:
19
src/main.go
19
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)
|
||||
|
||||
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"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user