mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
456 lines
14 KiB
Go
456 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
"fotbal-club/internal/config"
|
|
"fotbal-club/pkg/logger"
|
|
)
|
|
|
|
type UmamiService struct {
|
|
baseURL string
|
|
username string
|
|
password string
|
|
token string
|
|
tokenExp time.Time
|
|
lastVerified time.Time
|
|
defaultWebsiteID string
|
|
}
|
|
|
|
// --- Helpers for website discovery/creation and event sending ---
|
|
|
|
// UmamiWebsite represents a single website entry as returned by the list API
|
|
type UmamiWebsite struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
}
|
|
|
|
// umamiListWebsitesResponse matches the shape of GET /api/websites
|
|
type umamiListWebsitesResponse struct {
|
|
Data []UmamiWebsite `json:"data"`
|
|
Count int `json:"count"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
}
|
|
|
|
var ErrUmamiNoWebsites = errors.New("no websites found in Umami")
|
|
|
|
// FindWebsiteIDByDomain returns the website ID for a given domain if it exists.
|
|
func (u *UmamiService) FindWebsiteIDByDomain(domain string) (string, error) {
|
|
if err := u.authenticate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/websites?page=1&pageSize=100&orderBy=name&query=%s", u.baseURL, domain)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create list websites request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to send list websites request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var list umamiListWebsitesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
|
return "", fmt.Errorf("failed to decode list websites response: %w", err)
|
|
}
|
|
|
|
for _, w := range list.Data {
|
|
if w.Domain == domain {
|
|
return w.ID, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// GetDefaultWebsiteID returns the first available website ID from Umami
|
|
func (u *UmamiService) GetDefaultWebsiteID() (string, error) {
|
|
logger.Info("Attempting to get default Umami website ID from %s", u.baseURL)
|
|
|
|
if err := u.authenticate(); err != nil {
|
|
return "", fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/websites?page=1&pageSize=1&orderBy=name", u.baseURL)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create list websites request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to send list websites request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var list umamiListWebsitesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
|
return "", fmt.Errorf("failed to decode list websites response: %w", err)
|
|
}
|
|
|
|
if len(list.Data) == 0 {
|
|
logger.Warn("No websites found in Umami instance at %s. Please create a website first.", u.baseURL)
|
|
return "", ErrUmamiNoWebsites
|
|
}
|
|
|
|
u.defaultWebsiteID = list.Data[0].ID
|
|
logger.Info("Found default Umami website: ID=%s, Name=%s, Domain=%s", list.Data[0].ID, list.Data[0].Name, list.Data[0].Domain)
|
|
return list.Data[0].ID, nil
|
|
}
|
|
|
|
// EnsureWebsite returns an existing website ID for the domain or creates a new one.
|
|
func (u *UmamiService) EnsureWebsite(name, domain string) (string, error) {
|
|
if domain == "" {
|
|
return "", fmt.Errorf("domain is required")
|
|
}
|
|
|
|
if err := u.authenticate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if id, err := u.FindWebsiteIDByDomain(domain); err == nil && id != "" {
|
|
return id, nil
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return u.CreateWebsite(name, domain)
|
|
}
|
|
|
|
// SendEvent posts a custom event to Umami's public /api/send endpoint (no auth required).
|
|
// hostname is a label for the source (e.g., "email" or your site host).
|
|
func (u *UmamiService) SendEvent(websiteID, name, urlPath, title string, data map[string]interface{}, hostname string) error {
|
|
if u.baseURL == "" || websiteID == "" {
|
|
return fmt.Errorf("umami baseURL and websiteID are required")
|
|
}
|
|
payload := map[string]interface{}{
|
|
"payload": map[string]interface{}{
|
|
"hostname": hostname,
|
|
"language": "",
|
|
"referrer": "",
|
|
"screen": "",
|
|
"title": title,
|
|
"url": urlPath,
|
|
"website": websiteID,
|
|
"name": name,
|
|
"data": data,
|
|
},
|
|
"type": "event",
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal send event payload: %w", err)
|
|
}
|
|
req, err := http.NewRequest("POST", u.baseURL+"/api/send", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create send event request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "fotbal-club/server")
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send umami event: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("umami send event status %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type UmamiAuthRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type UmamiAuthResponse struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type UmamiCreateWebsiteRequest struct {
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
}
|
|
|
|
type UmamiWebsiteResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// NewUmamiService creates a new Umami service instance
|
|
func NewUmamiService() *UmamiService {
|
|
return &UmamiService{
|
|
baseURL: config.AppConfig.UmamiURL,
|
|
username: config.AppConfig.UmamiUsername,
|
|
password: config.AppConfig.UmamiPassword,
|
|
}
|
|
}
|
|
|
|
// verifyToken checks if the current token is still valid using /api/auth/verify
|
|
func (u *UmamiService) verifyToken() bool {
|
|
if u.token == "" {
|
|
return false
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/verify", nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Token is valid if we get 200 OK
|
|
if resp.StatusCode == http.StatusOK {
|
|
u.lastVerified = time.Now()
|
|
logger.Info("Umami token verified successfully")
|
|
return true
|
|
}
|
|
|
|
logger.Info("Umami token verification failed with status %d", resp.StatusCode)
|
|
return false
|
|
}
|
|
|
|
// authenticate gets a JWT token from Umami
|
|
func (u *UmamiService) authenticate() error {
|
|
if u.baseURL == "" || u.username == "" || u.password == "" {
|
|
return fmt.Errorf("umami credentials not configured")
|
|
}
|
|
|
|
// Check if we have a token and it hasn't expired
|
|
if u.token != "" && time.Now().Before(u.tokenExp) {
|
|
// Verify token twice daily (every 12 hours)
|
|
if time.Since(u.lastVerified) < 12*time.Hour {
|
|
return nil
|
|
}
|
|
// Time to verify - check if token is still valid
|
|
if u.verifyToken() {
|
|
return nil
|
|
}
|
|
// Token invalid, will re-authenticate below
|
|
logger.Info("Token expired or invalid, re-authenticating...")
|
|
}
|
|
|
|
authReq := UmamiAuthRequest{
|
|
Username: u.username,
|
|
Password: u.password,
|
|
}
|
|
|
|
body, err := json.Marshal(authReq)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal auth request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/login", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send auth request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var authResp UmamiAuthResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
|
return fmt.Errorf("failed to decode auth response: %w", err)
|
|
}
|
|
|
|
u.token = authResp.Token
|
|
// Set token expiration to 23 hours from now (tokens typically last 24h)
|
|
u.tokenExp = time.Now().Add(23 * time.Hour)
|
|
u.lastVerified = time.Now()
|
|
|
|
logger.Info("Successfully authenticated with Umami at %s (new token issued, expires at %s)", u.baseURL, u.tokenExp.Format("2006-01-02 15:04:05"))
|
|
return nil
|
|
}
|
|
|
|
// CreateWebsite creates a new website in Umami and returns the website ID
|
|
func (u *UmamiService) CreateWebsite(name, domain string) (string, error) {
|
|
if err := u.authenticate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
createReq := UmamiCreateWebsiteRequest{
|
|
Name: name,
|
|
Domain: domain,
|
|
}
|
|
|
|
body, err := json.Marshal(createReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal create website request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", u.baseURL+"/api/websites", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create website request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to send create website request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("create website failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var websiteResp UmamiWebsiteResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&websiteResp); err != nil {
|
|
return "", fmt.Errorf("failed to decode website response: %w", err)
|
|
}
|
|
|
|
logger.Info("Successfully created Umami website: %s (ID: %s)", name, websiteResp.ID)
|
|
return websiteResp.ID, nil
|
|
}
|
|
|
|
// GetWebsiteStats retrieves analytics stats for a website
|
|
func (u *UmamiService) GetWebsiteStats(websiteID string, startAt, endAt int64) (map[string]interface{}, error) {
|
|
if err := u.authenticate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/websites/%s/stats?startAt=%d&endAt=%d", u.baseURL, websiteID, startAt, endAt)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create stats request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send stats request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get stats failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var stats map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
|
return nil, fmt.Errorf("failed to decode stats response: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// GetWebsiteMetrics retrieves metrics for a website
|
|
func (u *UmamiService) GetWebsiteMetrics(websiteID, type_ string, startAt, endAt int64) ([]map[string]interface{}, error) {
|
|
if err := u.authenticate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/websites/%s/metrics?type=%s&startAt=%d&endAt=%d", u.baseURL, websiteID, type_, startAt, endAt)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create metrics request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send metrics request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get metrics failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var metrics []map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&metrics); err != nil {
|
|
return nil, fmt.Errorf("failed to decode metrics response: %w", err)
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
// GetWebsitePageviews retrieves pageviews data over time with specified unit (hour, day, month, year)
|
|
func (u *UmamiService) GetWebsitePageviews(websiteID string, startAt, endAt int64, unit string) ([]map[string]interface{}, error) {
|
|
if err := u.authenticate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/api/websites/%s/pageviews?startAt=%d&endAt=%d&unit=%s", u.baseURL, websiteID, startAt, endAt, unit)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pageviews request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+u.token)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send pageviews request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get pageviews failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var pageviews []map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&pageviews); err != nil {
|
|
return nil, fmt.Errorf("failed to decode pageviews response: %w", err)
|
|
}
|
|
|
|
return pageviews, nil
|
|
}
|