package server import ( "context" "fmt" "net/http" "os" "github.com/gofiber/adaptor/v2" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/favicon" "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/swagger" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/core" "github.com/j3ssie/osmedeus/v5/internal/database" "github.com/j3ssie/osmedeus/v5/internal/distributed" oslogger "github.com/j3ssie/osmedeus/v5/internal/logger" "github.com/j3ssie/osmedeus/v5/pkg/server/handlers" "github.com/j3ssie/osmedeus/v5/pkg/server/middleware" "github.com/j3ssie/osmedeus/v5/public" "go.uber.org/zap" _ "github.com/j3ssie/osmedeus/v5/docs/api-swagger" // swagger docs ) // Options contains server configuration options type Options struct { NoAuth bool // Disable authentication when true Master *distributed.Master // Master node for distributed mode (nil if not in master mode) Debug bool // Enable debug mode (log request bodies, detailed errors) } // cachedServerInfo holds server info read once at startup to avoid reading config on every request var cachedServerInfo struct { License string Version string Binary string Repo string Author string Docs string } // Server represents the web server type Server struct { app *fiber.App config *config.Config options *Options } // New creates a new server instance func New(cfg *config.Config, opts *Options) (*Server, error) { if opts == nil { opts = &Options{} } // Initialize database connection once at startup _, err := database.Connect(cfg) if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } // Run migrations once at startup ctx := context.Background() if err := database.Migrate(ctx); err != nil { return nil, fmt.Errorf("failed to run database migrations: %w", err) } oslogger.Get().Info("Database initialized", zap.String("engine", cfg.Database.DBEngine)) // Cache server info at startup (read once, not on every request) license := cfg.Server.License if license == "" { license = core.LICENSE // fallback to constant } cachedServerInfo.License = license cachedServerInfo.Version = core.VERSION cachedServerInfo.Binary = core.BINARY cachedServerInfo.Repo = core.REPO_URL cachedServerInfo.Author = core.AUTHOR cachedServerInfo.Docs = core.DOCS // Set cached info for handlers to use handlers.SetServerInfo(&handlers.ServerInfoData{ License: cachedServerInfo.License, Version: cachedServerInfo.Version, Binary: cachedServerInfo.Binary, Repo: cachedServerInfo.Repo, Author: cachedServerInfo.Author, Docs: cachedServerInfo.Docs, }) // Select error handler based on debug mode var errHandler fiber.ErrorHandler if opts.Debug { errHandler = middleware.DebugErrorHandler } else { errHandler = errorHandler } app := fiber.New(fiber.Config{ AppName: "Osmedeus API Server", ServerHeader: fmt.Sprintf("%s %s (%s)", core.BINARY, core.VERSION, license), ErrorHandler: errHandler, }) // Apply middleware app.Use(recover.New()) app.Use(logger.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", AllowHeaders: "Origin,Content-Type,Accept,Authorization", })) // Apply Prometheus metrics middleware app.Use(middleware.PrometheusMetrics()) // Apply debug middleware if debug mode is enabled if opts.Debug { app.Use(middleware.BodyReusable()) app.Use(middleware.DebugRequestBody()) app.Use(middleware.DebugResponseLogger()) } s := &Server{ app: app, config: cfg, options: opts, } // Setup routes s.setupRoutes() return s, nil } // setupRoutes configures all API routes func (s *Server) setupRoutes() { // Favicon from embedded filesystem s.app.Use(favicon.New(favicon.Config{ File: "favicon.ico", FileSystem: http.FS(public.EmbedFS), })) // Health checks s.app.Get("/health", handlers.HealthCheck) s.app.Get("/health/ready", handlers.ReadinessCheck) // Prometheus metrics endpoint s.app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) // Server info (JSON version info) s.app.Get("/server-info", handlers.ServerInfo(s.config)) s.app.Get("/status", handlers.ServerInfo(s.config)) // Alias for /server-info s.app.Get("/api/info", handlers.ServerInfo(s.config)) // Alternative endpoint for server info // Swagger documentation s.app.Get("/swagger/*", swagger.HandlerDefault) // API routes api := s.app.Group("/osm/api") // Login - always accessible api.Post("/login", handlers.Login(s.config)) // Apply auth middleware conditionally if s.config.Server.EnabledAuthAPI { api.Use(middleware.APIKeyAuth(s.config)) } else if !s.options.NoAuth { api.Use(middleware.JWTAuth(s.config)) } // Workflows api.Get("/workflows", handlers.ListWorkflowsVerbose(s.config)) api.Get("/workflows/tags", handlers.GetAllWorkflowTags(s.config)) api.Post("/workflows/refresh", handlers.RefreshWorkflowIndex(s.config)) api.Get("/workflows/:name", handlers.GetWorkflowVerbose(s.config)) // Runs api.Post("/runs", handlers.CreateRun(s.config)) api.Get("/runs", handlers.ListRuns(s.config)) api.Get("/runs/:id", handlers.GetRun(s.config)) api.Delete("/runs/:id", handlers.CancelRun(s.config)) api.Get("/runs/:id/steps", handlers.GetRunSteps) api.Get("/runs/:id/artifacts", handlers.GetRunArtifacts) // Jobs (group of runs from same request) api.Get("/jobs/:id", handlers.GetJobStatus(s.config)) // File uploads api.Post("/upload-file", handlers.UploadFile(s.config)) api.Post("/workflow-upload", handlers.UploadWorkflow(s.config)) // Snapshots (legacy endpoint for backward compatibility) api.Get("/snapshot-download/:workspace_name", handlers.SnapshotDownload(s.config)) // Snapshots (new endpoints) api.Get("/snapshots", handlers.ListSnapshots(s.config)) api.Post("/snapshots/export", handlers.SnapshotExport(s.config)) api.Post("/snapshots/import", handlers.SnapshotImport(s.config)) api.Delete("/snapshots/:name", handlers.DeleteSnapshot(s.config)) // Workspaces api.Get("/workspaces", handlers.ListWorkspaces(s.config)) api.Get("/workspace-names", handlers.ListWorkspaceNames(s.config)) // Artifacts api.Get("/artifacts", handlers.ListArtifacts(s.config)) api.Get("/artifacts/:workspace_name", handlers.DownloadWorkspaceArtifact(s.config)) // Assets api.Get("/assets", handlers.ListAssets(s.config)) // Vulnerabilities api.Get("/vulnerabilities", handlers.ListVulnerabilities(s.config)) api.Get("/vulnerabilities/summary", handlers.GetVulnerabilitySummary(s.config)) api.Get("/vulnerabilities/:id", handlers.GetVulnerability(s.config)) api.Post("/vulnerabilities", handlers.CreateVulnerability(s.config)) api.Delete("/vulnerabilities/:id", handlers.DeleteVulnerability(s.config)) // Stats api.Get("/stats", handlers.GetSystemStats(s.config)) // Install - registry info and installation endpoints api.Get("/registry-info", handlers.GetRegistryInfo(s.config)) api.Post("/registry-install", handlers.RegistryInstall(s.config)) // Schedules api.Get("/schedules", handlers.ListSchedules(s.config)) api.Post("/schedules", handlers.CreateSchedule(s.config)) api.Get("/schedules/:id", handlers.GetSchedule(s.config)) api.Put("/schedules/:id", handlers.UpdateSchedule(s.config)) api.Delete("/schedules/:id", handlers.DeleteSchedule(s.config)) api.Post("/schedules/:id/enable", handlers.EnableSchedule(s.config)) api.Post("/schedules/:id/disable", handlers.DisableSchedule(s.config)) api.Post("/schedules/:id/trigger", handlers.TriggerSchedule(s.config)) // Event logs api.Get("/event-logs", handlers.ListEventLogs(s.config)) // Functions api.Post("/functions/eval", handlers.FunctionEval(s.config)) api.Get("/functions/list", handlers.FunctionList(s.config)) // Settings API api.Get("/settings/yaml", handlers.GetSettingsYAML(s.config)) api.Get("/settings/yaml/", handlers.GetSettingsYAML(s.config)) // LLM endpoints (OpenAI-compatible) api.Post("/llm/v1/chat/completions", handlers.LLMChat(s.config)) api.Post("/llm/v1/embeddings", handlers.LLMEmbedding(s.config)) // Distributed endpoints (only available when running in master mode) if s.options.Master != nil { api.Get("/workers", handlers.ListWorkers(s.options.Master)) api.Get("/workers/:id", handlers.GetWorker(s.options.Master)) api.Get("/tasks", handlers.ListTasks(s.options.Master)) api.Get("/tasks/:id", handlers.GetTask(s.options.Master)) api.Post("/tasks", handlers.SubmitTask(s.options.Master)) } // Serve workspace files under /ws/{workspace_prefix_key}/ // Allows direct access to run outputs in workspaces directory (no auth required) if s.config.Server.WorkspacePrefixKey != "" && s.config.WorkspacesPath != "" { wsPath := fmt.Sprintf("/ws/%s", s.config.Server.WorkspacePrefixKey) s.app.Static(wsPath, s.config.WorkspacesPath, fiber.Static{ Browse: true, // Enable directory listing }) } // Serve UI at root / // Priority: external UI path > embedded UI if s.config.UIPath != "" { if _, err := os.Stat(s.config.UIPath); err == nil { s.app.Static("/", s.config.UIPath, fiber.Static{ Index: "index.html", }) } else { // Fallback to embedded UI if external path doesn't exist s.serveEmbeddedUI() } } else { // Use embedded UI when no external path configured s.serveEmbeddedUI() } } // serveEmbeddedUI serves the embedded UI files at root / func (s *Server) serveEmbeddedUI() { uiFS, err := public.GetUIFS() if err != nil { return } s.app.Use("/", filesystem.New(filesystem.Config{ Root: http.FS(uiFS), Index: "index.html", Browse: false, PathPrefix: "", })) } // Start starts the server func (s *Server) Start(addr string) error { return s.app.Listen(addr) } // Shutdown gracefully shuts down the server func (s *Server) Shutdown() error { return s.app.Shutdown() } // ShutdownWithContext gracefully shuts down the server with a context for timeout func (s *Server) ShutdownWithContext(ctx context.Context) error { return s.app.ShutdownWithContext(ctx) } // errorHandler handles errors globally func errorHandler(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError if e, ok := err.(*fiber.Error); ok { code = e.Code } return c.Status(code).JSON(fiber.Map{ "error": true, "message": err.Error(), }) }