package handler import ( "context" "encoding/json" "fmt" "net/http" "os/exec" "regexp" "strings" "time" "github.com/anotherhadi/iknowyou/internal/respond" "github.com/anotherhadi/iknowyou/internal/tools" ) // ansiRe strips all ANSI/VT100 escape sequences (CSI, OSC, etc.). // RunWithPTY only strips OSC sequences; CSI colour codes need this. var ansiRe = regexp.MustCompile(`\x1b[\x5b-\x5f][0-9;]*[A-Za-z]|\x1b[^[\x5b-\x5f]`) type EnumerateHandler struct { demo bool } func NewEnumerateHandler(_ string, demo bool) *EnumerateHandler { return &EnumerateHandler{demo: demo} } // --- Generate --- type generateRequest struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Username string `json:"username"` } type emailEntry struct { Address string `json:"address"` Username string `json:"username"` Domain string `json:"domain"` } type generateResponse struct { Usernames []string `json:"usernames"` Emails []emailEntry `json:"emails"` } var emailDomains = []string{ "gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "protonmail.com", "proton.me", "icloud.com", "live.com", "msn.com", } var accentMap = map[rune]rune{ 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'à': 'a', 'â': 'a', 'ä': 'a', 'á': 'a', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ú': 'u', 'ô': 'o', 'ö': 'o', 'ó': 'o', 'î': 'i', 'ï': 'i', 'í': 'i', 'ç': 'c', 'ñ': 'n', 'ß': 's', } func normalizeName(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder for _, r := range s { if mapped, ok := accentMap[r]; ok { b.WriteRune(mapped) } else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { b.WriteRune(r) } else if r == ' ' || r == '_' || r == '.' { b.WriteRune('-') } } return strings.Trim(b.String(), "-") } func buildUsernames(first, last string) []string { f := strings.ReplaceAll(normalizeName(first), "-", "") l := strings.ReplaceAll(normalizeName(last), "-", "") if f == "" || l == "" { return nil } fi := string([]rune(f)[0]) li := string([]rune(l)[0]) patterns := []string{ f + l, l + f, fi + l, f + "." + l, l + "." + f, fi + "." + l, f + "_" + l, l + "_" + f, fi + "_" + l, f + li, f + "." + li, fi + li, } seen := map[string]bool{} var result []string for _, p := range patterns { // Skip patterns that are too short to be realistic usernames/emails. if len([]rune(p)) < 4 { continue } if p != "" && !seen[p] { seen[p] = true result = append(result, p) } } return result } // GET /api/enumerate/status type statusResponse struct { UserScannerAvailable bool `json:"user_scanner_available"` } func (h *EnumerateHandler) Status(w http.ResponseWriter, r *http.Request) { _, err := exec.LookPath("user-scanner") respond.JSON(w, http.StatusOK, statusResponse{UserScannerAvailable: err == nil}) } // POST /api/enumerate/generate func (h *EnumerateHandler) Generate(w http.ResponseWriter, r *http.Request) { var req generateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON body") return } var usernames []string if req.Username != "" { u := normalizeName(req.Username) if u == "" { respond.Error(w, http.StatusBadRequest, "invalid username") return } usernames = []string{u} } else { if req.FirstName == "" || req.LastName == "" { respond.Error(w, http.StatusBadRequest, "provide either username or first_name+last_name") return } usernames = buildUsernames(req.FirstName, req.LastName) if len(usernames) == 0 { respond.Error(w, http.StatusBadRequest, "could not generate usernames from provided names") return } } var emails []emailEntry for _, u := range usernames { for _, d := range emailDomains { emails = append(emails, emailEntry{ Address: u + "@" + d, Username: u, Domain: d, }) } } respond.JSON(w, http.StatusOK, generateResponse{Usernames: usernames, Emails: emails}) } // --- Check Email --- type checkEmailRequest struct { Email string `json:"email"` Quick bool `json:"quick"` } type checkEmailResponse struct { Status string `json:"status"` // "found" | "not_found" | "maybe" Reason string `json:"reason"` } // userScannerCheck runs user-scanner via PTY (required for output). // flag is either "-e" (email) or "-u" (username). // Office365 is excluded — it's a known false positive. // quick=true uses a shorter timeout for a faster but incomplete scan. func userScannerCheck(ctx context.Context, flag, target string, quick bool) (status, reason string, sites []string) { defer func() { if r := recover(); r != nil { status, reason, sites = "maybe", "internal error during scan", nil } }() if _, err := exec.LookPath("user-scanner"); err != nil { return "maybe", "user-scanner not available", nil } scanTimeout := 90 * time.Second if quick { scanTimeout = 15 * time.Second } ctx, cancel := context.WithTimeout(ctx, scanTimeout) defer cancel() cmd := exec.CommandContext(ctx, "user-scanner", flag, target, "--only-found") output, _ := tools.RunWithPTY(ctx, cmd) // Strip ANSI codes, then normalise PTY line endings (\r\n → \n). output = ansiRe.ReplaceAllString(output, "") output = strings.ReplaceAll(output, "\r\n", "\n") output = strings.ReplaceAll(output, "\r", "\n") // Strip banner (first 8 lines) if lines := strings.SplitN(output, "\n", 10); len(lines) > 8 { output = strings.Join(lines[8:], "\n") } for _, line := range strings.Split(output, "\n") { if !strings.Contains(line, "[✔]") { continue } // Office365 is a known false positive — skip it. if strings.Contains(line, "Office365") { continue } parts := strings.Fields(line) if len(parts) >= 2 { sites = append(sites, strings.TrimRight(parts[1], ":")) } } if len(sites) > 0 { return "found", fmt.Sprintf("found via user-scanner (%d result(s))", len(sites)), sites } return "not_found", "not found via user-scanner", nil } // POST /api/enumerate/check-email func (h *EnumerateHandler) CheckEmail(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode") return } var req checkEmailRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.Email == "" { respond.Error(w, http.StatusBadRequest, "email is required") return } status, reason, _ := userScannerCheck(r.Context(), "-e", req.Email, req.Quick) respond.JSON(w, http.StatusOK, checkEmailResponse{Status: status, Reason: reason}) } // --- Check Username --- type checkUsernameRequest struct { Username string `json:"username"` Quick bool `json:"quick"` } type checkUsernameResponse struct { Status string `json:"status"` Reason string `json:"reason"` Sites []string `json:"sites"` } // POST /api/enumerate/check-username func (h *EnumerateHandler) CheckUsername(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode") return } var req checkUsernameRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.Username == "" { respond.Error(w, http.StatusBadRequest, "username is required") return } status, reason, sites := userScannerCheck(r.Context(), "-u", req.Username, req.Quick) if sites == nil { sites = []string{} } respond.JSON(w, http.StatusOK, checkUsernameResponse{Status: status, Reason: reason, Sites: sites}) }