258 lines
7.0 KiB
Go
258 lines
7.0 KiB
Go
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)
|
|
}
|