Pin React Three Fiber to React 18, remove PWA caching, add SessionGate and error boundaries, and document movie fusion, auth, outputs, and troubleshooting.
238 lines
7.3 KiB
Go
238 lines
7.3 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"crypto-miner-server/internal/builder"
|
|
"crypto-miner-server/internal/db"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
)
|
|
|
|
var (
|
|
authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads
|
|
usersFilePath string
|
|
usersMu sync.RWMutex
|
|
)
|
|
|
|
func loadUsers(dataDir string) {
|
|
usersFilePath = filepath.Join(dataDir, "users.json")
|
|
usersMu.Lock()
|
|
defer usersMu.Unlock()
|
|
data, err := os.ReadFile(usersFilePath)
|
|
if err == nil {
|
|
var loaded map[string]string
|
|
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
|
|
authUsers = loaded
|
|
}
|
|
}
|
|
}
|
|
|
|
func saveUser(username, password string) error {
|
|
usersMu.Lock()
|
|
defer usersMu.Unlock()
|
|
authUsers[username] = password
|
|
if usersFilePath == "" {
|
|
usersFilePath = filepath.Join("data", "users.json")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.MarshalIndent(authUsers, "", " ")
|
|
return os.WriteFile(usersFilePath, data, 0600)
|
|
}
|
|
|
|
func basicAuthMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodOptions {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
path := r.URL.Path
|
|
// Agent-facing API + health + forged worker downloads stay open for agents.
|
|
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
|
path == "/api/v1/health" ||
|
|
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
usersMu.RLock()
|
|
expectedPass, exists := authUsers[user]
|
|
usersMu.RUnlock()
|
|
|
|
if !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
|
loadUsers(dataDir)
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Middleware (global)
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
|
AllowCredentials: false,
|
|
}))
|
|
|
|
// REST API — auth only on /api/v1 (dashboard WS + static SPA stay open)
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
r.Use(basicAuthMiddleware)
|
|
h := NewHandler(database)
|
|
|
|
r.Get("/health", h.HealthCheck)
|
|
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
|
|
override := ""
|
|
if publicURLOverride != nil {
|
|
override = publicURLOverride()
|
|
}
|
|
GetServerInfo(w, r, override)
|
|
})
|
|
|
|
// Dashboard
|
|
r.Get("/dashboard/stats", h.GetDashboardStats)
|
|
|
|
// Agents
|
|
r.Get("/agents", h.ListAgents)
|
|
r.Get("/agents/{id}", h.GetAgent)
|
|
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
|
if fleetHandler != nil {
|
|
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
|
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
|
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
|
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
|
|
}
|
|
|
|
// Fleet ops
|
|
if fleetHandler != nil {
|
|
r.Get("/alerts", fleetHandler.GetAlerts)
|
|
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
|
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
|
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
|
}
|
|
|
|
// Shares
|
|
r.Get("/shares", h.GetRecentShares)
|
|
|
|
// Builds
|
|
r.Get("/builds", h.ListBuilds)
|
|
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
|
|
r.Get("/builds/{id}/artifact/{name}", builderHandler.DownloadBuildArtifact)
|
|
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)
|
|
|
|
// Config
|
|
r.Get("/config", configHandler.ServeHTTP)
|
|
r.Put("/config", configHandler.ServeHTTP)
|
|
|
|
// Builder
|
|
r.Post("/builder/build", builderHandler.ServeHTTP)
|
|
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
|
|
|
// Blueprints (config presets)
|
|
r.Get("/blueprints", blueprintHandler.ServeHTTP)
|
|
r.Post("/blueprints", blueprintHandler.ServeHTTP)
|
|
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
|
|
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
|
|
|
|
// User Management
|
|
r.Post("/users", func(w http.ResponseWriter, req *http.Request) {
|
|
var payload struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload.Username == "" || payload.Password == "" {
|
|
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := saveUser(payload.Username, payload.Password); err != nil {
|
|
http.Error(w, "Failed to save user", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]interface{}{"success": true})
|
|
})
|
|
|
|
// AI Autonomy (Ollama)
|
|
r.Post("/agent/decide", aiHandler.HandleDecide)
|
|
r.Post("/agent/report", aiHandler.HandleReport)
|
|
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
|
})
|
|
|
|
// WebSocket
|
|
r.Get("/ws/agent", wsHub.HandleAgentWS)
|
|
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
|
|
|
|
// Serve frontend SPA
|
|
if webRoot != "" {
|
|
// Check if webroot directory exists
|
|
if info, err := os.Stat(webRoot); err == nil && info.IsDir() {
|
|
// Create a file server for the webroot
|
|
fileServer := http.FileServer(http.Dir(webRoot))
|
|
|
|
// SPA fallback: serve index.html for all non-API, non-WebSocket routes
|
|
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
|
// Clean the path
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
fullPath := filepath.Join(webRoot, path)
|
|
|
|
// Check if the file exists
|
|
if _, err := os.Stat(fullPath); err == nil {
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// SPA fallback - serve index.html (never cache: hashed assets change each build)
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
|
|
})
|
|
} else {
|
|
// Fallback if webroot doesn't exist
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
|
|
<h1>Crypto Miner Control Server</h1>
|
|
<p>Server is running. Build the frontend with <code>cd server/web && npm install && npm run build</code></p>
|
|
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
|
|
</body></html>`))
|
|
})
|
|
}
|
|
} else {
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
|
|
<h1>Crypto Miner Control Server</h1>
|
|
<p>Server is running. No frontend configured.</p>
|
|
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
|
|
</body></html>`))
|
|
})
|
|
}
|
|
|
|
return r
|
|
}
|