grafana
This commit is contained in:
2
.github/workflows/docker-build-self.yml
vendored
2
.github/workflows/docker-build-self.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
|||||||
docker run -d --name $IMAGE_NAME -p $PORT:54321 --restart=always \
|
docker run -d --name $IMAGE_NAME -p $PORT:54321 --restart=always \
|
||||||
-v "/volume2/docker/$IMAGE_NAME/data:/app/data" \
|
-v "/volume2/docker/$IMAGE_NAME/data:/app/data" \
|
||||||
-e BUILD_NUMBER=${{ github.run_number }} \
|
-e BUILD_NUMBER=${{ github.run_number }} \
|
||||||
-e API_TOKEN="${{ secrets.GRAFANA_API_KEY }}" \
|
-e API_TOKEN='${{ secrets.API_TOKEN }}' \
|
||||||
"$IMAGE_NAME:latest"
|
"$IMAGE_NAME:latest"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
|
|||||||
BIN
data/tracking.db
BIN
data/tracking.db
Binary file not shown.
218
docs/grafana-integration.md
Normal file
218
docs/grafana-integration.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Grafana Integration Guide
|
||||||
|
|
||||||
|
This guide shows how to visualize your tracking data with Grafana using the new API endpoints.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
In production, all tracking endpoints use **Basic Authentication** via Echo's built-in middleware:
|
||||||
|
- **Username:** `api` (or set via `API_USERNAME` environment variable)
|
||||||
|
- **Password:** Your API token (set via `API_TOKEN` environment variable)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl -u api:your-api-token http://your-server:54321/api/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
The authentication uses Echo's standard `middleware.BasicAuth` for reliability and security.
|
||||||
|
|
||||||
|
## API Structure for Grafana
|
||||||
|
|
||||||
|
### 1. Prometheus Metrics Endpoint
|
||||||
|
**URL:** `/api/metrics?period=24h`
|
||||||
|
|
||||||
|
Returns Prometheus-style metrics that Grafana can scrape:
|
||||||
|
```
|
||||||
|
# HELP http_requests_total Total HTTP requests
|
||||||
|
# TYPE http_requests_total counter
|
||||||
|
http_requests_total 1247
|
||||||
|
|
||||||
|
# HELP http_unique_users_total Unique users
|
||||||
|
# TYPE http_unique_users_total gauge
|
||||||
|
http_unique_users_total 234
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Time Series Data Endpoint
|
||||||
|
**URL:** `/api/tracking/timeseries?target=hits&interval=1h&from=2025-08-13&to=2025-08-14`
|
||||||
|
|
||||||
|
Returns data in Grafana's expected format:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"target": "hits",
|
||||||
|
"datapoints": [
|
||||||
|
[42, 1628856000000],
|
||||||
|
[38, 1628859600000],
|
||||||
|
[51, 1628863200000]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Grafana Query Endpoint (SimpleJSON)
|
||||||
|
**URL:** `/api/tracking/query` (POST)
|
||||||
|
|
||||||
|
Handles Grafana's native query format for more complex dashboards.
|
||||||
|
|
||||||
|
## Grafana Setup Options
|
||||||
|
|
||||||
|
### Option 1: Prometheus Data Source (Recommended)
|
||||||
|
|
||||||
|
1. **Add Prometheus Data Source** in Grafana
|
||||||
|
2. **Set URL** to: `http://your-server:54321` (note: no `/api/metrics` suffix!)
|
||||||
|
3. **Set Basic Auth**: Username: `api`, Password: `your-api-token`
|
||||||
|
4. **Set Scrape Interval**: 30s or 1m
|
||||||
|
|
||||||
|
Grafana will automatically query `/api/v1/query` and `/api/v1/query_range` endpoints.
|
||||||
|
|
||||||
|
**Dashboard Queries:**
|
||||||
|
```promql
|
||||||
|
# Total requests
|
||||||
|
http_requests_total
|
||||||
|
|
||||||
|
# Unique users
|
||||||
|
http_unique_users_total
|
||||||
|
|
||||||
|
# Unique endpoints
|
||||||
|
http_unique_endpoints_total
|
||||||
|
|
||||||
|
# Request rate (per minute)
|
||||||
|
rate(http_requests_total[5m]) * 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: JSON API Data Source
|
||||||
|
|
||||||
|
1. **Install** the [JSON API Grafana Plugin](https://grafana.com/grafana/plugins/marcusolsson-json-datasource/)
|
||||||
|
2. **Add JSON API Data Source**
|
||||||
|
3. **Set URL** to: `http://your-server:54321/api/tracking/timeseries`
|
||||||
|
4. **Set Basic Auth**: Username: `api`, Password: `your-api-token`
|
||||||
|
|
||||||
|
**Query Examples:**
|
||||||
|
- `hits` - Total page hits over time
|
||||||
|
- `users` - Unique users over time
|
||||||
|
- `endpoints` - Unique endpoints accessed
|
||||||
|
|
||||||
|
### Option 3: SimpleJSON Data Source (Advanced)
|
||||||
|
|
||||||
|
For complex dashboards with custom queries:
|
||||||
|
|
||||||
|
1. **Install** the [SimpleJSON Data Source](https://grafana.com/grafana/plugins/grafana-simple-json-datasource/)
|
||||||
|
2. **Set URL** to: `http://your-server:54321/api/tracking/query`
|
||||||
|
3. **Configure** custom metrics:
|
||||||
|
- `http_requests` - Request time series
|
||||||
|
- `unique_users` - User count time series
|
||||||
|
- `top_endpoints` - Top endpoints table
|
||||||
|
|
||||||
|
## Sample Dashboard Panels
|
||||||
|
|
||||||
|
### 1. Traffic Overview (Single Stat)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target": "hits",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Total Page Views (24h)",
|
||||||
|
"type": "singlestat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Traffic Trends (Graph)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target": "hits",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "users",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Traffic Over Time",
|
||||||
|
"type": "graph"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Top Endpoints (Table)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target": "top_endpoints",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Most Popular Pages",
|
||||||
|
"type": "table"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Set these for production:
|
||||||
|
```bash
|
||||||
|
export API_TOKEN="your-secure-api-token"
|
||||||
|
export API_USERNAME="api" # Optional, defaults to "api"
|
||||||
|
export BUILD_NUMBER="prod-v1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Grafana Dashboard JSON
|
||||||
|
|
||||||
|
Here's a complete dashboard configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Website Analytics",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "Page Views (24h)",
|
||||||
|
"type": "singlestat",
|
||||||
|
"targets": [{"target": "hits"}],
|
||||||
|
"gridPos": {"h": 8, "w": 6, "x": 0, "y": 0}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Unique Users (24h)",
|
||||||
|
"type": "singlestat",
|
||||||
|
"targets": [{"target": "users"}],
|
||||||
|
"gridPos": {"h": 8, "w": 6, "x": 6, "y": 0}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Traffic Trends",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{"target": "hits", "refId": "A"},
|
||||||
|
{"target": "users", "refId": "B"}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"refresh": "30s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of This Approach
|
||||||
|
|
||||||
|
1. **Multiple Grafana Data Sources** - Choose what works best for you
|
||||||
|
2. **Real-time Updates** - Data refreshes automatically
|
||||||
|
3. **Flexible Time Ranges** - Hour, day, week, month views
|
||||||
|
4. **Standard Formats** - Works with existing Grafana plugins
|
||||||
|
5. **Authentication** - Secured with Basic Auth in production
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Start your server: `./tmp/main`
|
||||||
|
2. Test metrics endpoint: `curl http://localhost:54321/api/metrics`
|
||||||
|
3. Add Prometheus data source in Grafana pointing to `/api/metrics`
|
||||||
|
4. Create dashboard with `http_requests_total` and `http_unique_users_total` queries
|
||||||
|
5. Set refresh interval to 30 seconds
|
||||||
|
|
||||||
|
Your analytics will now update in real-time in Grafana! 📊
|
||||||
53
src/main.go
53
src/main.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -90,15 +91,55 @@ func main() {
|
|||||||
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)
|
// Tracking API endpoints (protected by basic auth if needed)
|
||||||
api := e.Group("/api")
|
api := e.Group("/api")
|
||||||
if util.IsProd() {
|
if util.IsProd() {
|
||||||
api.Use(util.BearerAuthMiddleware())
|
api.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||||
|
// Get expected credentials from environment
|
||||||
|
expectedToken := os.Getenv("API_TOKEN")
|
||||||
|
if expectedToken == "" {
|
||||||
|
return false, nil // No token configured
|
||||||
}
|
}
|
||||||
api.GET("/tracking/stats", trackingService.GetEndpointStats)
|
|
||||||
api.GET("/tracking/visits", trackingService.GetRecentVisits)
|
// Check credentials
|
||||||
api.GET("/tracking/summary", trackingService.GetTrackingSummary)
|
return username == "api" && password == expectedToken, nil
|
||||||
api.GET("/tracking/trends", trackingService.GetTimeTrends)
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grafana-compatible endpoints
|
||||||
|
api.GET("/metrics", trackingService.GetMetrics)
|
||||||
|
api.GET("/tracking/timeseries", trackingService.GetTimeSeriesData)
|
||||||
|
api.POST("/tracking/query", trackingService.GetGrafanaQuery)
|
||||||
|
|
||||||
|
// Prometheus-compatible endpoints for Grafana
|
||||||
|
prometheusAPI := e.Group("/api/v1")
|
||||||
|
if util.IsProd() {
|
||||||
|
prometheusAPI.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||||
|
expectedToken := os.Getenv("API_TOKEN")
|
||||||
|
if expectedToken == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return username == "api" && password == expectedToken, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prometheus API endpoints that Grafana expects
|
||||||
|
prometheusAPI.GET("/status/buildinfo", func(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"version": "knet-tracking-1.0",
|
||||||
|
"revision": "main",
|
||||||
|
"branch": "main",
|
||||||
|
"buildUser": "knet",
|
||||||
|
"buildDate": "2025-08-15",
|
||||||
|
"goVersion": "go1.24.0",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
prometheusAPI.POST("/query", trackingService.GetPrometheusQuery)
|
||||||
|
prometheusAPI.POST("/query_range", trackingService.GetPrometheusQueryRange)
|
||||||
|
|
||||||
e.GET("/boxes/ws", boxes.HandleBoxesWs)
|
e.GET("/boxes/ws", boxes.HandleBoxesWs)
|
||||||
|
|
||||||
|
|||||||
@@ -139,263 +139,495 @@ func (ts *TrackingService) recordVisit(userHash, endpoint, method string, c echo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEndpointStats returns statistics for all endpoints with time period support
|
func (ts *TrackingService) Close() error {
|
||||||
func (ts *TrackingService) GetEndpointStats(c echo.Context) error {
|
return ts.db.Close()
|
||||||
period := c.QueryParam("period") // day, week, month, year, all
|
}
|
||||||
if period == "" {
|
|
||||||
period = "all"
|
// Prometheus-compatible endpoints for Grafana
|
||||||
|
|
||||||
|
// GetPrometheusQuery handles Prometheus /api/v1/query endpoint
|
||||||
|
func (ts *TrackingService) GetPrometheusQuery(c echo.Context) error {
|
||||||
|
query := c.FormValue("query")
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": "query parameter is required",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeCondition string
|
// Handle different metric queries
|
||||||
switch period {
|
var value float64
|
||||||
case "day":
|
var metricName string
|
||||||
timeCondition = "AND timestamp >= datetime('now', '-1 day')"
|
|
||||||
case "week":
|
switch query {
|
||||||
timeCondition = "AND timestamp >= datetime('now', '-7 days')"
|
case "http_requests_total":
|
||||||
case "month":
|
metricName = "http_requests_total"
|
||||||
timeCondition = "AND timestamp >= datetime('now', '-1 month')"
|
var count int
|
||||||
case "year":
|
ts.db.QueryRow("SELECT COUNT(*) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
|
||||||
timeCondition = "AND timestamp >= datetime('now', '-1 year')"
|
value = float64(count)
|
||||||
|
|
||||||
|
case "http_unique_users_total":
|
||||||
|
metricName = "http_unique_users_total"
|
||||||
|
var count int
|
||||||
|
ts.db.QueryRow("SELECT COUNT(DISTINCT user_hash) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
|
||||||
|
value = float64(count)
|
||||||
|
|
||||||
|
case "http_unique_endpoints_total":
|
||||||
|
metricName = "http_unique_endpoints_total"
|
||||||
|
var count int
|
||||||
|
ts.db.QueryRow("SELECT COUNT(DISTINCT endpoint) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
|
||||||
|
value = float64(count)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
timeCondition = ""
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"resultType": "vector",
|
||||||
|
"result": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
// Return Prometheus-style response
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"resultType": "vector",
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"metric": map[string]string{
|
||||||
|
"__name__": metricName,
|
||||||
|
},
|
||||||
|
"value": []interface{}{
|
||||||
|
time.Now().Unix(),
|
||||||
|
fmt.Sprintf("%.0f", value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrometheusQueryRange handles Prometheus /api/v1/query_range endpoint
|
||||||
|
func (ts *TrackingService) GetPrometheusQueryRange(c echo.Context) error {
|
||||||
|
query := c.FormValue("query")
|
||||||
|
start := c.FormValue("start") // Unix timestamp
|
||||||
|
end := c.FormValue("end") // Unix timestamp
|
||||||
|
step := c.FormValue("step") // Duration like "15s", "1m", "5m"
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": "query parameter is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse step duration (default to 1 minute)
|
||||||
|
stepSeconds := 60 // Default 1 minute
|
||||||
|
if step != "" {
|
||||||
|
if strings.HasSuffix(step, "s") {
|
||||||
|
if s, err := fmt.Sscanf(step, "%ds", &stepSeconds); s != 1 || err != nil {
|
||||||
|
stepSeconds = 60
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(step, "m") {
|
||||||
|
var minutes int
|
||||||
|
if s, err := fmt.Sscanf(step, "%dm", &minutes); s == 1 && err == nil {
|
||||||
|
stepSeconds = minutes * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build time condition
|
||||||
|
var timeCondition string
|
||||||
|
if start != "" && end != "" {
|
||||||
|
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN datetime(%s, 'unixepoch') AND datetime(%s, 'unixepoch')", start, end)
|
||||||
|
} else {
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine what metric to query
|
||||||
|
var sqlQuery string
|
||||||
|
var metricName string
|
||||||
|
|
||||||
|
switch query {
|
||||||
|
case "http_requests_total":
|
||||||
|
metricName = "http_requests_total"
|
||||||
|
sqlQuery = fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
endpoint,
|
strftime('%%s', strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)) as ts,
|
||||||
method,
|
COUNT(*) as value
|
||||||
COUNT(*) as hit_count,
|
|
||||||
COUNT(DISTINCT user_hash) as unique_users,
|
|
||||||
MAX(timestamp) as last_access,
|
|
||||||
MIN(timestamp) as first_access
|
|
||||||
FROM user_visits
|
FROM user_visits
|
||||||
WHERE 1=1 %s
|
%s
|
||||||
GROUP BY endpoint, method
|
GROUP BY strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)
|
||||||
ORDER BY hit_count DESC
|
ORDER BY ts
|
||||||
`, timeCondition)
|
`, timeCondition)
|
||||||
|
|
||||||
rows, err := ts.db.Query(query)
|
case "http_unique_users_total":
|
||||||
if err != nil {
|
metricName = "http_unique_users_total"
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
sqlQuery = fmt.Sprintf(`
|
||||||
"error": "Failed to retrieve endpoint stats",
|
SELECT
|
||||||
})
|
strftime('%%s', strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)) as ts,
|
||||||
}
|
COUNT(DISTINCT user_hash) as value
|
||||||
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
|
FROM user_visits
|
||||||
ORDER BY timestamp DESC
|
%s
|
||||||
LIMIT ?
|
GROUP BY strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)
|
||||||
`
|
ORDER BY ts
|
||||||
|
`, timeCondition)
|
||||||
|
|
||||||
rows, err := ts.db.Query(query, limit)
|
default:
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"resultType": "matrix",
|
||||||
|
"result": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ts.db.Query(sqlQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
||||||
"error": "Failed to retrieve recent visits",
|
"status": "error",
|
||||||
|
"error": "Failed to execute query",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var visits []UserVisit
|
var values [][]interface{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var visit UserVisit
|
var timestamp int64
|
||||||
err := rows.Scan(&visit.UserHash, &visit.Endpoint, &visit.Method,
|
var value int
|
||||||
&visit.IPAddress, &visit.UserAgent, &visit.Referer, &visit.Timestamp)
|
err := rows.Scan(×tamp, &value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
visits = append(visits, visit)
|
values = append(values, []interface{}{timestamp, fmt.Sprintf("%d", value)})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"visits": visits,
|
"status": "success",
|
||||||
"count": len(visits),
|
"data": map[string]interface{}{
|
||||||
|
"resultType": "matrix",
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"metric": map[string]string{
|
||||||
|
"__name__": metricName,
|
||||||
|
},
|
||||||
|
"values": values,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackingSummary returns overall tracking summary with period support
|
// Grafana-compatible endpoints below
|
||||||
func (ts *TrackingService) GetTrackingSummary(c echo.Context) error {
|
|
||||||
|
// GetMetrics returns Prometheus-style metrics for Grafana
|
||||||
|
func (ts *TrackingService) GetMetrics(c echo.Context) error {
|
||||||
period := c.QueryParam("period")
|
period := c.QueryParam("period")
|
||||||
if period == "" {
|
if period == "" {
|
||||||
period = "all"
|
period = "24h"
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeCondition string
|
var timeCondition string
|
||||||
switch period {
|
switch period {
|
||||||
case "day":
|
case "1h":
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 hour')"
|
||||||
|
case "24h":
|
||||||
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
|
||||||
case "week":
|
case "7d":
|
||||||
timeCondition = "WHERE timestamp >= datetime('now', '-7 days')"
|
timeCondition = "WHERE timestamp >= datetime('now', '-7 days')"
|
||||||
case "month":
|
case "30d":
|
||||||
timeCondition = "WHERE timestamp >= datetime('now', '-1 month')"
|
timeCondition = "WHERE timestamp >= datetime('now', '-30 days')"
|
||||||
case "year":
|
|
||||||
timeCondition = "WHERE timestamp >= datetime('now', '-1 year')"
|
|
||||||
default:
|
default:
|
||||||
timeCondition = ""
|
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalHits, totalUniqueUsers int
|
// Get basic metrics
|
||||||
var oldestVisit, newestVisit time.Time
|
var totalHits, uniqueUsers, uniqueEndpoints int
|
||||||
|
|
||||||
// Get total hits for the period
|
|
||||||
query := fmt.Sprintf("SELECT COUNT(*) FROM user_visits %s", timeCondition)
|
query := fmt.Sprintf("SELECT COUNT(*) FROM user_visits %s", timeCondition)
|
||||||
err := ts.db.QueryRow(query).Scan(&totalHits)
|
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)
|
query = fmt.Sprintf("SELECT COUNT(DISTINCT user_hash) FROM user_visits %s", timeCondition)
|
||||||
err = ts.db.QueryRow(query).Scan(&totalUniqueUsers)
|
ts.db.QueryRow(query).Scan(&uniqueUsers)
|
||||||
if err != nil {
|
|
||||||
totalUniqueUsers = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get oldest and newest visits for the period
|
query = fmt.Sprintf("SELECT COUNT(DISTINCT endpoint) FROM user_visits %s", timeCondition)
|
||||||
query = fmt.Sprintf("SELECT MIN(timestamp), MAX(timestamp) FROM user_visits %s", timeCondition)
|
ts.db.QueryRow(query).Scan(&uniqueEndpoints)
|
||||||
ts.db.QueryRow(query).Scan(&oldestVisit, &newestVisit)
|
|
||||||
|
|
||||||
// Get unique endpoints for the period
|
// Return Prometheus-style metrics
|
||||||
query = fmt.Sprintf("SELECT COUNT(DISTINCT endpoint || method) FROM user_visits %s", timeCondition)
|
response := fmt.Sprintf(`# HELP http_requests_total Total HTTP requests
|
||||||
var totalEndpoints int
|
# TYPE http_requests_total counter
|
||||||
ts.db.QueryRow(query).Scan(&totalEndpoints)
|
http_requests_total %d
|
||||||
|
|
||||||
summary := map[string]interface{}{
|
# HELP http_unique_users_total Unique users
|
||||||
"period": period,
|
# TYPE http_unique_users_total gauge
|
||||||
"total_hits": totalHits,
|
http_unique_users_total %d
|
||||||
"total_endpoints": totalEndpoints,
|
|
||||||
"total_unique_users": totalUniqueUsers,
|
|
||||||
"oldest_visit": oldestVisit,
|
|
||||||
"newest_visit": newestVisit,
|
|
||||||
"tracking_active": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, summary)
|
# HELP http_unique_endpoints_total Unique endpoints accessed
|
||||||
|
# TYPE http_unique_endpoints_total gauge
|
||||||
|
http_unique_endpoints_total %d
|
||||||
|
`, totalHits, uniqueUsers, uniqueEndpoints)
|
||||||
|
|
||||||
|
return c.String(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeTrends returns visit trends over time
|
// GetTimeSeriesData returns time-series data for Grafana charts
|
||||||
func (ts *TrackingService) GetTimeTrends(c echo.Context) error {
|
func (ts *TrackingService) GetTimeSeriesData(c echo.Context) error {
|
||||||
granularity := c.QueryParam("granularity") // hour, day, week, month
|
target := c.QueryParam("target") // What to measure: hits, users, endpoints
|
||||||
period := c.QueryParam("period") // last 24h, 7d, 30d, 1y
|
from := c.QueryParam("from") // Start time (unix timestamp or relative)
|
||||||
|
to := c.QueryParam("to") // End time
|
||||||
|
interval := c.QueryParam("interval") // Time interval: 1m, 5m, 1h, 1d
|
||||||
|
|
||||||
if granularity == "" {
|
if target == "" {
|
||||||
granularity = "day"
|
target = "hits"
|
||||||
}
|
}
|
||||||
if period == "" {
|
if interval == "" {
|
||||||
period = "7d"
|
interval = "1h"
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeFormat, timeCondition string
|
// Convert interval to SQLite format
|
||||||
|
var timeFormat string
|
||||||
// Set time formatting based on granularity
|
switch interval {
|
||||||
switch granularity {
|
case "1m":
|
||||||
case "hour":
|
timeFormat = "strftime('%Y-%m-%d %H:%M', timestamp)"
|
||||||
|
case "5m":
|
||||||
|
timeFormat = "strftime('%Y-%m-%d %H:%M', datetime(timestamp, 'unixepoch', 'start of minute', '+' || (strftime('%M', timestamp) / 5) * 5 || ' minutes'))"
|
||||||
|
case "1h":
|
||||||
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
|
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
|
||||||
case "day":
|
case "1d":
|
||||||
timeFormat = "strftime('%Y-%m-%d', timestamp)"
|
timeFormat = "strftime('%Y-%m-%d', timestamp)"
|
||||||
case "week":
|
|
||||||
timeFormat = "strftime('%Y-W%W', timestamp)"
|
|
||||||
case "month":
|
|
||||||
timeFormat = "strftime('%Y-%m', timestamp)"
|
|
||||||
default:
|
default:
|
||||||
timeFormat = "strftime('%Y-%m-%d', timestamp)"
|
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set time condition based on period
|
// Build time condition
|
||||||
switch period {
|
var timeCondition string
|
||||||
case "24h":
|
if from != "" && to != "" {
|
||||||
timeCondition = "timestamp >= datetime('now', '-1 day')"
|
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN datetime('%s') AND datetime('%s')", from, to)
|
||||||
case "7d":
|
} else {
|
||||||
timeCondition = "timestamp >= datetime('now', '-7 days')"
|
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
|
||||||
case "30d":
|
}
|
||||||
timeCondition = "timestamp >= datetime('now', '-30 days')"
|
|
||||||
case "1y":
|
// Build query based on target
|
||||||
timeCondition = "timestamp >= datetime('now', '-1 year')"
|
var selectClause string
|
||||||
|
switch target {
|
||||||
|
case "hits":
|
||||||
|
selectClause = "COUNT(*) as value"
|
||||||
|
case "users":
|
||||||
|
selectClause = "COUNT(DISTINCT user_hash) as value"
|
||||||
|
case "endpoints":
|
||||||
|
selectClause = "COUNT(DISTINCT endpoint) as value"
|
||||||
default:
|
default:
|
||||||
timeCondition = "timestamp >= datetime('now', '-7 days')"
|
selectClause = "COUNT(*) as value"
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
%s as time_bucket,
|
%s as time_bucket,
|
||||||
COUNT(*) as hits,
|
%s,
|
||||||
COUNT(DISTINCT user_hash) as unique_users,
|
strftime('%%s', %s) * 1000 as timestamp_ms
|
||||||
COUNT(DISTINCT endpoint) as unique_endpoints
|
|
||||||
FROM user_visits
|
FROM user_visits
|
||||||
WHERE %s
|
%s
|
||||||
GROUP BY time_bucket
|
GROUP BY time_bucket
|
||||||
ORDER BY time_bucket DESC
|
ORDER BY timestamp_ms ASC
|
||||||
`, timeFormat, timeCondition)
|
`, timeFormat, selectClause, timeFormat, timeCondition)
|
||||||
|
|
||||||
rows, err := ts.db.Query(query)
|
rows, err := ts.db.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
"error": "Failed to retrieve time trends",
|
"error": "Failed to retrieve time series data",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type TrendPoint struct {
|
type DataPoint struct {
|
||||||
TimeBucket string `json:"time_bucket"`
|
Target string `json:"target"`
|
||||||
Hits int `json:"hits"`
|
DataPoints [][2]interface{} `json:"datapoints"` // [value, timestamp_ms]
|
||||||
UniqueUsers int `json:"unique_users"`
|
|
||||||
UniqueEndpoints int `json:"unique_endpoints"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var trends []TrendPoint
|
var dataPoints [][2]interface{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var trend TrendPoint
|
var timeBucket string
|
||||||
err := rows.Scan(&trend.TimeBucket, &trend.Hits, &trend.UniqueUsers, &trend.UniqueEndpoints)
|
var value int
|
||||||
|
var timestampMs int64
|
||||||
|
|
||||||
|
err := rows.Scan(&timeBucket, &value, ×tampMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trends = append(trends, trend)
|
|
||||||
|
dataPoints = append(dataPoints, [2]interface{}{value, timestampMs})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
response := []DataPoint{
|
||||||
"trends": trends,
|
{
|
||||||
"granularity": granularity,
|
Target: target,
|
||||||
"period": period,
|
DataPoints: dataPoints,
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TrackingService) Close() error {
|
// GetGrafanaQuery handles Grafana's query format
|
||||||
return ts.db.Close()
|
func (ts *TrackingService) GetGrafanaQuery(c echo.Context) error {
|
||||||
|
// This endpoint mimics Grafana's SimpleJSON datasource format
|
||||||
|
var request struct {
|
||||||
|
PanelId int `json:"panelId"`
|
||||||
|
Range struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
} `json:"range"`
|
||||||
|
RangeRaw struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
} `json:"rangeRaw"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
IntervalMs int `json:"intervalMs"`
|
||||||
|
Targets []struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RefId string `json:"refId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"targets"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
MaxDataPoints int `json:"maxDataPoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Bind(&request); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "Invalid request format",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []interface{}
|
||||||
|
|
||||||
|
for _, target := range request.Targets {
|
||||||
|
// Parse the target to determine what to query
|
||||||
|
// Format: "metric_name.endpoint_filter.method_filter"
|
||||||
|
parts := strings.Split(target.Target, ".")
|
||||||
|
metricName := parts[0]
|
||||||
|
|
||||||
|
var timeCondition string
|
||||||
|
if request.Range.From != "" && request.Range.To != "" {
|
||||||
|
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN '%s' AND '%s'",
|
||||||
|
request.Range.From, request.Range.To)
|
||||||
|
} else {
|
||||||
|
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch metricName {
|
||||||
|
case "http_requests":
|
||||||
|
results = append(results, ts.getRequestsTimeSeries(timeCondition, target.RefId))
|
||||||
|
case "unique_users":
|
||||||
|
results = append(results, ts.getUniqueUsersTimeSeries(timeCondition, target.RefId))
|
||||||
|
case "top_endpoints":
|
||||||
|
results = append(results, ts.getTopEndpoints(timeCondition, target.RefId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for Grafana queries
|
||||||
|
func (ts *TrackingService) getRequestsTimeSeries(timeCondition, refId string) map[string]interface{} {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
strftime('%%Y-%%m-%%d %%H:00', timestamp) as hour,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
strftime('%%s', strftime('%%Y-%%m-%%d %%H:00', timestamp)) * 1000 as timestamp_ms
|
||||||
|
FROM user_visits
|
||||||
|
%s
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY timestamp_ms ASC
|
||||||
|
`, timeCondition)
|
||||||
|
|
||||||
|
rows, _ := ts.db.Query(query)
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dataPoints [][2]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var hour string
|
||||||
|
var requests int
|
||||||
|
var timestampMs int64
|
||||||
|
rows.Scan(&hour, &requests, ×tampMs)
|
||||||
|
dataPoints = append(dataPoints, [2]interface{}{requests, timestampMs})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"target": "HTTP Requests",
|
||||||
|
"refId": refId,
|
||||||
|
"datapoints": dataPoints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) getUniqueUsersTimeSeries(timeCondition, refId string) map[string]interface{} {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
strftime('%%Y-%%m-%%d %%H:00', timestamp) as hour,
|
||||||
|
COUNT(DISTINCT user_hash) as users,
|
||||||
|
strftime('%%s', strftime('%%Y-%%m-%%d %%H:00', timestamp)) * 1000 as timestamp_ms
|
||||||
|
FROM user_visits
|
||||||
|
%s
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY timestamp_ms ASC
|
||||||
|
`, timeCondition)
|
||||||
|
|
||||||
|
rows, _ := ts.db.Query(query)
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dataPoints [][2]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var hour string
|
||||||
|
var users int
|
||||||
|
var timestampMs int64
|
||||||
|
rows.Scan(&hour, &users, ×tampMs)
|
||||||
|
dataPoints = append(dataPoints, [2]interface{}{users, timestampMs})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"target": "Unique Users",
|
||||||
|
"refId": refId,
|
||||||
|
"datapoints": dataPoints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TrackingService) getTopEndpoints(timeCondition, refId string) map[string]interface{} {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
endpoint,
|
||||||
|
COUNT(*) as hits
|
||||||
|
FROM user_visits
|
||||||
|
%s
|
||||||
|
GROUP BY endpoint
|
||||||
|
ORDER BY hits DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, timeCondition)
|
||||||
|
|
||||||
|
rows, _ := ts.db.Query(query)
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type EndpointData struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Hits int `json:"hits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpoints []EndpointData
|
||||||
|
for rows.Next() {
|
||||||
|
var endpoint string
|
||||||
|
var hits int
|
||||||
|
rows.Scan(&endpoint, &hits)
|
||||||
|
endpoints = append(endpoints, EndpointData{Endpoint: endpoint, Hits: hits})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"target": "Top Endpoints",
|
||||||
|
"refId": refId,
|
||||||
|
"type": "table",
|
||||||
|
"columns": []map[string]string{
|
||||||
|
{"text": "Endpoint", "type": "string"},
|
||||||
|
{"text": "Hits", "type": "number"},
|
||||||
|
},
|
||||||
|
"rows": endpoints,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ 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 {
|
||||||
@@ -68,37 +64,3 @@ 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