mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 00:47:26 +02:00
init enumerate
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
296
back/internal/api/handler/enumerate.go
Normal file
296
back/internal/api/handler/enumerate.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ func NewRouter(
|
|||||||
searchHandler := handler.NewSearchHandler(manager, demo)
|
searchHandler := handler.NewSearchHandler(manager, demo)
|
||||||
toolsHandler := handler.NewToolsHandler(factories)
|
toolsHandler := handler.NewToolsHandler(factories)
|
||||||
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
||||||
|
enumerateHandler := handler.NewEnumerateHandler(configPath, demo)
|
||||||
|
|
||||||
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
||||||
|
|
||||||
@@ -45,6 +46,13 @@ func NewRouter(
|
|||||||
r.Get("/{name}", toolsHandler.Get)
|
r.Get("/{name}", toolsHandler.Get)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/enumerate", func(r chi.Router) {
|
||||||
|
r.Get("/status", enumerateHandler.Status)
|
||||||
|
r.Post("/generate", enumerateHandler.Generate)
|
||||||
|
r.Post("/check-email", enumerateHandler.CheckEmail)
|
||||||
|
r.Post("/check-username", enumerateHandler.CheckUsername)
|
||||||
|
})
|
||||||
|
|
||||||
r.Route("/config", func(r chi.Router) {
|
r.Route("/config", func(r chi.Router) {
|
||||||
r.Get("/", configHandler.Get)
|
r.Get("/", configHandler.Get)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8080",
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
timeout: 120000,
|
||||||
|
proxyTimeout: 120000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
448
front/src/components/EnumeratePage.svelte
Normal file
448
front/src/components/EnumeratePage.svelte
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Search, CheckCircle, XCircle, HelpCircle, Play, User, Mail, Zap, AtSign, TriangleAlert } from "@lucide/svelte";
|
||||||
|
|
||||||
|
type EntryKind = "username" | "email";
|
||||||
|
type CheckStatus = "idle" | "checking" | "found" | "not_found" | "maybe";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
id: string;
|
||||||
|
kind: EntryKind;
|
||||||
|
value: string;
|
||||||
|
// email-specific
|
||||||
|
domain?: string;
|
||||||
|
// username-specific
|
||||||
|
sites?: string[];
|
||||||
|
status: CheckStatus;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: "name" | "username" = $state("name");
|
||||||
|
let firstName = $state("");
|
||||||
|
let lastName = $state("");
|
||||||
|
let usernameInput = $state("");
|
||||||
|
|
||||||
|
let entries: Entry[] = $state([]);
|
||||||
|
let generating = $state(false);
|
||||||
|
let generateError = $state("");
|
||||||
|
let demo = $state(false);
|
||||||
|
let userScannerAvailable = $state(true);
|
||||||
|
|
||||||
|
fetch("/api/config")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => { if (d) demo = d.demo === true; })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
fetch("/api/enumerate/status")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => { if (d) userScannerAvailable = d.user_scanner_available; })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
let filter: "all" | "username" | "email" = $state("all");
|
||||||
|
let domainFilter = $state("all");
|
||||||
|
let allDomains: string[] = $state([]);
|
||||||
|
let quickMode = $state(true);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
generateError = "";
|
||||||
|
generating = true;
|
||||||
|
entries = [];
|
||||||
|
allDomains = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
mode === "name"
|
||||||
|
? { first_name: firstName.trim(), last_name: lastName.trim() }
|
||||||
|
: { username: usernameInput.trim() };
|
||||||
|
|
||||||
|
const res = await fetch("/api/enumerate/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const newEntries: Entry[] = [];
|
||||||
|
|
||||||
|
for (const u of data.usernames ?? []) {
|
||||||
|
newEntries.push({ id: "u:" + u, kind: "username", value: u, status: "idle", reason: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set<string>();
|
||||||
|
for (const e of data.emails ?? []) {
|
||||||
|
newEntries.push({
|
||||||
|
id: "e:" + e.address,
|
||||||
|
kind: "email",
|
||||||
|
value: e.address,
|
||||||
|
domain: e.domain,
|
||||||
|
status: "idle",
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
|
domains.add(e.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = newEntries;
|
||||||
|
allDomains = Array.from(domains);
|
||||||
|
} catch (e: any) {
|
||||||
|
generateError = e.message;
|
||||||
|
} finally {
|
||||||
|
generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkEntry(entry: Entry) {
|
||||||
|
entry.status = "checking";
|
||||||
|
entry.reason = "";
|
||||||
|
entry.sites = undefined;
|
||||||
|
entries = entries; // trigger reactivity
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (entry.kind === "email") {
|
||||||
|
const res = await fetch("/api/enumerate/check-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: entry.value, quick: quickMode }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) { entry.status = "maybe"; entry.reason = "empty response from server"; entries = entries; return; }
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
entry.status = d.status;
|
||||||
|
entry.reason = d.reason;
|
||||||
|
} else {
|
||||||
|
const res = await fetch("/api/enumerate/check-username", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: entry.value, quick: quickMode }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) { entry.status = "maybe"; entry.reason = "empty response from server"; entries = entries; return; }
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
entry.status = d.status;
|
||||||
|
entry.reason = d.reason;
|
||||||
|
entry.sites = d.sites ?? [];
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
entry.status = "maybe";
|
||||||
|
entry.reason = e.message;
|
||||||
|
}
|
||||||
|
entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAll() {
|
||||||
|
const visible = filteredEntries;
|
||||||
|
await Promise.all(visible.map((e) => checkEntry(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = $derived(
|
||||||
|
entries.filter((e) => {
|
||||||
|
if (filter !== "all" && e.kind !== filter) return false;
|
||||||
|
if (filter === "email" && domainFilter !== "all" && e.domain !== domainFilter) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const anyChecking = $derived(entries.some((e) => e.status === "checking"));
|
||||||
|
|
||||||
|
function statusColor(s: CheckStatus) {
|
||||||
|
if (s === "found") return "text-success";
|
||||||
|
if (s === "not_found") return "text-error";
|
||||||
|
if (s === "maybe") return "text-warning";
|
||||||
|
return "text-base-content/40";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: CheckStatus) {
|
||||||
|
if (s === "found") return "Found";
|
||||||
|
if (s === "not_found") return "Not found";
|
||||||
|
if (s === "maybe") return "Maybe";
|
||||||
|
if (s === "checking") return "Checking…";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<!-- Input form -->
|
||||||
|
<div class="card bg-base-200 shadow p-4 flex flex-col gap-3">
|
||||||
|
|
||||||
|
<!-- Mobile layout -->
|
||||||
|
<div class="flex flex-col gap-2 sm:hidden">
|
||||||
|
<div class="flex gap-1 p-1 bg-base-300 rounded-xl border border-base-content/15">
|
||||||
|
<button
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'name' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/60'}"
|
||||||
|
onclick={() => { mode = 'name'; entries = []; }}
|
||||||
|
>
|
||||||
|
<User size={12} /> Name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'username' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/60'}"
|
||||||
|
onclick={() => { mode = 'username'; entries = []; }}
|
||||||
|
>
|
||||||
|
<AtSign size={12} /> Username
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === "name"}
|
||||||
|
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="First name"
|
||||||
|
bind:value={firstName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
<span class="text-base-content/20 shrink-0 px-1 select-none">·</span>
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="Last name"
|
||||||
|
bind:value={lastName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="@username"
|
||||||
|
bind:value={usernameInput}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={generating || (mode === "name" ? !firstName.trim() || !lastName.trim() : !usernameInput.trim())}
|
||||||
|
>
|
||||||
|
{#if generating}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Search size={15} />
|
||||||
|
{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop layout -->
|
||||||
|
<div class="hidden sm:flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="border-r border-base-content/10 flex items-center gap-0.5 p-1 shrink-0">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'name' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/70'}"
|
||||||
|
onclick={() => { mode = 'name'; entries = []; }}
|
||||||
|
>
|
||||||
|
<User size={12} /> Name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'username' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/70'}"
|
||||||
|
onclick={() => { mode = 'username'; entries = []; }}
|
||||||
|
>
|
||||||
|
<AtSign size={12} /> Username
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inputs -->
|
||||||
|
{#if mode === "name"}
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="First name"
|
||||||
|
bind:value={firstName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
<span class="text-base-content/20 shrink-0 select-none">·</span>
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="Last name"
|
||||||
|
bind:value={lastName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="@username"
|
||||||
|
bind:value={usernameInput}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Generate button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm m-1 rounded-lg gap-1 shrink-0"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={generating || (mode === "name" ? !firstName.trim() || !lastName.trim() : !usernameInput.trim())}
|
||||||
|
>
|
||||||
|
{#if generating}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Search size={14} />
|
||||||
|
{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if generateError}
|
||||||
|
<p class="text-error text-xs pl-1">{generateError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- user-scanner warning -->
|
||||||
|
{#if !userScannerAvailable}
|
||||||
|
<div class="alert alert-warning text-sm gap-2">
|
||||||
|
<TriangleAlert size={15} class="shrink-0" />
|
||||||
|
<span><span class="font-mono">user-scanner</span> is not installed — email and username checking will be unavailable.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
{#if entries.length === 0 && !generating}
|
||||||
|
<p class="text-center text-sm text-base-content/30 py-6">
|
||||||
|
Enter a name or username above to generate candidates.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 justify-between">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div class="join border border-base-content/10 rounded-lg">
|
||||||
|
<button class="btn btn-xs join-item {filter === 'all' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'all'; domainFilter = 'all'; }}>All</button>
|
||||||
|
<button class="btn btn-xs join-item {filter === 'username' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'username'; domainFilter = 'all'; }}>
|
||||||
|
<User size={11} /> Usernames
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs join-item {filter === 'email' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'email'; }}>
|
||||||
|
<Mail size={11} /> Emails
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filter === "email" && allDomains.length > 1}
|
||||||
|
<select class="select select-bordered select-xs" bind:value={domainFilter}>
|
||||||
|
<option value="all">All domains</option>
|
||||||
|
{#each allDomains as d}
|
||||||
|
<option value={d}>{d}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="tooltip" data-tip="Quick mode uses a smaller set of checks for faster results.">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-base-content/60 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" bind:checked={quickMode} />
|
||||||
|
<Zap size={11} /> Quick
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tooltip" data-tip={demo ? "Disabled in demo mode" : undefined}>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
onclick={checkAll}
|
||||||
|
disabled={anyChecking || demo}
|
||||||
|
>
|
||||||
|
{#if anyChecking}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Play size={13} />
|
||||||
|
{/if}
|
||||||
|
Test all visible
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-base-300">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal">Value</th>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal w-24">Type</th>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal w-32">Status</th>
|
||||||
|
<th class="w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredEntries as entry (entry.id)}
|
||||||
|
<tr class="hover:bg-base-200/50">
|
||||||
|
<td>
|
||||||
|
<div class="tooltip tooltip-right" data-tip="Open in search">
|
||||||
|
<a
|
||||||
|
href={`/?target=${encodeURIComponent(entry.value)}&type=${entry.kind}&fillOnly=true`}
|
||||||
|
class="font-mono text-sm hover:text-primary hover:underline transition-colors"
|
||||||
|
>{entry.value}</a>
|
||||||
|
</div>
|
||||||
|
{#if entry.sites && entry.sites.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each entry.sites as site}
|
||||||
|
<span class="badge badge-xs badge-success badge-soft">{site}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if entry.reason && entry.status !== "idle" && entry.status !== "checking"}
|
||||||
|
<div class="text-xs text-base-content/40 mt-0.5">{entry.reason}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-xs badge-ghost">
|
||||||
|
{entry.kind === "email" ? "email" : "username"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if entry.status === "checking"}
|
||||||
|
<span class="flex items-center gap-1 text-xs text-base-content/50">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span> Checking
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "found"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-success font-medium">
|
||||||
|
<CheckCircle size={13} /> Found
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "not_found"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-error">
|
||||||
|
<XCircle size={13} /> Not found
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "maybe"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-warning">
|
||||||
|
<HelpCircle size={13} /> Maybe
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-base-content/30">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="tooltip tooltip-left" data-tip={demo ? "Disabled in demo mode" : undefined}>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost gap-1"
|
||||||
|
onclick={() => checkEntry(entry)}
|
||||||
|
disabled={entry.status === "checking" || demo}
|
||||||
|
>
|
||||||
|
{#if entry.status === "checking"}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Play size={11} />
|
||||||
|
{/if}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/40 text-right">
|
||||||
|
{filteredEntries.length} entries shown
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
let redirecting = $state(false);
|
let redirecting = $state(false);
|
||||||
let redirectTarget = $state("");
|
let redirectTarget = $state("");
|
||||||
let demo = $state(false);
|
let demo = $state(false);
|
||||||
|
let prefill = $state({ target: "", type: "" });
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
loadSearches();
|
loadSearches();
|
||||||
@@ -20,10 +22,20 @@
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const target = params.get("target");
|
const target = params.get("target");
|
||||||
const type = params.get("type");
|
const type = params.get("type");
|
||||||
|
const fillOnly = params.get("fillOnly") === "true";
|
||||||
|
|
||||||
if (target && type) {
|
if (target && type) {
|
||||||
// Clean URL before launching so a refresh doesn't re-trigger
|
// Clean URL before acting so a refresh doesn't re-trigger
|
||||||
window.history.replaceState({}, "", window.location.pathname);
|
window.history.replaceState({}, "", window.location.pathname);
|
||||||
|
if (fillOnly) {
|
||||||
|
prefill = { target, type };
|
||||||
|
ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ready = true;
|
||||||
await handleSearch(target, type, params.get("profile") || "default");
|
await handleSearch(target, type, params.get("profile") || "default");
|
||||||
|
} else {
|
||||||
|
ready = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,8 +89,8 @@
|
|||||||
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
|
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if ready}
|
||||||
<SearchBar onSearch={handleSearch} {demo} />
|
<SearchBar onSearch={handleSearch} {demo} initialTarget={prefill.target} initialType={prefill.type} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Bug,
|
Bug,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
ListFilter,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
{ label: "Search", href: "/", icon: Search },
|
{ label: "Search", href: "/", icon: Search },
|
||||||
{ label: "Tools", href: "/tools", icon: Hammer },
|
{ label: "Tools", href: "/tools", icon: Hammer },
|
||||||
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
|
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
|
||||||
|
{ label: "Enumerate", href: "/enumerate", icon: ListFilter },
|
||||||
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
|
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
|
||||||
{
|
{
|
||||||
label: "More",
|
label: "More",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Select from "./comps/Select.svelte";
|
import Select from "./comps/Select.svelte";
|
||||||
import { INPUT_TYPES } from "@src/lib/vars";
|
import { INPUT_TYPES } from "@src/lib/vars";
|
||||||
|
|
||||||
let { onSearch = async () => {}, demo = false } = $props();
|
let { onSearch = async () => {}, demo = false, initialTarget = "", initialType = "" } = $props();
|
||||||
|
|
||||||
const DETECTORS = {
|
const DETECTORS = {
|
||||||
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
|
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = $state("");
|
let target = $state(initialTarget);
|
||||||
let inputType = $state("email");
|
let inputType = $state(initialType || "email");
|
||||||
let profile = $state("default");
|
let profile = $state("default");
|
||||||
let profiles = $state([]);
|
let profiles = $state([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|||||||
16
front/src/pages/enumerate.astro
Normal file
16
front/src/pages/enumerate.astro
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import Layout from "@src/layouts/Layout.astro";
|
||||||
|
import EnumeratePage from "@src/components/EnumeratePage.svelte";
|
||||||
|
---
|
||||||
|
<Layout title="Enumerate — iknowyou">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-6 flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Enumerate</h1>
|
||||||
|
<p class="text-base-content/50 text-sm mt-1">
|
||||||
|
Generate usernames and email addresses from a name, then verify their existence.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EnumeratePage client:only="svelte" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
Reference in New Issue
Block a user