Files
reforger_crawler_main/controllers/dependencyController.go
T
ilbinek 56d9e10621
Cross Compile Go / build (push) Canceled after 0s
admin dashboard, metrics and telemetry
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET
  via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts
- admin API: overview stats, indexing queue, addon management (priority,
  reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes,
  traffic, metrics explorer, services, events/audit log, system info
- built-in metrics: request latency per route, visitors, top addons and
  searches, indexer/scraper counters; minute buckets in the DB with hourly
  rollups and retention; optional Prometheus /metrics (METRICS_TOKEN)
- POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat)
  and a stdlib-only Go client in telemetry/
- auth hardening: empty secrets no longer allow access, constant-time
  compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting
- Dockerfile builds the UI and no longer bakes config.yaml into the image;
  CI builds the UI, runs tests and builds arm64 with CGO
2026-10-03 18:10:57 +02:00

175 lines
4.6 KiB
Go

package controllers
import (
"strconv"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SetAddonDependencies replaces all outgoing dependency edges for an addon.
// An empty dependency list is valid and clears the edges.
func SetAddonDependencies(c *gin.Context) {
var report models.DependencyReport
if err := c.ShouldBindJSON(&report); err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
if report.AddonID == "" {
c.JSON(400, gin.H{"error": "addonId is required"})
return
}
seen := make(map[string]bool)
edges := make([]models.AddonDependency, 0, len(report.Dependencies))
for _, dep := range report.Dependencies {
if dep.ID == "" || dep.ID == report.AddonID || seen[dep.ID] {
continue
}
seen[dep.ID] = true
edges = append(edges, models.AddonDependency{
AddonID: report.AddonID,
DependencyID: dep.ID,
Version: dep.Version,
DependencyName: dep.Name,
})
}
err := initializers.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("addon_id = ?", report.AddonID).Delete(&models.AddonDependency{}).Error; err != nil {
return err
}
if len(edges) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(&edges, 100).Error
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
metrics.DependencyReports.Inc()
metrics.DependencyEdges.Add(float64(len(edges)))
c.JSON(200, gin.H{"status": "success", "stored": len(edges)})
}
// GetDependents returns the addons that depend on the given addon.
func GetDependents(c *gin.Context) {
addonID := c.Param("id")
var addon models.Addon
if err := initializers.DB.Where("id = ?", addonID).First(&addon).Error; err != nil {
c.JSON(404, gin.H{"error": "Addon not found"})
return
}
limit := parseLimit(c.Query("limit"))
offset := parseOffset(c.Query("offset"))
dependents, total, err := QueryRelatedAddons(addonID, "ad.addon_id", "ad.dependency_id", limit, offset)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, models.DependentsResponse{
AddonID: addonID,
Dependents: dependents,
Total: total,
Limit: limit,
Offset: offset,
})
}
// GetDependencies returns the addons that the given addon depends on.
func GetDependencies(c *gin.Context) {
addonID := c.Param("id")
var addon models.Addon
if err := initializers.DB.Where("id = ?", addonID).First(&addon).Error; err != nil {
c.JSON(404, gin.H{"error": "Addon not found"})
return
}
limit := parseLimit(c.Query("limit"))
offset := parseOffset(c.Query("offset"))
dependencies, total, err := QueryRelatedAddons(addonID, "ad.dependency_id", "ad.addon_id", limit, offset)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, models.DependenciesResponse{
AddonID: addonID,
Dependencies: dependencies,
Total: total,
Limit: limit,
Offset: offset,
})
}
// QueryRelatedAddons returns one page of addons linked to addonID through the
// addon_dependencies edge table, plus the total number of matches.
//
// joinCol is the edge column joined to addons.id; filterCol is the edge column
// matched against addonID. Both are caller-supplied constants, never user input.
//
// The addons.id tiebreaker in the ORDER BY is required: subscriber_count alone
// is not a unique ordering, so without it paging could repeat or skip rows.
func QueryRelatedAddons(addonID, joinCol, filterCol string, limit, offset int) ([]models.AddonSearchResult, int, error) {
base := func() *gorm.DB {
return initializers.DB.Model(&models.Addon{}).
Joins("JOIN addon_dependencies ad ON "+joinCol+" = addons.id").
Where(filterCol+" = ?", addonID)
}
var total int64
if err := base().Count(&total).Error; err != nil {
return nil, 0, err
}
results := []models.AddonSearchResult{}
err := base().
Select("addons.id, addons.name, addons.type, addons.summary, addons.preview, addons.subscriber_count, addons.current_version_number, addons.author").
Order("addons.subscriber_count DESC, addons.id ASC").
Limit(limit).
Offset(offset).
Find(&results).Error
if err != nil {
return nil, 0, err
}
return results, int(total), nil
}
func parseLimit(raw string) int {
limit := 100
if raw != "" {
if val, err := strconv.Atoi(raw); err == nil && val > 0 {
limit = val
}
}
if limit > 500 {
limit = 500
}
return limit
}
func parseOffset(raw string) int {
if raw == "" {
return 0
}
val, err := strconv.Atoi(raw)
if err != nil || val < 0 {
return 0
}
return val
}