package web import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/salvacybersec/keyhunter/pkg/dorks" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/recon" "github.com/salvacybersec/keyhunter/pkg/storage" "github.com/spf13/viper" ) // apiKey is the JSON-friendly representation of a finding with masked key value. type apiKey struct { ID int64 `json:"id"` ScanID int64 `json:"scanId,omitempty"` ProviderName string `json:"providerName"` KeyValue string `json:"keyValue"` KeyMasked string `json:"keyMasked"` Confidence string `json:"confidence"` SourcePath string `json:"sourcePath"` SourceType string `json:"sourceType"` LineNumber int `json:"lineNumber"` Verified bool `json:"verified"` VerifyStatus string `json:"verifyStatus,omitempty"` VerifyHTTPCode int `json:"verifyHttpCode,omitempty"` VerifyMetadata map[string]string `json:"verifyMetadata,omitempty"` CreatedAt string `json:"createdAt"` } // mountAPI registers all /api/v1/* routes on the given router. func (s *Server) mountAPI(r chi.Router) { r.Route("/api/v1", func(r chi.Router) { // Stats r.Get("/stats", s.handleAPIStats) // Keys r.Get("/keys", s.handleAPIListKeys) r.Get("/keys/{id}", s.handleAPIGetKey) r.Delete("/keys/{id}", s.handleAPIDeleteKey) // Providers r.Get("/providers", s.handleAPIListProviders) r.Get("/providers/{name}", s.handleAPIGetProvider) // Scan r.Post("/scan", s.handleAPIScan) r.Get("/scan/progress", s.handleSSEScanProgress) // Recon r.Post("/recon", s.handleAPIRecon) r.Get("/recon/progress", s.handleSSEReconProgress) // Dorks r.Get("/dorks", s.handleAPIListDorks) r.Post("/dorks", s.handleAPIAddDork) // Config r.Get("/config", s.handleAPIGetConfig) r.Put("/config", s.handleAPIUpdateConfig) }) } // --- Stats --- func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) { var totalKeys int if s.cfg.DB != nil { row := s.cfg.DB.SQL().QueryRow("SELECT COUNT(*) FROM findings") _ = row.Scan(&totalKeys) } totalProviders := 0 if s.cfg.Providers != nil { totalProviders = len(s.cfg.Providers.List()) } reconSources := 0 if s.cfg.ReconEngine != nil { reconSources = len(s.cfg.ReconEngine.List()) } writeJSON(w, http.StatusOK, map[string]interface{}{ "totalKeys": totalKeys, "totalProviders": totalProviders, "reconSources": reconSources, }) } // --- Keys --- func (s *Server) handleAPIListKeys(w http.ResponseWriter, r *http.Request) { if s.cfg.DB == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"}) return } q := r.URL.Query() f := storage.Filters{ Provider: q.Get("provider"), Limit: intParam(q.Get("limit"), 50), Offset: intParam(q.Get("offset"), 0), } if v := q.Get("verified"); v != "" { b := v == "true" || v == "1" f.Verified = &b } findings, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, f) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } keys := make([]apiKey, 0, len(findings)) for _, f := range findings { keys = append(keys, apiKey{ ID: f.ID, ScanID: f.ScanID, ProviderName: f.ProviderName, KeyValue: "", // always masked KeyMasked: f.KeyMasked, Confidence: f.Confidence, SourcePath: f.SourcePath, SourceType: f.SourceType, LineNumber: f.LineNumber, Verified: f.Verified, VerifyStatus: f.VerifyStatus, VerifyHTTPCode: f.VerifyHTTPCode, VerifyMetadata: f.VerifyMetadata, CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"), }) } writeJSON(w, http.StatusOK, keys) } func (s *Server) handleAPIGetKey(w http.ResponseWriter, r *http.Request) { if s.cfg.DB == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"}) return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } finding, err := s.cfg.DB.GetFinding(id, s.cfg.EncKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } // Return masked via apiKey struct writeJSON(w, http.StatusOK, apiKey{ ID: finding.ID, ScanID: finding.ScanID, ProviderName: finding.ProviderName, KeyValue: "", // always masked KeyMasked: finding.KeyMasked, Confidence: finding.Confidence, SourcePath: finding.SourcePath, SourceType: finding.SourceType, LineNumber: finding.LineNumber, Verified: finding.Verified, VerifyStatus: finding.VerifyStatus, VerifyHTTPCode: finding.VerifyHTTPCode, VerifyMetadata: finding.VerifyMetadata, CreatedAt: finding.CreatedAt.Format("2006-01-02T15:04:05Z"), }) } func (s *Server) handleAPIDeleteKey(w http.ResponseWriter, r *http.Request) { if s.cfg.DB == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"}) return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } n, err := s.cfg.DB.DeleteFinding(id) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if n == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } w.WriteHeader(http.StatusNoContent) } // --- Providers --- // apiProvider is the JSON-friendly representation of a provider. type apiProvider struct { Name string `json:"name"` DisplayName string `json:"displayName"` Tier int `json:"tier"` LastVerified string `json:"lastVerified,omitempty"` Keywords []string `json:"keywords"` } func toAPIProvider(p providers.Provider) apiProvider { return apiProvider{ Name: p.Name, DisplayName: p.DisplayName, Tier: p.Tier, LastVerified: p.LastVerified, Keywords: p.Keywords, } } func (s *Server) handleAPIListProviders(w http.ResponseWriter, r *http.Request) { if s.cfg.Providers == nil { writeJSON(w, http.StatusOK, []interface{}{}) return } list := s.cfg.Providers.List() out := make([]apiProvider, len(list)) for i, p := range list { out[i] = toAPIProvider(p) } writeJSON(w, http.StatusOK, out) } func (s *Server) handleAPIGetProvider(w http.ResponseWriter, r *http.Request) { if s.cfg.Providers == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } name := chi.URLParam(r, "name") p, ok := s.cfg.Providers.Get(name) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } writeJSON(w, http.StatusOK, toAPIProvider(p)) } // --- Scan --- type scanRequest struct { Path string `json:"path"` Verify bool `json:"verify"` Workers int `json:"workers"` } func (s *Server) handleAPIScan(w http.ResponseWriter, r *http.Request) { var req scanRequest if err := readJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } // Launch scan in background -- actual scan wiring happens when ScanEngine // is available. For now, return 202 to indicate the request was accepted. if s.cfg.ScanEngine != nil { go func() { // Background scan execution with SSE progress broadcasting. // Full wiring deferred to serve command integration. s.sse.Broadcast(SSEEvent{Type: "scan:started", Data: map[string]string{ "path": req.Path, }}) }() } writeJSON(w, http.StatusAccepted, map[string]string{ "status": "started", "message": "scan initiated", }) } // --- Recon --- type reconRequest struct { Query string `json:"query"` Sources []string `json:"sources"` Stealth bool `json:"stealth"` } func (s *Server) handleAPIRecon(w http.ResponseWriter, r *http.Request) { var req reconRequest if err := readJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if s.cfg.ReconEngine != nil { go func() { cfg := recon.Config{ Query: req.Query, EnabledSources: req.Sources, Stealth: req.Stealth, } s.sse.Broadcast(SSEEvent{Type: "recon:started", Data: map[string]string{ "query": req.Query, }}) findings, err := s.cfg.ReconEngine.SweepAll(context.Background(), cfg) if err != nil { s.sse.Broadcast(SSEEvent{Type: "recon:error", Data: map[string]string{ "error": err.Error(), }}) return } for _, f := range findings { s.sse.Broadcast(SSEEvent{Type: "recon:finding", Data: f}) } s.sse.Broadcast(SSEEvent{Type: "recon:complete", Data: map[string]int{ "total": len(findings), }}) }() } writeJSON(w, http.StatusAccepted, map[string]string{ "status": "started", "message": "recon initiated", }) } // --- Dorks --- // apiDork is the JSON-friendly representation of a dork. type apiDork struct { ID string `json:"id"` Name string `json:"name"` Source string `json:"source"` Category string `json:"category"` Query string `json:"query"` Description string `json:"description"` Tags []string `json:"tags,omitempty"` } func toAPIDork(d dorks.Dork) apiDork { return apiDork{ ID: d.ID, Name: d.Name, Source: d.Source, Category: d.Category, Query: d.Query, Description: d.Description, Tags: d.Tags, } } func (s *Server) handleAPIListDorks(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") var list []dorks.Dork if source != "" && s.cfg.Dorks != nil { list = s.cfg.Dorks.ListBySource(source) } else if s.cfg.Dorks != nil { list = s.cfg.Dorks.List() } out := make([]apiDork, len(list)) for i, d := range list { out[i] = toAPIDork(d) } writeJSON(w, http.StatusOK, out) } type addDorkRequest struct { DorkID string `json:"dorkId"` Name string `json:"name"` Source string `json:"source"` Category string `json:"category"` Query string `json:"query"` Description string `json:"description"` Tags []string `json:"tags"` } func (s *Server) handleAPIAddDork(w http.ResponseWriter, r *http.Request) { if s.cfg.DB == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"}) return } var req addDorkRequest if err := readJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } id, err := s.cfg.DB.SaveCustomDork(storage.CustomDork{ DorkID: req.DorkID, Name: req.Name, Source: req.Source, Category: req.Category, Query: req.Query, Description: req.Description, Tags: req.Tags, }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id}) } // --- Config --- func (s *Server) handleAPIGetConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, viper.AllSettings()) } func (s *Server) handleAPIUpdateConfig(w http.ResponseWriter, r *http.Request) { var settings map[string]interface{} if err := readJSON(r, &settings); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } for k, v := range settings { viper.Set(k, v) } // Attempt to persist; ignore error if no config file is set. _ = viper.WriteConfig() writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } // --- Helpers --- // writeJSON marshals v to JSON and writes it with the given HTTP status code. func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { // Best effort — headers already sent. fmt.Fprintf(w, `{"error":"encode: %s"}`, err) } } // readJSON decodes the request body into v. func readJSON(r *http.Request, v interface{}) error { if r.Body == nil { return fmt.Errorf("empty request body") } defer r.Body.Close() return json.NewDecoder(r.Body).Decode(v) } // intParam parses a query param as int, returning defaultVal on empty or error. func intParam(s string, defaultVal int) int { if s == "" { return defaultVal } v, err := strconv.Atoi(s) if err != nil || v < 0 { return defaultVal } return v } // itoa is a small helper for int64 to string conversion. func itoa(v int64) string { return strconv.FormatInt(v, 10) }