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(`
Server is running. Build the frontend with cd server/web && npm install && npm run build
API: /api/v1/health
`)) }) } } else { r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`Server is running. No frontend configured.
API: /api/v1/health
`)) }) } return r }