package web import ( "encoding/json" "fmt" "net/http" "strings" "time" "github.com/sartoopjj/thefeed/internal/client" ) func (s *Server) handleScannerPresets(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", 405) return } type preset struct { Name string `json:"name"` Label string `json:"label"` Count int `json:"count"` } writeJSON(w, map[string]any{ "presets": []preset{ {Name: "default", Label: "Default", Count: parseScannerPresetCount()}, }, }) } // parseScannerPresetLines returns the parsed non-empty, non-comment lines from the preset. func parseScannerPresetLines() []string { var lines []string for _, line := range strings.Split(defaultScannerPresets, "\n") { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") { lines = append(lines, line) } } return lines } func parseScannerPresetCount() int { return len(parseScannerPresetLines()) } func (s *Server) handleScannerStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Targets []string `json:"targets"` Preset string `json:"preset"` // e.g. "default" — server-side preset, avoids sending 50K IPs MaxIPs int `json:"maxIPs"` RateLimit int `json:"rateLimit"` Timeout float64 `json:"timeout"` ExpandSubnet bool `json:"expandSubnet"` QueryMode string `json:"queryMode"` ProfileID string `json:"profileId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } // Resolve preset into targets server-side. if req.Preset == "default" && len(req.Targets) == 0 { req.Targets = parseScannerPresetLines() } if len(req.Targets) == 0 { http.Error(w, "targets required", 400) return } // Resolve profile config. pl, err := s.loadProfiles() if err != nil || pl == nil { http.Error(w, "no profiles configured", 400) return } var profileCfg *Config if req.ProfileID != "" { for _, p := range pl.Profiles { if p.ID == req.ProfileID { profileCfg = &p.Config break } } } if profileCfg == nil { // Fall back to active profile. for _, p := range pl.Profiles { if p.ID == pl.Active { profileCfg = &p.Config break } } } if profileCfg == nil { http.Error(w, "no profile found", 400) return } if profileCfg.Domain == "" || profileCfg.Key == "" { http.Error(w, "profile missing domain or passphrase", 400) return } queryMode := req.QueryMode if queryMode == "" { queryMode = profileCfg.QueryMode } cfg := client.ScannerConfig{ Targets: req.Targets, MaxIPs: req.MaxIPs, RateLimit: req.RateLimit, Timeout: req.Timeout, ExpandSubnet: req.ExpandSubnet, QueryMode: queryMode, Domain: profileCfg.Domain, Passphrase: profileCfg.Key, } // Cancel any in-progress resolver checker scan to avoid resource // contention (both the checker and scanner do DNS probes). s.mu.RLock() checker := s.checker s.mu.RUnlock() if checker != nil { checker.CancelCurrentScan() } s.scanner.SetLogFunc(func(msg string) { s.addLog(msg) }) if err := s.scanner.Start(cfg); err != nil { http.Error(w, fmt.Sprintf("start scanner: %v", err), 400) return } writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleScannerStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } s.scanner.Stop() writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleScannerPause(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } s.scanner.Pause() writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleScannerResume(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } s.scanner.Resume() writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleScannerProgress(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", 405) return } prog := s.scanner.Progress() writeJSON(w, prog) } func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Resolvers []string `json:"resolvers"` Mode string `json:"mode"` // "append" or "overwrite" ProfileID string `json:"profileId"` // ListName picks which named active-list receives these // resolvers. Empty → currently-selected list, or "Default" // for legacy installs that haven't been migrated yet. ListName string `json:"listName,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } // If no resolvers explicitly provided, pull them from scanner results. resolvers := req.Resolvers if len(resolvers) == 0 { prog := s.scanner.Progress() for _, r := range prog.Results { resolvers = append(resolvers, r.IP) } } if len(resolvers) == 0 { http.Error(w, "no resolvers to apply", 400) return } // Make sure resolvers have :53 suffix. for i, r := range resolvers { if !strings.Contains(r, ":") { resolvers[i] = r + ":53" } } // Determine which profile to apply to (for logging purposes / active check). pl, _ := s.loadProfiles() if pl == nil { http.Error(w, "no profiles configured", 400) return } targetProfileID := req.ProfileID if targetProfileID == "" { targetProfileID = pl.Active } var targetIdx int = -1 for i, p := range pl.Profiles { if p.ID == targetProfileID { targetIdx = i break } } if targetIdx < 0 { http.Error(w, "profile not found", 400) return } // Update the shared resolver bank instead of per-profile resolvers. if req.Mode == "overwrite" { pl.ResolverBank = resolvers } else { // Append — deduplicate against existing bank. addToBank(pl, resolvers) } // Save under the requested list (or current selection / Default). listName := sanitizeListName(req.ListName) if listName == "" { listName = strings.TrimSpace(pl.SelectedList) } if listName == "" && len(pl.ActiveLists) > 0 { listName = pl.ActiveLists[0].Name } if listName == "" { listName = defaultListName } target := findList(pl, listName) if target == nil { pl.ActiveLists = append(pl.ActiveLists, ActiveList{Name: listName}) target = &pl.ActiveLists[len(pl.ActiveLists)-1] } if req.Mode == "overwrite" { target.Resolvers = append([]string(nil), resolvers...) } else { seen := map[string]bool{} for _, r := range target.Resolvers { seen[r] = true } for _, r := range resolvers { if !seen[r] { target.Resolvers = append(target.Resolvers, r) seen[r] = true } } } target.LastUsed = time.Now().Unix() pl.SelectedList = target.Name if err := s.saveProfiles(pl); err != nil { http.Error(w, fmt.Sprintf("save profiles: %v", err), 500) return } // If this is the active profile, re-init the fetcher with the updated bank. if targetProfileID == pl.Active { // Cancel any in-progress checker scan before re-initializing so the // old goroutine exits quickly and doesn't race with the new fetcher. s.mu.RLock() oldChecker := s.checker s.mu.RUnlock() if oldChecker != nil { oldChecker.CancelCurrentScan() } if err := s.initFetcher(); err != nil { http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500) return } // The scanner already verified these resolvers, so skip the initial // health-check scan — set them as active directly, start only the // periodic checker, and fetch metadata immediately. s.mu.RLock() fetcher := s.fetcher checker := s.checker ctx := s.fetcherCtx s.mu.RUnlock() if fetcher != nil { // Pool AND active are the full saved list, not just the // freshly-scanned subset — append mode merges the new // resolvers into pre-existing list entries, and the user // expects all of them live (otherwise the Active panel // shows N while the tab badge shows N+M, which looks // like the apply lost the old ones). if target != nil && len(target.Resolvers) > 0 { fetcher.UpdateResolverPool(target.Resolvers) fetcher.SetActiveResolvers(target.Resolvers) } else { fetcher.SetActiveResolvers(resolvers) } s.saveLastScan(resolvers) } if checker != nil && ctx != nil { checker.StartPeriodic(ctx) } go s.refreshMetadataOnly() } s.addLog(fmt.Sprintf("Scanner resolvers applied: %d resolvers (%s) to profile %s", len(resolvers), req.Mode, pl.Profiles[targetIdx].Nickname)) s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") writeJSON(w, map[string]any{"ok": true, "count": len(pl.ResolverBank)}) }