package api import ( "encoding/json" "fmt" "io" "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.quad4.io/quad4-software/osv-server/internal/config" "git.quad4.io/quad4-software/osv-server/internal/osv" "git.quad4.io/quad4-software/osv-server/internal/query" ) // Server represents the HTTP API server. type Server struct { router *chi.Mux osv *osv.Manager enableDownloadEndpoint bool silent bool } // NewServer creates a new API server instance. func NewServer(osvManager *osv.Manager, cfg *config.Config) *Server { s := &Server{ router: chi.NewRouter(), osv: osvManager, enableDownloadEndpoint: cfg.EnableDownloadEndpoint, silent: os.Getenv("OSV_SILENT") == "true", } s.setupRoutes() return s } func (s *Server) setupRoutes() { if !s.silent { s.router.Use(middleware.Logger) } s.router.Use(middleware.Recoverer) s.router.Use(middleware.RequestID) s.router.Get(RouteHealth, s.handleHealth) s.router.Get(RouteStats, s.handleStats) s.router.Get(RouteQuery, s.handleQuery) s.router.Post(RouteQuery, s.handleQueryPost) s.router.Get(RouteQueryBatch, s.handleQueryBatch) s.router.Post(RouteQueryBatch, s.handleQueryBatchPost) if s.enableDownloadEndpoint { s.router.Get(RouteDownload, s.handleDownload) } } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { status := StatusHealthy if !s.osv.DataExists() { status = StatusInitializing } w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusOK) // #nosec G104 - http.ResponseWriter.Write errors are rare and non-critical for health endpoint _, _ = w.Write([]byte(`{"status":"` + status + `"}`)) } func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { stats := s.osv.GetStats() w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusOK) json := `{ "last_update": "` + stats.LastUpdate.Format(time.RFC3339) + `", "last_update_error": "` + stats.LastUpdateError + `", "total_downloads": ` + fmt.Sprintf("%d", stats.TotalDownloads) + `, "failed_downloads": ` + fmt.Sprintf("%d", stats.FailedDownloads) + `, "update_in_progress": ` + fmt.Sprintf("%t", stats.UpdateInProgress) if stats.DownloadProgress != nil { json += `, "download_progress": { "percent": ` + fmt.Sprintf("%.2f", stats.DownloadProgress.Percent) + `, "speed": "` + stats.DownloadProgress.Speed + `", "downloaded": ` + fmt.Sprintf("%d", stats.DownloadProgress.Downloaded) + `, "total": ` + fmt.Sprintf("%d", stats.DownloadProgress.Total) + ` }` } json += ` }` // #nosec G104 - http.ResponseWriter.Write errors are rare and non-critical for stats endpoint _, _ = w.Write([]byte(json)) } func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { if !s.osv.DataExists() { s.writeDownloadingResponse(w) return } dataPath := s.osv.GetDataPath() http.ServeFile(w, r, dataPath) } func (s *Server) handleQueryPost(w http.ResponseWriter, r *http.Request) { if !s.osv.DataExists() { s.writeDownloadingResponse(w) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, `{"error":"Failed to read request body"}`, http.StatusBadRequest) return } var req query.QueryRequest if err := json.Unmarshal(body, &req); err != nil { http.Error(w, `{"error":"Invalid JSON request"}`, http.StatusBadRequest) return } idx := s.osv.GetIndexer() resp, err := query.QueryDatabase(idx, &req) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError) return } w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusOK) respJSON, err := json.Marshal(resp) if err != nil { http.Error(w, `{"error":"Failed to marshal response"}`, http.StatusInternalServerError) return } // #nosec G104 - http.ResponseWriter.Write errors are rare _, _ = w.Write(respJSON) } func (s *Server) handleQueryBatch(w http.ResponseWriter, r *http.Request) { if !s.osv.DataExists() { s.writeDownloadingResponse(w) return } dataPath := s.osv.GetDataPath() http.ServeFile(w, r, dataPath) } func (s *Server) handleQueryBatchPost(w http.ResponseWriter, r *http.Request) { if !s.osv.DataExists() { s.writeDownloadingResponse(w) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, `{"error":"Failed to read request body"}`, http.StatusBadRequest) return } var batchReq struct { Queries []query.QueryRequest `json:"queries"` } if err := json.Unmarshal(body, &batchReq); err != nil { http.Error(w, `{"error":"Invalid JSON request"}`, http.StatusBadRequest) return } idx := s.osv.GetIndexer() var results []query.QueryResponse for _, req := range batchReq.Queries { resp, err := query.QueryDatabase(idx, &req) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"Query failed: %s"}`, err.Error()), http.StatusInternalServerError) return } results = append(results, *resp) } w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusOK) response := struct { Results []query.QueryResponse `json:"results"` }{Results: results} respJSON, err := json.Marshal(response) if err != nil { http.Error(w, `{"error":"Failed to marshal response"}`, http.StatusInternalServerError) return } // #nosec G104 - http.ResponseWriter.Write errors are rare _, _ = w.Write(respJSON) } func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { if !s.osv.DataExists() { s.writeDownloadingResponse(w) return } stats := s.osv.GetStats() if stats.UpdateInProgress { w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusServiceUnavailable) // #nosec G104 - http.ResponseWriter.Write errors are rare _, _ = w.Write([]byte(`{"error":"Database update in progress, please try again later"}`)) return } dataPath := s.osv.GetDataPath() meta, err := s.osv.GetMetadata() if err == nil && meta != nil { w.Header().Set("X-OSV-Hash", meta.Hash) w.Header().Set("X-OSV-Last-Modified", meta.LastModified.Format(time.RFC3339)) w.Header().Set("X-OSV-Size", fmt.Sprintf("%d", meta.Size)) } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", "attachment; filename=osv-database.zip") http.ServeFile(w, r, dataPath) } func (s *Server) writeDownloadingResponse(w http.ResponseWriter) { stats := s.osv.GetStats() w.Header().Set("Content-Type", ContentTypeJSON) w.WriteHeader(http.StatusServiceUnavailable) var message string if stats.DownloadProgress != nil { message = fmt.Sprintf(`"message":"Initial database download in progress: %.2f%% (%s/s)"`, stats.DownloadProgress.Percent, stats.DownloadProgress.Speed) } else { message = `"message":"Initial database download in progress"` } response := fmt.Sprintf(`{"error":"Data not available yet",%s,"status":"downloading"}`, message) // #nosec G104 - http.ResponseWriter.Write errors are rare _, _ = w.Write([]byte(response)) } // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }