init enumerate

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-04-11 20:43:30 +02:00
parent f53380fbd9
commit a0fceb36df
8 changed files with 793 additions and 7 deletions

View File

@@ -30,7 +30,11 @@ export default defineConfig({
],
server: {
proxy: {
"/api": "http://localhost:8080",
"/api": {
target: "http://localhost:8080",
timeout: 120000,
proxyTimeout: 120000,
},
},
},
},

View 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>

View File

@@ -9,6 +9,8 @@
let redirecting = $state(false);
let redirectTarget = $state("");
let demo = $state(false);
let prefill = $state({ target: "", type: "" });
let ready = $state(false);
onMount(async () => {
loadSearches();
@@ -20,10 +22,20 @@
const params = new URLSearchParams(window.location.search);
const target = params.get("target");
const type = params.get("type");
const fillOnly = params.get("fillOnly") === "true";
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);
if (fillOnly) {
prefill = { target, type };
ready = true;
return;
}
ready = true;
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>...
</p>
</div>
{:else}
<SearchBar onSearch={handleSearch} {demo} />
{:else if ready}
<SearchBar onSearch={handleSearch} {demo} initialTarget={prefill.target} initialType={prefill.type} />
{/if}
</div>

View File

@@ -9,6 +9,7 @@
BookOpen,
Bug,
ClipboardList,
ListFilter,
} from "@lucide/svelte";
import type { Snippet } from "svelte";
@@ -23,6 +24,7 @@
{ label: "Search", href: "/", icon: Search },
{ label: "Tools", href: "/tools", icon: Hammer },
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
{ label: "Enumerate", href: "/enumerate", icon: ListFilter },
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
{
label: "More",

View File

@@ -3,7 +3,7 @@
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
let { onSearch = async () => {}, demo = false } = $props();
let { onSearch = async () => {}, demo = false, initialTarget = "", initialType = "" } = $props();
const DETECTORS = {
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" },
};
let target = $state("");
let inputType = $state("email");
let target = $state(initialTarget);
let inputType = $state(initialType || "email");
let profile = $state("default");
let profiles = $state([]);
let loading = $state(false);

View 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>