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) }