mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
upload
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user