mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
288 lines
11 KiB
Go
288 lines
11 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fotbal-club/internal/models"
|
|
"fotbal-club/internal/config"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ErrorController struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
func NewErrorController(db *gorm.DB) *ErrorController { return &ErrorController{DB: db} }
|
|
|
|
type ingestPayload struct {
|
|
Origin string `json:"origin"`
|
|
Language string `json:"language"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Stack string `json:"stack"`
|
|
Component string `json:"component"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Column int `json:"column"`
|
|
URL string `json:"url"`
|
|
Method string `json:"method"`
|
|
Status int `json:"status"`
|
|
RequestID string `json:"request_id"`
|
|
UserID *uint `json:"user_id"`
|
|
SessionToken string `json:"session_token"`
|
|
Tags map[string]string `json:"tags"`
|
|
Context map[string]interface{} `json:"context"`
|
|
Env string `json:"env"`
|
|
Version string `json:"version"`
|
|
Hostname string `json:"hostname"`
|
|
OccurredAt *time.Time `json:"occurred_at"`
|
|
}
|
|
|
|
func (ec *ErrorController) Ingest(c *gin.Context) {
|
|
var p ingestPayload
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
var s models.Settings
|
|
_ = ec.DB.First(&s).Error
|
|
if p.Tags == nil { p.Tags = map[string]string{} }
|
|
if strings.TrimSpace(p.Origin) != "" {
|
|
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = strings.TrimSpace(p.Origin) }
|
|
} else {
|
|
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = "backend" }
|
|
}
|
|
if _, ok := p.Tags["instance_env"]; !ok {
|
|
if config.AppConfig != nil { p.Tags["instance_env"] = strings.TrimSpace(config.AppConfig.AppEnv) }
|
|
}
|
|
if p.Hostname == "" {
|
|
host := c.Request.Host
|
|
if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] }
|
|
p.Hostname = host
|
|
}
|
|
if _, ok := p.Tags["instance_host"]; !ok && strings.TrimSpace(p.Hostname) != "" {
|
|
p.Tags["instance_host"] = strings.TrimSpace(p.Hostname)
|
|
}
|
|
if v := strings.TrimSpace(s.ClubName); v != "" { p.Tags["club_name"] = v }
|
|
if v := strings.TrimSpace(s.ClubID); v != "" { p.Tags["club_id"] = v }
|
|
if v := strings.TrimSpace(s.ClubLogoURL); v != "" { p.Tags["club_logo_url"] = v }
|
|
if p.Env == "" && config.AppConfig != nil { p.Env = config.AppConfig.AppEnv }
|
|
if p.Version == "" {
|
|
v := strings.TrimSpace(os.Getenv("APP_VERSION"))
|
|
if v != "" { p.Version = v }
|
|
}
|
|
if _, ok := p.Tags["container_id"]; !ok {
|
|
if h, err := os.Hostname(); err == nil {
|
|
if hv := strings.TrimSpace(h); hv != "" { p.Tags["container_id"] = hv }
|
|
}
|
|
}
|
|
var tags datatypes.JSON
|
|
var ctx datatypes.JSON
|
|
if p.Tags != nil {
|
|
if b, err := json.Marshal(p.Tags); err == nil { tags = datatypes.JSON(b) }
|
|
}
|
|
if p.Context != nil {
|
|
if b, err := json.Marshal(p.Context); err == nil { ctx = datatypes.JSON(b) }
|
|
}
|
|
occurred := time.Now()
|
|
if p.OccurredAt != nil && !p.OccurredAt.IsZero() {
|
|
occurred = *p.OccurredAt
|
|
}
|
|
rec := models.ErrorEvent{
|
|
Origin: p.Origin,
|
|
Language: p.Language,
|
|
Severity: p.Severity,
|
|
Message: p.Message,
|
|
Stack: p.Stack,
|
|
Component: p.Component,
|
|
File: p.File,
|
|
Line: p.Line,
|
|
Column: p.Column,
|
|
URL: p.URL,
|
|
Method: p.Method,
|
|
Status: p.Status,
|
|
RequestID: p.RequestID,
|
|
UserID: p.UserID,
|
|
SessionToken: p.SessionToken,
|
|
Tags: tags,
|
|
Context: ctx,
|
|
Env: p.Env,
|
|
Version: p.Version,
|
|
Hostname: p.Hostname,
|
|
OccurredAt: occurred,
|
|
}
|
|
if err := ec.DB.Create(&rec).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store"})
|
|
return
|
|
}
|
|
go func(p ingestPayload) {
|
|
var s models.Settings
|
|
_ = ec.DB.First(&s).Error
|
|
// Prefer environment (config) for ingest URL and token; domain managed solely via .env
|
|
extURL := strings.TrimSpace(config.AppConfig.ErrorIngestURL)
|
|
token := strings.TrimSpace(config.AppConfig.ErrorIngestToken)
|
|
if extURL == "" {
|
|
// Default ingest URL: local when ERROR_LOCAL, otherwise external
|
|
errorLocal := false
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
|
|
if !errorLocal {
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
|
|
}
|
|
if errorLocal { extURL = "http://127.0.0.1:8083/api/v1/errors" } else { extURL = "https://errors.tdvorak.dev/api/v1/errors" }
|
|
}
|
|
if extURL == "" { return }
|
|
b, err := json.Marshal(p)
|
|
if err != nil { return }
|
|
post := func(u string) bool {
|
|
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
|
|
if err != nil { return false }
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("X-Ingest-Token", token)
|
|
}
|
|
client := &http.Client{ Timeout: 4 * time.Second }
|
|
resp, err := client.Do(req)
|
|
if err != nil { return false }
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
return resp.StatusCode >= 200 && resp.StatusCode < 300
|
|
}
|
|
if post(extURL) { return }
|
|
if u, err := url.Parse(extURL); err == nil {
|
|
h := u.Hostname()
|
|
if h == "127.0.0.1" || h == "localhost" {
|
|
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
|
|
_ = post(u.String())
|
|
}
|
|
}
|
|
}(p)
|
|
c.JSON(http.StatusCreated, gin.H{"id": rec.ID})
|
|
}
|
|
|
|
type listResponse struct {
|
|
Items []models.ErrorEvent `json:"items"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
func (ec *ErrorController) AdminList(c *gin.Context) {
|
|
q := ec.DB.Model(&models.ErrorEvent{})
|
|
if v := strings.TrimSpace(c.Query("origin")); v != "" { q = q.Where("origin = ?", v) }
|
|
if v := strings.TrimSpace(c.Query("severity")); v != "" { q = q.Where("severity = ?", v) }
|
|
if v := strings.TrimSpace(c.Query("method")); v != "" { q = q.Where("method = ?", v) }
|
|
if v := c.Query("status"); v != "" { q = q.Where("status = ?", v) }
|
|
if v := strings.TrimSpace(c.Query("search")); v != "" {
|
|
like := "%" + v + "%"
|
|
q = q.Where("message ILIKE ? OR stack ILIKE ? OR url ILIKE ?", like, like, like)
|
|
}
|
|
if v := c.Query("from"); v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at >= ?", t) }
|
|
}
|
|
if v := c.Query("to"); v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at <= ?", t) }
|
|
}
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
if page < 1 { page = 1 }
|
|
if limit < 1 || limit > 200 { limit = 20 }
|
|
var total int64
|
|
_ = q.Count(&total).Error
|
|
var items []models.ErrorEvent
|
|
_ = q.Order("occurred_at DESC").Limit(limit).Offset((page-1)*limit).Find(&items).Error
|
|
c.JSON(http.StatusOK, listResponse{Items: items, Total: total})
|
|
}
|
|
|
|
func (ec *ErrorController) AdminGet(c *gin.Context) {
|
|
var rec models.ErrorEvent
|
|
if err := ec.DB.First(&rec, c.Param("id")).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, rec)
|
|
}
|
|
|
|
// AdminListExternal proxies list to external error-review admin API
|
|
func (ec *ErrorController) AdminListExternal(c *gin.Context) {
|
|
var s models.Settings
|
|
_ = ec.DB.First(&s).Error
|
|
// Domain managed via environment only (with default); token from env
|
|
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
|
|
if base == "" {
|
|
errorLocal := false
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
|
|
if !errorLocal {
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
|
|
}
|
|
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
|
|
}
|
|
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
|
u, err := url.Parse(base)
|
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/errors"
|
|
u.RawQuery = c.Request.URL.RawQuery
|
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("X-Admin-Token", token)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
client := &http.Client{ Timeout: 8 * time.Second }
|
|
resp, err := client.Do(req)
|
|
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
|
|
defer resp.Body.Close()
|
|
c.Status(resp.StatusCode)
|
|
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
// AdminGetExternal proxies detail to external error-review admin API
|
|
func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
|
|
var s models.Settings
|
|
_ = ec.DB.First(&s).Error
|
|
// Domain managed via environment only (with default); token from env
|
|
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
|
|
if base == "" {
|
|
errorLocal := false
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
|
|
if !errorLocal {
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
|
|
}
|
|
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
|
|
}
|
|
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
|
u, err := url.Parse(base)
|
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/errors/" + id
|
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("X-Admin-Token", token)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
client := &http.Client{ Timeout: 8 * time.Second }
|
|
resp, err := client.Do(req)
|
|
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
|
|
defer resp.Body.Close()
|
|
c.Status(resp.StatusCode)
|
|
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|