This commit is contained in:
Hadi
2026-04-06 15:12:34 +02:00
commit 4989225671
117 changed files with 11454 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
<script lang="ts">
interface Sheet {
id: string;
title: string;
description?: string;
tags?: string[];
}
let { sheets }: { sheets: Sheet[] } = $props();
let search = $state("");
let activeTag: string | null = $state(
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("tag")
: null
);
const allTags = [...new Set(sheets.flatMap((s) => s.tags ?? []))].sort();
const filtered = $derived(
sheets.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.title.toLowerCase().includes(q) ||
(s.description?.toLowerCase().includes(q) ?? false);
const matchTag = !activeTag || (s.tags?.includes(activeTag) ?? false);
return matchSearch && matchTag;
})
);
</script>
<div class="flex flex-col gap-4">
<input
type="search"
placeholder="Search cheatsheets..."
bind:value={search}
class="input input-bordered w-full"
/>
{#if allTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each allTags as tag}
<button
class="badge badge-md cursor-pointer transition-colors {activeTag === tag
? 'badge-primary'
: 'badge-ghost hover:badge-outline'}"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
>
{tag}
</button>
{/each}
</div>
{/if}
<div class="flex flex-col gap-3">
{#if filtered.length === 0}
<p class="text-base-content/40 text-sm py-6 text-center">No results.</p>
{:else}
{#each filtered as sheet}
<a
href={`/cheatsheets/${sheet.id}`}
class="card bg-base-200 hover:bg-base-300 transition-colors p-4 flex flex-row items-center gap-4"
>
<div class="size-2 rounded-full bg-primary shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm">{sheet.title}</div>
{#if sheet.description}
<div class="text-base-content/50 text-xs mt-0.5">{sheet.description}</div>
{/if}
</div>
{#if sheet.tags && sheet.tags.length > 0}
<div class="flex gap-1 shrink-0">
{#each sheet.tags as tag}
<span class="badge badge-xs badge-ghost">{tag}</span>
{/each}
</div>
{/if}
</a>
{/each}
{/if}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script>
import { FlaskConical } from "@lucide/svelte";
let demo = $state(false);
async function checkDemo() {
try {
const res = await fetch("/api/config");
if (res.ok) {
const data = await res.json();
demo = data.demo === true;
}
} catch (_) {}
}
checkDemo();
</script>
{#if demo}
<div class="w-full bg-warning/15 border-b border-warning/30 py-1.5 px-4 flex items-center justify-center gap-2 text-xs text-warning">
<FlaskConical size={13} class="shrink-0" />
<span>Demo mode — searches and configuration changes are disabled</span>
</div>
{/if}

View File

@@ -0,0 +1,98 @@
<script>
import { RotateCw, AlertTriangle } from "@lucide/svelte";
import SearchBar from "./SearchBar.svelte";
import SearchList from "./SearchList.svelte";
import { onMount } from "svelte";
let searches = $state([]);
let loadError = $state("");
let redirecting = $state(false);
let redirectTarget = $state("");
let demo = $state(false);
onMount(async () => {
loadSearches();
fetch("/api/config")
.then((r) => r.ok ? r.json() : null)
.then((d) => { if (d) demo = d.demo === true; })
.catch(() => {});
const params = new URLSearchParams(window.location.search);
const target = params.get("target");
const type = params.get("type");
if (target && type) {
// Clean URL before launching so a refresh doesn't re-trigger
window.history.replaceState({}, "", window.location.pathname);
await handleSearch(target, type, params.get("profile") || "default");
}
});
async function loadSearches() {
try {
const res = await fetch("/api/searches");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
searches = (data ?? []).sort(
(a, b) => new Date(b.started_at) - new Date(a.started_at)
);
} catch (e) {
loadError = e.message;
}
}
async function handleSearch(target, inputType, profile) {
redirectTarget = target;
redirecting = true;
try {
const res = await fetch("/api/searches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target, input_type: inputType, profile: profile || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const s = await res.json();
window.location.href = `/search/${s.id}`;
} catch (e) {
redirecting = false;
throw e;
}
}
async function handleDelete(id) {
await fetch(`/api/searches/${id}`, { method: "DELETE" });
searches = searches.filter((s) => s.id !== id);
}
</script>
<div class="flex flex-col gap-8">
<div class="card bg-base-200 shadow p-6">
{#if redirecting}
<div class="flex flex-col items-center justify-center gap-3 py-4">
<span class="loading loading-dots loading-md text-primary"></span>
<p class="text-sm text-base-content/60">
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
</p>
</div>
{:else}
<SearchBar onSearch={handleSearch} {demo} />
{/if}
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Recent searches</h2>
<button class="btn btn-ghost btn-xs" onclick={loadSearches}><RotateCw class="size-3" /> refresh</button>
</div>
{#if loadError}
<div class="alert alert-error text-sm gap-2"><AlertTriangle size={15} class="shrink-0" />{loadError}</div>
{:else}
<SearchList {searches} onDelete={handleDelete} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import {
Menu,
Search,
Hammer,
SlidersHorizontal,
GitBranch,
User,
BookOpen,
Bug,
ClipboardList,
} from "@lucide/svelte";
import type { Snippet } from "svelte";
let {
action,
}: {
title?: string;
action?: Snippet;
} = $props();
const navLinks = [
{ label: "Search", href: "/", icon: Search },
{ label: "Tools", href: "/tools", icon: Hammer },
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
{
label: "More",
children: [
{ label: "How it works", href: "/help", icon: BookOpen },
{
label: "Source code",
href: "https://github.com/anotherhadi/iknowyou",
icon: GitBranch,
},
{
label: "Report a Bug",
href: "https://github.com/anotherhadi/iknowyou/issues",
icon: Bug,
},
{ label: "About me", href: "https://hadi.icu", icon: User },
],
},
];
</script>
<div class="bg-base-200">
<div class="navbar max-w-5xl m-auto">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<Menu size={20} />
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-300 rounded-box z-50 mt-3 w-52 p-2"
>
{#each navLinks as link}
<li>
{#if link.children}
<span>{link.label}</span>
<ul class="p-2">
{#each link.children as sublink}
<li>
<a href={sublink.href} class="flex items-center gap-2">
{#if sublink.icon}
{@const Icon = sublink.icon}
<Icon size={12} />
{/if}
{sublink.label}
</a>
</li>
{/each}
</ul>
{:else}
<a href={link.href} class="flex items-center gap-2">
{#if link.icon}
{@const Icon = link.icon}
<Icon size={12} />
{/if}
{link.label}
</a>
{/if}
</li>
{/each}
</ul>
</div>
<a
href="/"
class="btn btn-ghost text-xl flex justify-center gap-2 items-center"
>
<img src="/logo.svg" class="m-auto h-6" alt="iky logo" />
<img src="/logo-large.svg" class="m-auto h-6" alt="iky logo large" />
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
{#each navLinks as link}
<li>
{#if link.children}
<details>
<summary>{link.label}</summary>
<ul class="p-2 bg-base-300 w-52 z-50">
{#each link.children as sublink}
<li>
<a href={sublink.href} class="flex items-center gap-2">
{#if sublink.icon}
{@const Icon = sublink.icon}
<Icon size={12} />
{/if}
{sublink.label}
</a>
</li>
{/each}
</ul>
</details>
{:else}
<a href={link.href} class="flex items-center gap-2">
{#if link.icon}
{@const Icon = link.icon}
<Icon size={14} />
{/if}
{link.label}
</a>
{/if}
</li>
{/each}
</ul>
</div>
<div class="navbar-end">
{@render action?.()}
</div>
</div>
</div>

View File

@@ -0,0 +1,571 @@
<script>
import { onMount } from "svelte";
import { Plus, Trash2, Save, ChevronRight, X, Lock, AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import Badge from "./comps/Badge.svelte";
import InfoTip from "./comps/InfoTip.svelte";
let tools = $state([]);
let profiles = $state([]);
let loading = $state(true);
let error = $state("");
let configReadonly = $state(false);
let selectedProfile = $state(null);
let profileDetail = $state(null);
let profileLoading = $state(false);
let notesEdit = $state("");
let enabledEdit = $state([]);
let disabledEdit = $state([]);
let rulesSaving = $state(false);
let rulesMsg = $state(null);
let overrideEdits = $state({});
let overrideSaving = $state({});
let overrideMsg = $state({});
let showNewProfile = $state(false);
let newName = $state("");
let newProfileSaving = $state(false);
let newProfileError = $state("");
let overrideToolNames = $derived(Object.keys(profileDetail?.tools ?? {}));
let configurableTools = $derived(tools.filter((t) => t.config_fields?.length > 0));
let availableForOverride = $derived(configurableTools.filter((t) => !overrideToolNames.includes(t.name)));
let allToolNames = $derived(tools.map((t) => t.name));
let isReadonly = $derived((profileDetail?.readonly ?? false) || configReadonly);
onMount(loadAll);
async function loadAll() {
loading = true;
error = "";
try {
const [tr, pr, cr] = await Promise.all([
fetch("/api/tools"),
fetch("/api/config/profiles"),
fetch("/api/config"),
]);
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
tools = await tr.json();
profiles = await pr.json();
if (cr.ok) {
const cfg = await cr.json();
configReadonly = cfg.readonly ?? false;
}
if (!selectedProfile && profiles.length > 0) {
const def = profiles.find((p) => p.name === "default");
await selectProfile(def ? "default" : profiles[0].name);
}
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function selectProfile(name) {
selectedProfile = name;
profileLoading = true;
profileDetail = null;
overrideEdits = {};
overrideSaving = {};
overrideMsg = {};
rulesMsg = null;
try {
const res = await fetch(`/api/config/profiles/${encodeURIComponent(name)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
profileDetail = await res.json();
notesEdit = profileDetail.notes ?? "";
enabledEdit = [...(profileDetail.enabled ?? [])];
disabledEdit = [...(profileDetail.disabled ?? [])];
const nextEdits = {};
for (const [toolName, toolConf] of Object.entries(profileDetail.tools ?? {})) {
const tool = tools.find((t) => t.name === toolName);
if (!tool?.config_fields?.length) continue;
nextEdits[toolName] = {};
for (const f of tool.config_fields) {
nextEdits[toolName][f.name] =
toolConf?.[f.name] !== undefined ? toolConf[f.name] : (f.default ?? "");
}
}
overrideEdits = nextEdits;
} catch (e) {
error = e.message;
} finally {
profileLoading = false;
}
}
function validateNewName(name) {
if (!name) return "Name is required";
if (!/^[a-z0-9-]+$/.test(name)) return "Only lowercase letters (a-z), digits (0-9), and hyphens (-) are allowed";
return "";
}
async function createProfile() {
const name = newName.trim();
const nameError = validateNewName(name);
if (nameError) { newProfileError = nameError; return; }
newProfileSaving = true;
newProfileError = "";
try {
const res = await fetch("/api/config/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
showNewProfile = false;
newName = "";
await loadAll();
await selectProfile(name);
} catch (e) {
newProfileError = e.message;
} finally {
newProfileSaving = false;
}
}
async function deleteProfile(name) {
if (!confirm(`Delete profile "${name}"?`)) return;
try {
await fetch(`/api/config/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
if (selectedProfile === name) {
selectedProfile = null;
profileDetail = null;
}
await loadAll();
} catch (e) {
error = e.message;
}
}
async function saveRules() {
rulesSaving = true;
rulesMsg = null;
try {
const res = await fetch(`/api/config/profiles/${encodeURIComponent(selectedProfile)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabledEdit, disabled: disabledEdit, notes: notesEdit }),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
rulesMsg = { ok: true, text: "Saved" };
setTimeout(() => (rulesMsg = null), 3000);
await selectProfile(selectedProfile);
} catch (e) {
rulesMsg = { ok: false, text: e.message };
} finally {
rulesSaving = false;
}
}
async function saveOverride(toolName) {
const tool = tools.find((t) => t.name === toolName);
for (const f of tool?.config_fields ?? []) {
if (f.required) {
const v = overrideEdits[toolName]?.[f.name];
if (v === undefined || v === null || v === "") {
flashOverride(toolName, { ok: false, text: `"${f.name}" is required` });
return;
}
}
}
overrideSaving = { ...overrideSaving, [toolName]: true };
overrideMsg = { ...overrideMsg, [toolName]: null };
try {
const res = await fetch(
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(overrideEdits[toolName]),
}
);
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
flashOverride(toolName, { ok: true, text: "Saved" });
} catch (e) {
flashOverride(toolName, { ok: false, text: e.message });
} finally {
overrideSaving = { ...overrideSaving, [toolName]: false };
}
}
async function deleteOverride(toolName) {
if (!confirm(`Remove "${toolName}" override from "${selectedProfile}"?`)) return;
try {
await fetch(
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
{ method: "DELETE" }
);
await selectProfile(selectedProfile);
} catch (e) {
error = e.message;
}
}
function addOverrideFor(toolName) {
if (!toolName) return;
const tool = tools.find((t) => t.name === toolName);
if (!tool) return;
const toolEdits = {};
for (const f of tool.config_fields ?? []) toolEdits[f.name] = f.default ?? "";
overrideEdits = { ...overrideEdits, [toolName]: toolEdits };
profileDetail = {
...profileDetail,
tools: { ...(profileDetail.tools ?? {}), [toolName]: {} },
};
}
function flashOverride(toolName, val) {
overrideMsg = { ...overrideMsg, [toolName]: val };
setTimeout(() => {
overrideMsg = { ...overrideMsg, [toolName]: null };
}, 3000);
}
</script>
{#if loading}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else}
<div class="flex flex-col md:flex-row gap-0 items-start">
<div class="w-full md:w-52 shrink-0 flex flex-col gap-1 border-b border-base-300 pb-4 mb-4 md:border-b-0 md:border-r md:pb-0 md:mb-0 md:pr-4 md:mr-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs uppercase tracking-widest text-base-content/50">Profiles</span>
{#if !configReadonly}
<button
class="btn btn-ghost btn-xs"
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
>
<Plus size={14} />
</button>
{/if}
</div>
{#if showNewProfile && !configReadonly}
<div class="flex flex-col gap-2 p-3 bg-base-300 rounded-box mb-1">
<input
type="text"
class="input input-bordered input-sm w-full {newProfileError && !/^[a-z0-9-]*$/.test(newName) ? 'input-error' : ''}"
placeholder="profile-name"
bind:value={newName}
onkeydown={(e) => e.key === "Enter" && createProfile()}
/>
{#if newProfileError}
<p class="text-xs text-error">{newProfileError}</p>
{/if}
<button
class="btn btn-primary btn-xs w-full"
onclick={createProfile}
disabled={newProfileSaving || !newName.trim()}
>
{#if newProfileSaving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Create
{/if}
</button>
</div>
{/if}
{#each profiles as p}
<div class="flex items-center gap-1 group">
<button
class="flex-1 btn btn-sm {selectedProfile === p.name ? 'btn-primary' : 'btn-ghost'} justify-start gap-1 truncate"
onclick={() => selectProfile(p.name)}
>
{#if selectedProfile === p.name}
<ChevronRight size={14} class="shrink-0" />
{/if}
{#if p.readonly}
<Lock size={10} class="shrink-0 opacity-50" />
{/if}
{p.name}
</button>
{#if !p.readonly}
<button
class="btn btn-ghost btn-xs text-error opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onclick={() => deleteProfile(p.name)}
>
<Trash2 size={12} />
</button>
{/if}
</div>
{/each}
{#if profiles.length === 0}
<p class="text-base-content/40 text-xs text-center py-4">No profiles yet.</p>
{/if}
</div>
<div class="flex-1 min-w-0 pt-4 md:pt-0">
{#if configReadonly}
<div class="alert alert-warning mb-4 py-2 px-3 text-sm gap-2">
<Lock size={14} class="shrink-0" />
Config is managed externally and is read-only.
</div>
{/if}
{#if !selectedProfile}
<p class="text-base-content/40 text-sm text-center py-8">Select a profile to view it.</p>
{:else if profileLoading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if profileDetail}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="font-bold text-lg flex items-center gap-2">
{#if isReadonly}<Lock size={14} class="text-base-content/40" />{/if}
{selectedProfile}
</h2>
{#if isReadonly}
<Badge text="read-only" size="sm" />
{/if}
{#if profileDetail.active_tools?.length > 0}
<span class="text-xs text-base-content/50">
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
</span>
{/if}
</div>
{#if isReadonly}
{#if profileDetail.notes}
<p class="text-sm text-base-content/60 italic">{profileDetail.notes}</p>
{/if}
{:else}
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-widest text-base-content/50">Notes</span>
<textarea
class="textarea textarea-bordered text-sm resize-none"
placeholder="Add a description for this profile..."
rows="2"
bind:value={notesEdit}
></textarea>
</div>
{/if}
<div class="card bg-base-200 shadow">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between">
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Rules</h3>
{#if !isReadonly && rulesMsg}
<span class="text-xs {rulesMsg.ok ? 'text-success' : 'text-error'}">{rulesMsg.text}</span>
{/if}
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">
Enabled
<InfoTip tooltip="Tools in the enabled list will be allowed for this profile. If the enabled list is empty, all tools will be enabled." />
</span>
<div class="flex flex-wrap gap-1 items-center min-h-8">
{#if isReadonly}
{#each (profileDetail.enabled ?? []) as toolName}
<span class="badge badge-outline gap-1">{toolName}</span>
{/each}
{#if (profileDetail.enabled ?? []).length === 0}
<span class="text-xs text-base-content/40">All tools</span>
{/if}
{:else}
{#each enabledEdit as toolName}
<span class="badge badge-outline gap-1">
{toolName}
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== toolName))}>
<X size={10} />
</button>
</span>
{/each}
<Select
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
placeholder="add tool"
size="xs"
onselect={(val) => (enabledEdit = [...enabledEdit, val])}
/>
{/if}
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">
Disabled
<InfoTip tooltip="Tools in the disabled list will be blocked for this profile. Applied after enabled rules, so if a tool is in both lists, it will be disabled." />
</span>
<div class="flex flex-wrap gap-1 items-center min-h-8">
{#if isReadonly}
{#each (profileDetail.disabled ?? []) as toolName}
<span class="badge badge-error gap-1">{toolName}</span>
{/each}
{#if (profileDetail.disabled ?? []).length === 0}
<span class="text-xs text-base-content/40">None</span>
{/if}
{:else}
{#each disabledEdit as toolName}
<span class="badge badge-error gap-1">
{toolName}
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== toolName))}>
<X size={10} />
</button>
</span>
{/each}
<Select
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
placeholder="add tool"
size="xs"
onselect={(val) => (disabledEdit = [...disabledEdit, val])}
/>
{/if}
</div>
</div>
{#if !isReadonly}
<button
class="btn btn-primary btn-sm gap-1 self-start"
onclick={saveRules}
disabled={rulesSaving}
>
{#if rulesSaving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{/if}
</div>
</div>
{#if !isReadonly}
<div class="card bg-base-200 shadow">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Tool overrides</h3>
{#if availableForOverride.length > 0}
<Select
options={availableForOverride.map((t) => t.name)}
placeholder="add override"
size="xs"
onselect={(val) => addOverrideFor(val)}
/>
{/if}
</div>
{#if overrideToolNames.length === 0}
<p class="text-sm text-base-content/40">No overrides configured.</p>
{:else}
<div class="flex flex-col gap-3">
{#each overrideToolNames as toolName}
{@const tool = tools.find((t) => t.name === toolName)}
<div class="border border-base-300 rounded-box p-3 flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-sm">{toolName}</span>
<div class="flex items-center gap-2">
{#if overrideMsg[toolName]}
<span class="text-xs {overrideMsg[toolName].ok ? 'text-success' : 'text-error'}">
{overrideMsg[toolName].text}
</span>
{/if}
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => deleteOverride(toolName)}
>
<Trash2 size={12} />
</button>
</div>
</div>
{#if tool?.config_fields?.length && overrideEdits[toolName]}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
{#each tool.config_fields as field}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-mono text-xs font-semibold">{field.name}</span>
<span class="badge badge-ghost badge-xs">{field.type}</span>
{#if field.required}
<span class="badge badge-error badge-xs">required</span>
{/if}
</div>
{#if field.type === "bool"}
<label class="flex items-center gap-2 cursor-pointer mt-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
bind:checked={overrideEdits[toolName][field.name]}
/>
<span class="text-xs text-base-content/50">
{overrideEdits[toolName][field.name] ? "enabled" : "disabled"}
</span>
</label>
{:else if field.type === "int"}
<input
type="number"
step="1"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{:else if field.type === "float"}
<input
type="number"
step="any"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{:else if field.type === "enum"}
<select
class="select select-bordered select-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
>
{#each field.options as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
type="text"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{/if}
</div>
{/each}
</div>
<button
class="btn btn-primary btn-sm gap-1 self-start"
onclick={() => saveOverride(toolName)}
disabled={overrideSaving[toolName]}
>
{#if overrideSaving[toolName]}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{:else}
<p class="text-xs text-base-content/40">This tool has no configurable fields.</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,232 @@
<script>
import { Search, AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
let { onSearch = async () => {}, demo = false } = $props();
const DETECTORS = {
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
phone: (_raw, v) => /^\+\d{1,4} \d{4,}$/.test(v),
ip: (_raw, v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v),
domain: (raw, v) => /^https?:\/\//.test(raw) || /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v),
name: (_raw, v) => /^[a-zA--ÿ'-]+(?: [a-zA-ZÀ-ÿ'-]+){1,2}$/.test(v),
};
const VALIDATORS = {
email: { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: "Invalid email address" },
username: { test: (v) => /^[a-zA-Z0-9._-]+$/.test(v), msg: "Username may only contain a-z, 0-9, . - _" },
phone: { test: (v) => /^\+\d{1,4} \d{4,}$/.test(v), msg: "Format: +INDICATIF NUMERO (ex: +33 0612345678)" },
ip: { test: (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v), msg: "Invalid IP address" },
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 profile = $state("default");
let profiles = $state([]);
let loading = $state(false);
let error = $state("");
let validationError = $state("");
// null = auto-switch free; "TYPE" = auto-switched from TYPE (show revert); "__locked__" = user overrode, no auto-switch
let prevType = $state(null);
let showRevert = $derived(prevType !== null && prevType !== "__locked__");
let profileOptions = $derived(profiles.map((p) => p.name));
let strippedTarget = $derived.by(() => {
let v = target.trim();
v = v.replace(/^https?:\/\//, "");
if (v.startsWith("@")) v = v.slice(1);
return v;
});
let detectedType = $derived.by(() => {
const raw = target.trim();
const v = strippedTarget;
if (!v && !raw) return null;
if (raw.startsWith("@")) return "username";
for (const [type, fn] of Object.entries(DETECTORS)) {
if (fn(raw, v)) return type;
}
return null;
});
async function loadProfiles() {
try {
const res = await fetch("/api/config/profiles");
if (res.ok) profiles = await res.json();
} catch (_) {}
}
loadProfiles();
function sanitize(s) {
return s.replace(/[<>"'`&]/g, "").trim();
}
function validate(val, type) {
const v = VALIDATORS[type];
if (!v) return "";
return v.test(val) ? "" : v.msg;
}
function onTargetInput() {
if (!strippedTarget) prevType = null; // reset when field is cleared
if (validationError) validationError = validate(strippedTarget, inputType);
if (prevType === null && detectedType && detectedType !== inputType) {
prevType = inputType;
inputType = detectedType;
}
}
function revert() {
inputType = prevType;
prevType = "__locked__";
validationError = "";
}
function onSelectType(v) {
inputType = v;
prevType = "__locked__";
validationError = "";
}
async function submit() {
if (demo) return;
const clean = sanitize(strippedTarget);
if (!clean) return;
validationError = validate(clean, inputType);
if (validationError) return;
error = "";
loading = true;
try {
await onSearch(clean, inputType, profile);
target = "";
prevType = null;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col gap-2">
{#if error}
<div class="alert alert-error text-sm py-2 gap-2"><AlertTriangle size={15} class="shrink-0" />{error}</div>
{/if}
{#if showRevert}
<div class="flex items-center gap-2 px-1 text-xs text-base-content/50">
<span>Switched to <span class="text-base-content/70 font-medium">{inputType}</span></span>
<button
class="badge badge-ghost badge-sm hover:badge-primary transition-colors cursor-pointer"
onclick={revert}
>
{prevType}
</button>
</div>
{/if}
<!-- Mobile layout -->
<div class="flex flex-col gap-2 sm:hidden">
<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"
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
bind:value={target}
oninput={onTargetInput}
onkeydown={(e) => e.key === "Enter" && submit()}
disabled={demo}
/>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/25 pl-1">type</span>
<Select
options={INPUT_TYPES}
selected={inputType}
size="xs"
onselect={onSelectType}
/>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/25">profile</span>
<Select
options={profileOptions}
selected={profile}
size="xs"
onselect={(v) => { profile = v; }}
/>
</div>
<button
class="btn btn-primary btn-sm flex-1 gap-1"
onclick={submit}
disabled={demo || loading || !target.trim()}
>
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Search size={14} />
{/if}
Search
</button>
</div>
</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"
>
<div class="border-r border-base-content/10 flex items-center gap-1 pl-3">
<span class="text-xs text-base-content/25 shrink-0">type</span>
<Select
options={INPUT_TYPES}
selected={inputType}
onselect={onSelectType}
/>
</div>
<input
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm
placeholder:text-base-content/30 min-w-0"
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
bind:value={target}
oninput={onTargetInput}
onkeydown={(e) => e.key === "Enter" && submit()}
disabled={demo}
/>
<div class="border-l border-base-content/10 flex items-center gap-1 pr-1 pl-3">
<span class="text-xs text-base-content/25 shrink-0">profile</span>
<Select
options={profileOptions}
selected={profile}
onselect={(v) => { profile = v; }}
/>
</div>
<button
class="btn btn-primary btn-sm m-1 rounded-lg gap-1 shrink-0"
onclick={submit}
disabled={demo || loading || !target.trim()}
>
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Search size={14} />
{/if}
Search
</button>
</div>
{#if validationError}
<p class="text-xs text-error pl-1">{validationError}</p>
{/if}
</div>

View File

@@ -0,0 +1,349 @@
<script>
import { onDestroy, onMount } from "svelte";
import { RefreshCw, ChevronRight, Check, X, AlertTriangle } from "@lucide/svelte";
import TtyOutput from "@src/components/comps/TtyOutput.svelte";
let { id = null } = $props();
let demo = $state(false);
async function checkDemo() {
try {
const res = await fetch("/api/config");
if (res.ok) {
const data = await res.json();
demo = data.demo === true;
}
} catch (_) {}
}
checkDemo();
let resolvedId = $state("");
let search = $state(null);
let error = $state("");
let pollTimeout = null;
let pollDelay = $state(800);
const POLL_MIN = 800;
const POLL_MAX = 5000;
let grouped = $derived(groupByTool(search?.events ?? []));
let toolProgress = $derived(computeProgress(search?.planned_tools ?? [], grouped));
let totalResults = $derived(
(search?.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)
);
let sortedEntries = $derived((() => {
const entries = Object.entries(grouped);
const hasResults = ([toolName, d]) => {
const count = search?.planned_tools?.find(t => t.name === toolName)?.result_count ?? null;
return d.done && (count !== null ? count > 0 : d.output.length > 0);
};
const withResults = entries.filter(([n, d]) => hasResults([n, d]));
const running = entries.filter(([_, d]) => !d.done);
const noResults = entries.filter(([n, d]) => d.done && !hasResults([n, d]));
return { withResults, running, noResults };
})());
function groupByTool(events) {
const map = {};
for (const e of events) {
if (!map[e.tool]) map[e.tool] = { errors: [], output: "", done: false };
if (e.type === "output") map[e.tool].output += e.payload;
else if (e.type === "error") map[e.tool].errors.push(e.payload);
else if (e.type === "done") map[e.tool].done = true;
}
return map;
}
function computeProgress(planned, grouped) {
const skipped = planned.filter((t) => t.skipped);
const active = planned.filter((t) => !t.skipped);
const errored = active.filter((t) => {
const d = grouped[t.name];
return d?.done && d.errors.length > 0 && d.output.length === 0;
});
const done = active.filter((t) => grouped[t.name]?.done);
const running = active.filter((t) => !grouped[t.name]?.done);
const skippedTotal = skipped.length + errored.length;
return { skipped, active, done, running, errored, skippedTotal };
}
async function refresh() {
try {
const res = await fetch(`/api/searches/${resolvedId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
search = await res.json();
if (search.status !== "running") stopPolling();
} catch (e) {
error = e.message;
stopPolling();
}
}
function scheduleNext() {
pollTimeout = setTimeout(async () => {
await refresh();
if (search?.status === "running") {
pollDelay = Math.min(pollDelay * 2, POLL_MAX);
scheduleNext();
}
}, pollDelay);
}
function startPolling() { stopPolling(); pollDelay = POLL_MIN; scheduleNext(); }
function stopPolling() { if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } }
onMount(async () => {
resolvedId = id ?? window.location.pathname.replace(/^\/search\//, "").replace(/\/$/, "");
await refresh();
if (search?.status === "running") startPolling();
});
onDestroy(stopPolling);
let toast = $state(null); // { msg, type }
async function cancel() {
stopPolling();
await fetch(`/api/searches/${resolvedId}`, { method: "DELETE" });
toast = { msg: "Search cancelled.", type: "alert-warning" };
setTimeout(() => { window.location.href = "/"; }, 2000);
}
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
const STATUS_BADGE = {
running: "badge-warning",
done: "badge-success",
cancelled: "badge-error",
};
</script>
{#if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else if !search}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-1 flex-wrap">
<h1 class="font-mono text-xl sm:text-2xl font-bold truncate">{search.target}</h1>
<span class="badge {STATUS_BADGE[search.status] ?? 'badge-ghost'} shrink-0">
{#if search.status === "running"}
<span class="loading loading-ring loading-xs mr-1"></span>
{/if}
{search.status}
</span>
</div>
<div class="flex items-center gap-2 flex-wrap text-sm text-base-content/50">
<span>{search.input_type}</span>
{#if search.profile}
<span class="badge badge-outline badge-sm font-semibold">{search.profile}</span>
{/if}
<span>· started {fmtDate(search.started_at)}</span>
{#if search.status !== "running" && totalResults > 0}
<span>· <span class="text-base-content/70 font-medium">{totalResults} result{totalResults !== 1 ? "s" : ""}</span></span>
{/if}
</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-sm btn-ghost gap-1" onclick={refresh}>
<RefreshCw size={14} /> Refresh
</button>
{#if search.status === "running"}
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={cancel}>
<X size={14} /> Cancel
</button>
{/if}
</div>
</div>
{#if search.planned_tools?.length > 0}
<div class="card bg-base-200 shadow mb-6">
<div class="card-body p-4 gap-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Tools</h2>
<div class="flex gap-3 text-sm">
<span class="text-success font-mono">
{toolProgress.done.length}/{toolProgress.active.length} done
</span>
{#if toolProgress.skippedTotal > 0}
<span class="text-warning font-mono">
{toolProgress.skippedTotal} skipped
</span>
{/if}
</div>
</div>
<div class="w-full h-2 bg-base-300 rounded-full overflow-hidden flex relative">
<div class="h-full bg-success transition-all duration-500" style="width:{toolProgress.active.length > 0 ? (toolProgress.done.length - toolProgress.errored.length) / toolProgress.active.length * 100 : 0}%"></div>
<div class="h-full bg-warning/70 transition-all duration-500" style="width:{toolProgress.active.length > 0 ? toolProgress.errored.length / toolProgress.active.length * 100 : 0}%"></div>
{#if search.status === "running"}
<div class="shimmer absolute inset-0 pointer-events-none"></div>
{/if}
</div>
<details class="group">
<summary
class="flex items-center gap-1 cursor-pointer text-xs text-base-content/40
hover:text-base-content/70 transition-colors list-none select-none w-fit"
>
<ChevronRight size={12} class="transition-transform duration-200 group-open:rotate-90" />
Show tools
</summary>
<div class="flex flex-wrap gap-2 pt-3">
{#each search.planned_tools as t}
{@const d = grouped[t.name]}
{@const isErrored = d?.done && d.errors.length > 0 && d.output.length === 0}
{#if t.skipped}
<div class="tooltip" data-tip={t.reason}>
<span class="badge badge-warning badge-sm font">{t.name}</span>
</div>
{:else if isErrored}
<div class="tooltip" data-tip={d.errors[0] ?? "error"}>
<span class="badge badge-warning badge-sm">{t.name}</span>
</div>
{:else if d?.done}
<a href="/tools/{t.name}" class="badge badge-success badge-sm hover:badge-outline transition-all gap-1">
<Check size={10} />{t.name}
</a>
{:else if search.status === "running"}
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
<span class="loading loading-ring loading-xs mr-1"></span>{t.name}
</a>
{:else}
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
{t.name}
</a>
{/if}
{/each}
</div>
</details>
{#if demo}
<p class="text-xs text-base-content/40 italic">Results shown are not exhaustive — demo mode only displays a subset of what the tools can find.</p>
{/if}
</div>
</div>
{/if}
{#if Object.keys(grouped).length === 0 && search.status === "running"}
<p class="text-base-content/40 text-sm text-center py-8">Waiting for results...</p>
{:else if Object.keys(grouped).length === 0}
<p class="text-base-content/40 text-sm text-center py-8">No results.</p>
{:else}
<div class="flex flex-col gap-4">
{#each sortedEntries.withResults as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
{#each sortedEntries.running as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
{#if sortedEntries.noResults.length > 0}
<details class="group">
<summary
class="flex items-center gap-2 cursor-pointer select-none list-none
text-sm text-base-content/40 hover:text-base-content/60 transition-colors"
>
<ChevronRight size={14} class="transition-transform duration-200 group-open:rotate-90" />
No results
<span class="font-mono text-xs">({sortedEntries.noResults.length})</span>
</summary>
<div class="flex flex-col gap-3 pt-3">
{#each sortedEntries.noResults as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
</div>
</details>
{/if}
</div>
{/if}
{/if}
{#snippet toolCard(toolName, data)}
{@const toolStatus = search.planned_tools?.find((t) => t.name === toolName)}
{@const resultCount = data.output.length > 0 ? (toolStatus?.result_count ?? null) : null}
<div class="card bg-base-200 shadow">
<details class="group" open>
<summary class="card-body gap-3 p-4 cursor-pointer list-none">
<div class="flex items-center gap-3 flex-wrap">
<ChevronRight size={14} class="shrink-0 opacity-40 transition-transform duration-200 group-open:rotate-90" />
<a href="/tools/{toolName}" class="font-bold hover:underline underline-offset-2" onclick={(e) => e.stopPropagation()}>
{toolName}
</a>
{#if !data.done}
<span class="badge badge-warning badge-sm">
<span class="loading loading-ring loading-xs mr-1"></span>running
</span>
{:else if data.errors.length > 0 && data.output.length === 0}
<span class="badge badge-error badge-sm">error</span>
{:else}
<span class="badge badge-success badge-sm">done</span>
{/if}
<span class="text-xs text-base-content/40 ml-auto">
{#if resultCount === null}
output
{:else}
{resultCount} result{resultCount !== 1 ? "s" : ""}
{/if}
{#if data.errors.length > 0}
· <span class="text-error">{data.errors.length} error{data.errors.length !== 1 ? "s" : ""}</span>
{/if}
</span>
</div>
</summary>
<div class="px-4 pb-4 flex flex-col gap-3">
{#each data.errors as err}
<div class="alert alert-error py-2 text-sm gap-2"><AlertTriangle size={14} class="shrink-0" />{err}</div>
{/each}
{#if data.output.length > 0}
<TtyOutput output={data.output} />
{:else if data.done && data.errors.length === 0}
<p class="text-sm text-base-content/40">No results.</p>
{/if}
</div>
</details>
</div>
{/snippet}
{#if toast}
<div class="toast toast-end toast-bottom z-50">
<div class="alert {toast.type} shadow-lg">
<span>{toast.msg}</span>
</div>
</div>
{/if}
<style>
.shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.18) 50%,
transparent 100%
);
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
</style>

View File

@@ -0,0 +1,71 @@
<script>
import { INPUT_TYPE_ICON } from "@src/lib/vars";
import { FileText, X } from "@lucide/svelte";
let { searches = [], onDelete = async () => {} } = $props();
const STATUS_BADGE = {
running: "badge-warning",
done: "badge-success",
cancelled: "badge-error",
};
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
</script>
{#if searches.length === 0}
<p class="text-base-content/40 text-sm text-center py-8">No searches yet. Run one above.</p>
{:else}
<div class="flex flex-col gap-2">
{#each searches as s (s.id)}
<a
href={`/search/${s.id}`}
class="card bg-base-200 hover:bg-base-300 transition-colors shadow-sm cursor-pointer"
>
<div class="card-body flex-row items-center gap-4 py-3 px-4">
<div class="text-base-content/40 w-6 flex items-center justify-center shrink-0">
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
<Icon size={16} />
{/each}
</div>
<div class="flex flex-col min-w-0 flex-1">
<span class="font-mono font-semibold truncate">{s.target}</span>
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
<span>{s.input_type}</span>
{#if s.profile}
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
{/if}
<span>· {fmtDate(s.started_at)}</span>
</div>
</div>
{#if s.status !== "running"}
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
{#if total > 0}
<span class="text-xs font-mono text-base-content/50 shrink-0">{total} result{total !== 1 ? "s" : ""}</span>
{/if}
{/if}
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm shrink-0">
{#if s.status === "running"}
<span class="loading loading-ring loading-xs mr-1"></span>
{/if}
{s.status}
</span>
<button
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error shrink-0"
onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
title="Delete"
><X size={14} /></button>
</div>
</a>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,265 @@
<script>
import { onMount } from "svelte";
import { Save, Trash2, AlertTriangle, Package } from "@lucide/svelte";
import ToolIcon from "./comps/ToolIcon.svelte";
let { name = null } = $props();
let resolvedName = $state("");
let tool = $state(null);
let error = $state("");
let edits = $state({});
let saving = $state(false);
let msg = $state(null);
let hasGlobalConfig = $state(false);
let configReadonly = $state(false);
onMount(async () => {
resolvedName = name ?? window.location.pathname.replace(/^\/tools\//, "").replace(/\/$/, "");
try {
const [toolRes, cfgRes] = await Promise.all([
fetch(`/api/tools/${encodeURIComponent(resolvedName)}`),
fetch("/api/config"),
]);
if (!toolRes.ok) {
const body = await toolRes.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${toolRes.status}`);
}
tool = await toolRes.json();
const cfg = cfgRes.ok ? await cfgRes.json() : { tools: {} };
configReadonly = cfg.readonly ?? false;
const curMap = cfg.tools?.[resolvedName] ?? {};
hasGlobalConfig = !!cfg.tools?.[resolvedName];
const next = {};
for (const f of tool.config_fields ?? []) {
const saved = curMap[f.name];
next[f.name] = saved !== undefined && saved !== null
? saved
: (f.value !== undefined && f.value !== null ? f.value : (f.default ?? defaultForType(f.type)));
}
edits = next;
} catch (e) {
error = e.message;
}
});
function defaultForType(type) {
if (type === "bool") return false;
if (type === "int" || type === "float") return 0;
return "";
}
async function save() {
saving = true;
msg = null;
try {
const res = await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(edits),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
hasGlobalConfig = true;
msg = { ok: true, text: "Saved" };
} catch (e) {
msg = { ok: false, text: e.message };
} finally {
saving = false;
setTimeout(() => (msg = null), 3000);
}
}
async function clearConfig() {
if (!confirm(`Clear global config for "${resolvedName}"?`)) return;
try {
await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, { method: "DELETE" });
hasGlobalConfig = false;
const next = {};
for (const f of tool.config_fields ?? []) next[f.name] = f.default ?? defaultForType(f.type);
edits = next;
msg = { ok: true, text: "Cleared" };
} catch (e) {
msg = { ok: false, text: e.message };
} finally {
setTimeout(() => (msg = null), 3000);
}
}
</script>
{#if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else if !tool}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="flex flex-col gap-6 max-w-2xl">
{#if tool.available === false}
<div class="alert alert-error gap-3">
<AlertTriangle size={18} class="shrink-0" />
<div>
<p class="font-semibold text-sm">Tool unavailable</p>
{#if tool.unavailable_reason}
<p class="text-sm opacity-80">{tool.unavailable_reason}</p>
{/if}
</div>
</div>
{/if}
<div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex items-center gap-3">
<ToolIcon iconName={tool.icon} size={32} />
<div class="pl-4">
<h1 class="text-2xl sm:text-3xl font-bold mb-1">{tool.name}</h1>
{#if tool.description}
<p class="text-base-content/60">{tool.description}</p>
{/if}
</div>
</div>
{#if tool.link}
<a href={tool.link} target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-sm gap-1">
↗ source
</a>
{/if}
</div>
<div class="card bg-base-200">
<div class="card-body gap-3 p-4">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Accepted input types</h2>
<div class="flex flex-wrap gap-2">
{#each tool.input_types as t}
<span class="badge badge-outline border-base-content/20">{t}</span>
{/each}
</div>
</div>
</div>
{#if tool.dependencies?.length > 0}
<div class="card bg-base-200">
<div class="card-body gap-3 p-4">
<h2 class="text-xs uppercase tracking-widest text-base-content/50 flex items-center gap-2">
<Package size={13} /> External dependencies
</h2>
<ul class="flex flex-col gap-1">
{#each tool.dependencies as dep}
<li class="flex items-center gap-2">
<span class="font-mono text-sm bg-base-300 px-2 py-0.5 rounded">{dep}</span>
<span class="text-xs text-base-content/40">must be in <code>$PATH</code></span>
</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if tool.config_fields?.length > 0}
<div class="card bg-base-200">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Global config</h2>
{#if hasGlobalConfig}
<span class="badge badge-outline badge-xs">configured</span>
{/if}
{#if configReadonly}
<span class="badge badge-ghost badge-xs">read-only</span>
{/if}
</div>
{#if msg}
<span class="text-xs {msg.ok ? 'text-success' : 'text-error'}">{msg.text}</span>
{/if}
</div>
{#each tool.config_fields as field}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono font-semibold text-sm">{field.name}</span>
<span class="badge badge-ghost badge-xs">{field.type}</span>
{#if field.required}
<span class="badge badge-error badge-xs">required</span>
{/if}
</div>
{#if field.description}
<p class="text-sm text-base-content/60">{field.description}</p>
{/if}
<div class="text-xs text-base-content/40 font-mono mb-1">
default: {field.default ?? "-"}
</div>
{#if field.type === "bool"}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
bind:checked={edits[field.name]}
disabled={configReadonly}
/>
<span class="text-sm text-base-content/50">
{edits[field.name] ? "enabled" : "disabled"}
</span>
</label>
{:else if field.type === "int"}
<input
type="number"
step="1"
class="input input-bordered input-sm font-mono w-full max-w-48"
bind:value={edits[field.name]}
/>
{:else if field.type === "float"}
<input
type="number"
step="any"
class="input input-bordered input-sm font-mono w-full max-w-48"
bind:value={edits[field.name]}
/>
{:else if field.type === "enum"}
<select
class="select select-bordered select-sm font-mono w-full max-w-xs"
bind:value={edits[field.name]}
>
{#each field.options as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
type="text"
class="input input-bordered input-sm font-mono w-full max-w-xs"
bind:value={edits[field.name]}
/>
{/if}
</div>
{/each}
{#if !configReadonly}
<div class="flex gap-2 pt-1 flex-wrap">
<button
class="btn btn-primary btn-sm gap-1"
onclick={save}
disabled={saving}
>
{#if saving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{#if hasGlobalConfig}
<button class="btn btn-ghost btn-sm gap-1 text-error" onclick={clearConfig}>
<Trash2 size={14} /> Reset to defaults
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,346 @@
<script>
import { onMount } from "svelte";
import { AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
import ToolIcon from "./comps/ToolIcon.svelte";
let tools = $state([]);
let config = $state({ tools: {}, profiles: {} });
let profileSummaries = $state([]);
let selectedProfile = $state("default");
let profileDetail = $state(null);
let loading = $state(true);
let profileLoading = $state(false);
let error = $state("");
let selectedInputType = $state("all");
const inputTypeOptions = ["all", ...INPUT_TYPES];
let profileOptions = $derived(profileSummaries.map((p) => p.name));
let activeSet = $derived(
profileDetail
? new Set(profileDetail.active_tools ?? [])
: new Set(tools.map((t) => t.name)),
);
let globalToolConf = $derived(config.tools ?? {});
let profileOverrides = $derived(profileDetail?.tools ?? {});
let toolsWithStatus = $derived(
tools.map((tool) => {
const isActive = activeSet.has(tool.name);
const effective = {
...(globalToolConf[tool.name] ?? {}),
...(profileOverrides[tool.name] ?? {}),
};
const missingConfig = (tool.config_fields ?? []).some((f) => {
if (!f.required) return false;
const v = effective[f.name];
return v === undefined || v === null || v === "";
});
const unavailable = tool.available === false;
return { ...tool, isActive, missingConfig, unavailable };
}),
);
let visibleTools = $derived(
selectedInputType === "all"
? toolsWithStatus
: toolsWithStatus.filter((t) =>
t.input_types.includes(selectedInputType),
),
);
let active = $derived(
visibleTools.filter(
(t) => t.isActive && !t.missingConfig && !t.unavailable,
),
);
let activeMissing = $derived(
visibleTools.filter((t) => t.isActive && t.missingConfig && !t.unavailable),
);
let activeUnavail = $derived(
visibleTools.filter((t) => t.isActive && t.unavailable),
);
let inactive = $derived(
visibleTools.filter(
(t) => !t.isActive && !t.missingConfig && !t.unavailable,
),
);
let inactiveMissing = $derived(
visibleTools.filter(
(t) => !t.isActive && t.missingConfig && !t.unavailable,
),
);
let inactiveUnavail = $derived(
visibleTools.filter((t) => !t.isActive && t.unavailable),
);
onMount(async () => {
try {
const [tr, cr, pr] = await Promise.all([
fetch("/api/tools"),
fetch("/api/config"),
fetch("/api/config/profiles"),
]);
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
if (!cr.ok) throw new Error(`HTTP ${cr.status}`);
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
tools = await tr.json();
config = await cr.json();
profileSummaries = await pr.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
async function selectProfile(name) {
selectedProfile = name;
profileLoading = true;
try {
const res = await fetch(
`/api/config/profiles/${encodeURIComponent(name)}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
profileDetail = await res.json();
} catch (e) {
error = e.message;
} finally {
profileLoading = false;
}
}
</script>
{#snippet toolCard(tool, missing)}
<div
class="card bg-base-200 group-hover:bg-base-300 transition-colors shadow-sm h-full
{missing ? 'border border-warning/40' : ''}
{tool.unavailable ? 'border border-error/40' : ''}"
>
<div class="card-body p-4 flex-row items-start gap-0">
<div
class="size-10 rounded-lg bg-base-300 group-hover:bg-base-200 transition-colors
flex items-center justify-center shrink-0 mr-3 mt-0.5"
>
<ToolIcon iconName={tool.icon} size={20} />
</div>
<div class="flex flex-col min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-bold text-sm leading-tight">{tool.name}</span>
{#if tool.unavailable}
<span class="badge badge-error badge-xs gap-1">
<AlertTriangle size={9} /> unavailable
</span>
{:else if missing}
<span class="badge badge-warning badge-xs gap-1">
<AlertTriangle size={9} /> config required
</span>
{/if}
</div>
{#if tool.unavailable && tool.unavailable_reason}
<p class="text-xs text-error/70 leading-relaxed">
{tool.unavailable_reason}
</p>
{:else if tool.description}
<p class="text-xs text-base-content/50 line-clamp-2 leading-relaxed">
{tool.description}
</p>
{/if}
<div class="flex flex-wrap gap-1">
{#each tool.input_types as t}
<span class="badge badge-xs badge-outline border-base-content/20">{t}</span>
{/each}
</div>
</div>
</div>
</div>
{/snippet}
{#if loading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else}
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Profile</span
>
<Select
options={profileOptions}
selected={selectedProfile}
onselect={selectProfile}
/>
{#if profileLoading}
<span class="loading loading-spinner loading-xs opacity-40"></span>
{/if}
</div>
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Input</span
>
<Select
options={inputTypeOptions}
selected={selectedInputType}
onselect={(val) => (selectedInputType = val)}
/>
</div>
</div>
{#if tools.length === 0}
<p class="text-base-content/40 text-sm text-center py-8">
No tools registered.
</p>
{:else}
<div class="flex flex-col gap-6">
{#if active.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-success shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active</span
>
<span class="text-xs text-base-content/30">{active.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each active as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-warning shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active - required config missing</span
>
<span class="text-xs text-base-content/30"
>{activeMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeMissing as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-error shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-error/70"
>Active - unavailable</span
>
<span class="text-xs text-base-content/30"
>{activeUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeUnavail as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if active.length + activeMissing.length + activeUnavail.length > 0 && inactive.length + inactiveMissing.length + inactiveUnavail.length > 0}
<div class="divider opacity-20 my-0"></div>
{/if}
{#if inactive.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled</span
>
<span class="text-xs text-base-content/20">{inactive.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactive as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - required config missing</span
>
<span class="text-xs text-base-content/20"
>{inactiveMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveMissing as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - unavailable</span
>
<span class="text-xs text-base-content/20"
>{inactiveUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveUnavail as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
</div>
{/if}
{/if}

View File

@@ -0,0 +1,29 @@
<script>
let { text, color = null, icon: IconComponent = null, size = "sm", loading = false } = $props();
const colorDefaults = {
"done": "badge-success",
"error": "badge-error",
"unavailable": "badge-error",
"running": "badge-warning",
"cancelled": "badge-error",
"required": "badge-error",
"read-only": "badge-ghost",
"active": "badge-success",
"disabled": "badge-ghost",
"configured": "badge-outline",
"configurable": "badge-outline",
"config required":"badge-warning",
};
let cls = $derived(color ?? colorDefaults[text?.toLowerCase()] ?? "badge-ghost");
</script>
<span class="badge badge-{size} {cls} gap-1">
{#if loading}
<span class="loading loading-ring loading-xs"></span>
{:else if IconComponent}
<IconComponent size={9} />
{/if}
{text}
</span>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { CircleQuestionMark } from "@lucide/svelte";
let {
tooltip,
}: {
tooltip: string;
} = $props();
</script>
<div class="tooltip" data-tip={tooltip}>
<CircleQuestionMark class="size-3 text-base-content/40 align-middle"/>
</div>

View File

@@ -0,0 +1,84 @@
<script>
import { ChevronDown } from "@lucide/svelte";
let {
options = [],
placeholder = "",
selected = null,
onselect,
size = "sm", // "xs" | "sm" | ""
} = $props();
let open = $state(false);
let query = $state("");
let container;
let input = $state();
let filtered = $derived(options.filter((o) =>
o.toLowerCase().includes(query.toLowerCase())
));
function select(value) {
onselect?.(value);
open = false;
query = "";
}
function toggle() {
open = !open;
if (open) setTimeout(() => input?.focus(), 0);
}
</script>
<svelte:window onmousedown={(e) => {
if (container && !container.contains(e.target)) {
open = false;
query = "";
}
}} />
<div class="relative" bind:this={container}>
<button
type="button"
class="btn btn-ghost btn-{size} gap-1 font-normal"
onclick={toggle}
>
<ChevronDown size={11} />
{selected ?? placeholder}
</button>
{#if open}
<div
class="absolute z-50 top-full left-0 mt-1 w-52 bg-base-300 rounded-box shadow-xl border border-base-content/10 flex flex-col"
>
<div class="p-2 border-b border-base-content/10">
<input
bind:this={input}
type="text"
class="input input-bordered input-xs w-full"
placeholder="Search..."
bind:value={query}
onkeydown={(e) => { if (e.key === "Escape") { open = false; query = ""; } }}
/>
</div>
<ul class="max-h-48 overflow-y-auto p-1">
{#if filtered.length === 0}
<li class="px-3 py-2 text-xs text-base-content/40 text-center">No results</li>
{:else}
{#each filtered as option}
<li>
<button
type="button"
class="w-full text-left px-3 py-1.5 text-sm font-mono rounded-btn transition-colors
{option === selected ? 'bg-primary/15 text-primary font-semibold' : 'hover:bg-base-content/10'}"
onclick={() => select(option)}
>
{option}
</button>
</li>
{/each}
{/if}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
const { iconName = "", size=16 }: { iconName: string , size: number} = $props();
const genericFallbackUrl = "/Wrench.svg";
</script>
{#if iconName}
<img
src="https://cdn.simpleicons.org/{iconName}"
alt={iconName + " icon"}
class="opacity-50"
width={size}
height={size}
style="filter: brightness(0) invert(1);"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = genericFallbackUrl;
}}
/>
{:else}
<img
src={genericFallbackUrl}
alt={"Tool icon"}
class="opacity-50"
width={size}
height={size}
style="filter: brightness(0) invert(1);"
/>
{/if}

View File

@@ -0,0 +1,63 @@
<script>
import { AnsiUp } from "ansi_up";
import DOMPurify from "dompurify";
let { output } = $props();
const au = new AnsiUp();
au.use_classes = false;
const ansiRe = /\x1b\[[0-9;]*m/g;
const urlRe = /https?:\/\/[^\s<>"']+/g;
const tlds = "com|org|net|io|fr|de|uk|co|info|biz|eu|us|ca|au|ru|cn|jp|br|in|es|dev|app|me|tv|cc|ch|be|nl|se|no|dk|fi|pl|cz|at|hu|ro";
const bareDomainRe = new RegExp(`(?<![a-zA-Z0-9@])[a-zA-Z0-9][a-zA-Z0-9\\-]*(?:\\.[a-zA-Z0-9][a-zA-Z0-9\\-]*)*\\.(?:${tlds})(?:/[^\\s<>"']*)?`, "g");
const makeLink = (href, text) =>
`<a href="${href}" target="_blank" rel="noopener noreferrer" class="ansi-link">${text}</a>`;
function linkifyText(text) {
// First pass: full URLs
const afterUrls = text.replace(urlRe, (url) => makeLink(url, url));
// Re-split to protect the newly created <a> tags, then linkify bare domains in remaining text
return afterUrls.split(/(<[^>]+>)/).map((part) => {
if (part.startsWith("<")) return part;
return part.replace(bareDomainRe, (domain) => makeLink(`https://${domain}`, domain));
}).join("");
}
function linkify(html) {
return html.split(/(<[^>]+>)/).map((part) =>
part.startsWith("<") ? part : linkifyText(part)
).join("");
}
let html = $derived((() => {
const lines = output.split("\n");
let start = 0;
let end = lines.length - 1;
while (start <= end && lines[start].replace(ansiRe, "").trim() === "") start++;
while (end >= start && lines[end].replace(ansiRe, "").trim() === "") end--;
return DOMPurify.sanitize(linkify(au.ansi_to_html(lines.slice(start, end + 1).join("\n"))), {
ALLOWED_TAGS: ["span", "a", "b"],
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
});
})());
</script>
{#if html}
<div class="ansi-output">{@html html}</div>
{/if}
<style>
.ansi-output {
line-height: 1.35;
}
.ansi-output :global(.ansi-link) {
text-decoration: underline;
text-underline-offset: 2px;
opacity: 0.8;
}
.ansi-output :global(.ansi-link:hover) {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,14 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const cheatsheets = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/cheatsheets" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
order: z.number().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { cheatsheets };

View File

@@ -0,0 +1,134 @@
---
title: "Unmasking Github Users: How to Identify the Person Behind Any Github Profile"
description: "Ever wondered who is behind a specific Github username? This guide covers advanced OSINT techniques to deanonymize users, find hidden email addresses, and link Github accounts to real-world identities."
tags: [github, social]
---
In the world of Open-Source Intelligence (OSINT), we often focus on social media platforms like Twitter or LinkedIn. However, developers frequently leave behind much more detailed personal information on **Github**.
Whether you are a recruiter, a security researcher, or a digital investigator, Github is a goldmine. Why? Because while a user might choose a cryptic handle like `anotherhadi`, their Git configuration often reveals their real name and email address.
## Level 1: The Low-Hanging Fruit
Before diving into technical exploits, start with the obvious. Many users forget how much they have shared in their profile settings.
- **The Bio & Location**: Even a vague location like "Montpellier, France," combined with a niche tech stack (e.g., "COBOL expert"), significantly narrows down the search.
- **External Links**: Check the personal website or blog link. Run a WHOIS lookup on that domain to find registration details. Use other OSINT tools and techniques on those websites to pivot further.
- **The Profile Picture**: Right-click the avatar and use Google Reverse Image Search, Yandex, or other reverse image engines. Developers often use the same professional headshot on Github as they do on LinkedIn.
## Level 2: Digging into Commits
This is the **most effective OSINT** method. While Github masks author names and emails in the web view, this information is permanently embedded in the commit metadata.
### The `.patch` Method
Find a repository where the target has contributed. Open any commit they made, and simply add `.patch` to the end of the URL.
- **URL**: `https://github.com/{username}/{repo}/commit/{commit_hash}.patch`
- Look at the `From:` line. It should look like this: `From: John Doe <j.doe@company.com>`
For example, check: [github.com/anotherhadi/nixy/commit/e6873e8caae491073d8ab7daad9d2e50a04490ce.patch](https://github.com/anotherhadi/nixy/commit/e6873e8caae491073d8ab7daad9d2e50a04490ce.patch)
### The API Events Method
If you cannot find a recent commit, check their **public activity** stream via the Github API.
- **Go to**: `https://api.github.com/users/{target_username}/events/public`
- Search (Ctrl+F) for the word `email`. You will often find the **email address** associated with their `PushEvent` headers, even if they have "Keep my email addresses private" enabled in their current settings.
## The Verification Loop: Linking Email to Account
If you have found an email address and want to be 100% sure it belongs to a specific Github profile, you can use Githubs own attribution engine against itself.
### The Email Spoofing Method
While the previous methods help you find an email _from_ a profile, this technique does the opposite: it identifies which Github account is linked to a specific email address.
**How it works:**
Github attributes commits based on the email address found in the Git metadata. If you push a commit using a specific email, Github will automatically link that commit to the account associated with that address as its **primary email**.
**The Process:**
1. **Initialize a local repo:** `git init investigation`
2. **Configure the target email:** `git config user.email "target@example.com"` and `git config user.name "A Username"`
3. **Create a dummy commit:** `echo "test" > probe.txt && git add . && git commit -m "Probe"`
4. **Push to a repo you own:** Create a new empty repository on your Github account and push the code there.
5. **Observe the result:** Go to the commit history on the Github web interface. The avatar and username of the account linked to that email will appear as the author of the commit.
> **Note:** This method only works if the target email is set as the **Primary Email** on the user's account. It is a foolproof way to confirm if an email address you found elsewhere belongs to a specific Github user.
### The Search Index: Finding Hidden Contributions
Even if an email address is not listed on a user's profile, it may still be indexed within Github's global search.
Github allows you to filter search results by the metadata fields of a commit.
This is particularly useful if the target has **contributed to public repositories** using their real email.
You can use these specific qualifiers in the **Github search bar** (select the "Commits" tab):
- `author-email:target@example.com`: Finds commits where the target is the original author.
- `committer-email:target@example.com`: Finds commits where the target was the one who committed the code (sometimes different from the author).
## Level 3: Technical Metadata
If the email is masked or missing, we can look at the **cryptographic keys** the user uses to communicate with Github.
### SSH Keys
Every users public **SSH keys are public**.
- **URL**: `https://github.com/{username}.keys`
- **The Pivot**: You can take the key string and search for it on platforms like **Censys** or **Shodan**. If that same key is authorized on a specific server IP, you have successfully located the users infrastructure.
### GPG Keys
If a user signs their commits, their **GPG key** is available at:
- **URL**: `https://github.com/{username}.gpg`
- **The Reveal**: Import this key into your local GPG tool (`gpg --import`). It will often reveal the **Verified Identity** and the primary email address linked to the encryption key.
## Level 4: Connecting the Dots
Once you have a **name**, an **email**, or a **unique username**, its time to _pivot_.
- **Username Pivoting**: Use tools like [Sherlock](https://github.com/sherlock-project/sherlock) or [Maigret](https://github.com/soxoj/maigret/) to search for the same username across hundreds of other platforms. Developers are creatures of habit; they likely use the same handle on Stack Overflow, Reddit, or even old gaming forums.
- **Email Pivoting**: Use tools like [holehe](https://github.com/megadose/holehe) to find other accounts registered with the email addresses you just uncovered.
## Automating the Hunt: Github-Recon
If you want to move from manual investigation to automated intelligence, check out [Github-Recon](https://github.com/anotherhadi/github-recon).
Written in Go, this powerful CLI tool aggregates public OSINT data by automating the techniques mentioned above and more. Whether you start with a username or a single email address, it can retrieve SSH/GPG keys, enumerate social accounts, and find "close friends" based on interactions.
Its standout features include a **Deep Scan** mode-which clones repositories to perform regex searches and TruffleHog secret detection—and an automated **Email Spoofing** engine that instantly identifies the account linked to any primary email address.
<a href="https://github.com/anotherhadi/github-recon" class="link-card" target="_blank">
<span>
<h4>anotherhadi/github-recon</h4>
<p>GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email.</p>
</span>
</a>
## Conclusion and Protection: How to Stay Anonymous
If you are a developer reading this, you might be feeling exposed.
Understanding what information about you is publicly visible is the **first step to managing your online presence**. This guide and tools like [github-recon](https://github.com/anotherhadi/github-recon) can help you identify your own publicly available data on Github. Heres how you can take steps to protect your privacy and security:
- **Review your public profile**: Regularly check your Github profile and
repositories to ensure that you are not unintentionally exposing sensitive
information.
- **Manage email exposure**: Use Github's settings to control which email
addresses are visible on your profile and in commit history. You can also **use
a no-reply email** address for commits, and an
[alias email](https://proton.me/support/addresses-and-aliases) for your
account. Delete/modify any sensitive information in your commit history.
- **Be Mindful of Repository Content**: **Avoid including sensitive information** in
your repositories, such as API keys, passwords, emails or personal data. Use
`.gitignore` to exclude files that contain sensitive information.
You can also use a tool like [TruffleHog](github.com/trufflesecurity/trufflehog)
to scan your repositories specifically for exposed secrets and tokens.
**Useful links:**
- [Blocking command line pushes that expose your personal email address](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/blocking-command-line-pushes-that-expose-your-personal-email-address)
- [No-reply email address](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address)
In OSINT, the best hidden secrets are the ones we forget we ever shared. Happy hunting!

View File

@@ -0,0 +1,100 @@
---
title: "Google Dorks"
description: "Essential cheatsheet for Google Dorking, using advanced search operators to perform Open Source Intelligence (OSINT) and identify publicly exposed information or misconfigurations on target websites."
tags: [google, dorks]
---
[Google](https://google.com) hacking, also named Google dorking, is a hacker technique that uses Google Search and other Google applications to find security holes in the configuration and computer code that websites are using.
Dorks also works on [Startpage](https://startpage.com) or [Duckduckgo](https://duckduckgo.com).
## Basics
- `-` excludes a term
- `OR` searches for either term
- `""` searches for an exact phrase
- `*` acts as a wildcard
- `site:` restricts the search to a specific domain
- `inurl:` restricts the search to a specific URL
- `intitle:` restricts the search to a specific title
- `intext:` restricts the search to a specific text
- `allintext:` restricts the search to all text
- `filetype:` restricts the search to a specific file type
## Information gathering
Replace "{target}" with a name or other identifiers used online. Always remember
to use these queries solely for legal and ethical purposes on information you
own or have permission to check.
- **File Types:**
- `"{target}" filetype:pdf`
- `"{target}" filetype:doc OR filetype:docx OR filetype:xls OR filetype:ppt`
- Config files:
`site:{target}+filetype:xml+|+filetype:conf+|+filetype:cnf+|+filetype:reg+|+filetype:inf+|+filetype:rdp+|+filetype:cfg+|+filetype:txt+|+filetype:ora+|+filetype:ini`
- Database files: `site:{target}+filetype:sql+|+filetype:dbf+|+filetype:mdb`
- Data files: `site:{target} ext:csv OR ext:xls OR ext:log` or `site:{target} "@gmail.com" ext:csv`
- Log files: `site:{target}+filetype:log+|filetype:txt` - Backup files:
`site:{target}+filetype:bkf+|+filetype:bkp+|+filetype:bak+|+filetype:old+|+filetype:backup`
- Setup files:
`site:{target}+inurl:readme+|+inurl:license+|+inurl:install+|+inurl:setup+|+inurl:config`
- Private files:
`site:{target} "internal use only" ( you can replace with "classified", "private", "unauthorised" )`
- Sensitive docs:
`ext:txt | ext:pdf | ext:xml | ext:xls | ext:xlsx | ext:ppt | ext:pptx | ext:doc | ext:docx intext:“confidential” | intext:“Not for Public Release” | intext:”internal use only” | intext:“do not distribute” site:{target}`
- Code leaks: Check for code snippets, secrets, configs
```txt
site:pastebin.com "{target}"
site:jsfiddle.net "{target}"
site:codebeautify.org "{target}"
site:codepen.io "{target}"`
```
- Cloud File Shares: Find exposed files linked to your target
```txt
site:http://drive.google.com "{target}"
site:http://docs.google.com inurl:"/d/" "{target}"
site:http://dropbox.com/s "{target}"
```
- Other: `site:{target}+filetype:pdf+|+filetype:xlsx+|+filetype:docx`
- **Social Media & Professional Networks:**
- `site:linkedin.com/in "{target}"`
- `site:facebook.com "{target}"`
- `site:twitter.com "{target}"`
- `site:instagram.com "{target}"`
- **Profile & Resume Searches:**
- `inurl:"profile" "{target}"`
- `intitle:"{target}" "profile"`
- `"{target}" intext:"resume"`
- `intitle:"Curriculum Vitae" OR intitle:"CV" "{target}"`
- **Email and Contact Information:**
- `"{target}" intext:"@gmail.com"`
- `"{target}" intext:"email"`
- `"{target}" AND "contact"`
- **Forums and Public Repositories:**
- `site:pastebin.com "{target}"`
- `site:github.com "{target}"`
- `site:forums "{target}"`
- **Directory Listings and Miscellaneous:**
- `site:{target}+intitle:index.of`,
- **Exclusion Searches:**
- `"{target}" -site:facebook.com`
- `"{target}" -site:twitter.com`
## Advanced Google Operators
- `related:site` finds websites similar to the specified URL
- `define:term` shows a word or phrase definition directly in the results
- `inanchor:word` filters pages where the anchor text includes the specified
word
- `around(n)` restricts results to pages where two words appear within _n_ words
of each other
## Ressources
- [TakSec's google dorks](https://github.com/TakSec/google-dorks-bug-bounty/)
- [Exploit-db Google hacking database](https://www.exploit-db.com/google-hacking-database)

View File

@@ -0,0 +1,61 @@
---
title: "Sock Puppets"
description: "Essential cheatsheet on creating and managing Sock Puppets (fake identities) for ethical security research and Open Source Intelligence (OSINT), focusing on maintaining separation from personal data and bypassing common verification."
tags: [sock-puppets]
---
Sock puppets are fake identities use to gather information from a target.
The sock puppet should have no link between your personal information and the fakes ones. (No ip address, mail, follow, etc..)
## Information generation
<a href="https://fakerjs.dev" class="link-card not-prose" target="_blank">
<span>
<h4>Faker</h4>
<p>Generate massive amounts of fake data</p>
</span>
</a>
<a href="https://fakenamegenerator.com/" class="link-card not-prose" target="_blank">
<span>
<h4>Fake Name</h4>
<p>Personal informations</p>
</span>
</a>
<a href="https://www.thispersondoesnotexist.com/" class="link-card not-prose" target="_blank">
<span>
<h4>This Person Does Not Exist</h4>
<p>Generate fake image</p>
</span>
</a>
## Bypass phone verification
<a href="https://www.smspool.net/" class="link-card not-prose" target="_blank">
<span>
<h4>SMSPool</h4>
<p>Cheapest and Fastest Online SMS verification</p>
</span>
</a>
<a href="https://onlinesim.io/" class="link-card not-prose" target="_blank">
<span>
<h4>Online Sim</h4>
<p>SMS verification with free tier</p>
</span>
</a>
<a href="https://sms4stats.com/" class="link-card not-prose" target="_blank">
<span>
<h4>Sms 4 Sats</h4>
<p>Paid SMS verification</p>
</span>
</a>
<a href="http://sms4sat6y7lkq4vscloomatwyj33cfeddukkvujo2hkdqtmyi465spid.onion" class="link-card not-prose" target="_blank">
<span>
<h4>Sms 4 Sats (Onion)</h4>
<p>Paid SMS verification. Tor version</p>
</span>
</a>

View File

@@ -0,0 +1,23 @@
---
title: "Tips"
description: "A cheatsheet of practical tips and unconventional methods for Open Source Intelligence (OSINT), focusing on advanced data visualization, information leakage detection, and utilizing web archives for historical data."
---
## Visualisation
Use [OSINTracker](https://app.osintracker.com/) to visualise your findings.
It allows you to create a graph of your findings, which can help you see connections and relationships between different pieces of information.
## Forgotten passwords
To find email addresses and phone numbers associated with an account, you can click on "Forgot password?" on the login page of a website. Be careful, though, this creates notifications and can be detected by the target, and often gives your information away.
## Archive Search
- [Wayback Machine](https://web.archive.org) stores over 618 billion web captures
- [Archive.today](https://archive.ph) creates on-demand snapshots, including for JS-heavy sites, with both a functional page and screenshot version
## Bookmarklets
- [K2SOsint/Bookmarklets](https://github.com/K2SOsint/Bookmarklets)
- [MyOsint.training](https://tools.myosint.training/)

View File

@@ -0,0 +1,88 @@
---
title: "Twitter/X OSINT"
description: "Essential cheatsheet for Open Source Intelligence (OSINT) on Twitter/X, detailing advanced search operators, engagement filters, and temporal/geographic capabilities for effective data collection."
tags: [social]
---
## Banner last update time
The banner URL includes a Unix timestamp indicating when the banner was last
updated.
For example:
`https://pbs.twimg.com/profile_banners/1564326938851921921/1750897704/600x200`
In this case, `1750897704` is the timestamp. You can convert it using
[unixtimestamp.com](https://www.unixtimestamp.com/) or any other Unix time converter.
## Basic Search Operators
Twitter's advanced search functionality provides powerful filtering capabilities
for OSINT investigations:
- **Keywords**: `word1 word2` (tweets containing both words)
- **Exact phrases**: `"exact phrase"` (tweets with this exact sequence)
- **Exclusion**: `-word` (excludes tweets containing this word)
- **Either/or**: `word1 OR word2` (tweets containing either term)
- **Hashtags**: `#hashtag` (tweets with specific hashtag)
- **Accounts**: `from:username` (tweets sent by specific account)
- **Mentions**: `to:username` (tweets in reply to an account)
- **Mentions in any context**: `@username` (tweets mentioning an account)
## Advanced Filters
<a href="https://x.com/search-advanced" class="link-card" target="_blank">
<span>
<h4>Twitter/X Search advanced GUI</h4>
<p>Graphical User Interface (GUI) for the twitter search advanced functionality</p>
</span>
</a>
### Engagement Filters
- **Minimum retweets**: `min_retweets:number`
- **Minimum likes**: `min_faves:number`
- **Minimum replies**: `min_replies:number`
- **Filter for links**: `filter:links`
- **Filter for media**: `filter:media`
- **Filter for images**: `filter:images`
- **Filter for videos**: `filter:videos`
### Temporal and Geographic Filters
- **Date range**: `since:YYYY-MM-DD until:YYYY-MM-DD`
- **Geolocation**: `geocode:latitude,longitude,radius` (e.g.,
`geocode:40.7128,-74.0060,5km`)
- **Language**: `lang:code` (e.g., `lang:en` for English)
### Tweet Characteristics
- **Positive attitude**: `🙂 OR :) OR filter:positive`
- **Negative attitude**: `🙁 OR :( OR filter:negative`
- **Questions**: `?` or `filter:questions`
- **Retweets only**: `filter:retweets`
- **Native retweets only**: `filter:nativeretweets`
- **Twitter Blue subscribers**: `filter:verified` (note: since 2023, "verified" means Twitter Blue subscriber, not a traditionally verified account)
- **Safe content**: `filter:safe`
## Practical Search Combinations
- **Content from a user within a date range**:
`from:username since:2023-01-01 until:2023-12-31`
- **High-engagement tweets about a topic**:
`"artificial intelligence" min_retweets:100 lang:en -filter:retweets`
- **Media shared by a specific user**:
`from:username filter:media -filter:retweets`
- **Conversations between specific users**:
`from:username1 to:username2 OR from:username2 to:username1`
- **Link sharing on a topic by verified users**:
`"climate change" filter:links filter:verified since:2023-01-01`
## Disclaimer
Remember that all Twitter searches should comply with Twitter's Terms of Service
and appropriate legal frameworks for your jurisdiction.

View File

@@ -0,0 +1,92 @@
---
import "@src/styles/global.css";
import "@src/styles/gfm.css";
import "@src/styles/markdown.css";
import Navbar from "@src/components/Nav.svelte";
import DemoBanner from "@src/components/DemoBanner.svelte";
import { Coffee } from "@lucide/svelte";
interface Props {
title?: string;
description?: string;
}
const {
title = "iknowyou",
description = "Self-hosted OSINT aggregation. Run multiple recon tools against a target in parallel and get results in one place.",
} = Astro.props;
const pageTitle = title === "iknowyou" ? title : `${title} — iky`;
const canonicalURL = new URL(Astro.url.pathname, Astro.site ?? Astro.url.origin);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<meta property="og:type" content="website" />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:site_name" content="iknowyou" />
</head>
<body class="bg-base-100 min-h-screen">
<DemoBanner client:only="svelte" />
<Navbar client:load>
<a
href="https://ko-fi.com/anotherhadi"
slot="action"
target="_blank"
class="btn btn-primary btn-sm"
><Coffee class="size-3" /> Support me</a
>
</Navbar>
<div class="m-auto max-w-5xl md:py-10 md:px-10 py-5 px-5 animate-fade-in">
<slot />
</div>
</body>
<script>
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const CHECK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
document.querySelectorAll<HTMLElement>("pre[data-lang]").forEach((pre) => {
const lang = pre.dataset.lang ?? "text";
const wrapper = document.createElement("div");
wrapper.className = "code-block";
const header = document.createElement("div");
header.className = "code-header";
const langSpan = document.createElement("span");
langSpan.className = "code-lang";
langSpan.textContent = lang;
const copyBtn = document.createElement("button");
copyBtn.className = "copy-btn";
copyBtn.innerHTML = `${COPY_ICON} copy`;
copyBtn.addEventListener("click", () => {
const code = pre.querySelector("code");
if (!code) return;
navigator.clipboard.writeText(code.innerText).then(() => {
copyBtn.classList.add("copied");
copyBtn.innerHTML = `${CHECK_ICON} copied!`;
setTimeout(() => {
copyBtn.classList.remove("copied");
copyBtn.innerHTML = `${COPY_ICON} copy`;
}, 2000);
});
});
header.appendChild(langSpan);
header.appendChild(copyBtn);
wrapper.appendChild(header);
pre.parentNode!.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
});
</script>
</html>

17
front/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,17 @@
export function cleanUserInput(query: string | undefined | null): string {
if (!query) return "";
return query.replace(/[^a-zA-Z0-9\s.\-_/]/g, "").trim();
}
export function getRandomEmoji(): string {
const emojis = [
"(·.·)",
"(>_<)",
"¯\\_(ツ)_/¯",
"(╯_╰)",
"(-_-)",
"┐(`;)┌",
"(X_X)",
];
return emojis[Math.floor(Math.random() * emojis.length)];
}

16
front/src/lib/vars.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Mail, User, Phone, Globe, Server, KeyRound, Contact } from "@lucide/svelte";
export const INPUT_TYPES = [
"email", "username", "name", "phone", "ip",
"domain", "password",
];
export const INPUT_TYPE_ICON = {
email: Mail,
username: User,
name: Contact,
phone: Phone,
domain: Globe,
ip: Server,
password: KeyRound,
};

30
front/src/pages/403.astro Normal file
View File

@@ -0,0 +1,30 @@
---
import Layout from "@src/layouts/Layout.astro";
import { ShieldAlert, Home } from "@lucide/svelte";
---
<Layout title="403 - Access Denied">
<main
class="flex flex-col items-center justify-center gap-6 px-4 text-center"
>
<div class="flex flex-col items-center mt-20">
<ShieldAlert size={80} class="text-warning" />
<h1 class="text-4xl font-black text-warning opacity-50 mb-10">403</h1>
</div>
<div>
<h2 class="text-3xl font-bold logo-gradient italic">Access Denied</h2>
<p class="opacity-60 max-w-xs mx-auto mt-2">
You don't have the necessary clearance to access this sector of the app.
</p>
</div>
<div class="badge badge-outline badge-warning font-mono text-xs p-3">
ERROR_CODE: INSUFFICIENT_PERMISSIONS
</div>
<a href="/" class="btn btn-soft btn-warning gap-2">
<Home size={16} /> Return to Surface
</a>
</main>
</Layout>

26
front/src/pages/404.astro Normal file
View File

@@ -0,0 +1,26 @@
---
import Layout from "@src/layouts/Layout.astro";
import { Ghost, Home } from "@lucide/svelte";
---
<Layout title="404 - Page Not Found">
<main
class="flex flex-col items-center justify-center gap-6 px-4 text-center"
>
<div class="flex flex-col items-center mt-20">
<Ghost size={80} class="text-primary" />
<h1 class="text-4xl font-black text-primary opacity-50 mb-10">404</h1>
</div>
<div>
<h2 class="text-3xl font-bold logo-gradient italic">Lost?</h2>
<p class="opacity-60 max-w-xs mx-auto mt-2">
The page you are looking for doesn't exist or has been moved.
</p>
</div>
<a href="/" class="btn btn-soft btn-primary gap-2">
<Home size={16} /> Back to Home
</a>
</main>
</Layout>

32
front/src/pages/500.astro Normal file
View File

@@ -0,0 +1,32 @@
---
import Layout from "@src/layouts/Layout.astro";
import { AlertTriangle, RefreshCw } from "@lucide/svelte";
---
<Layout title="500 - Server Error">
<main
class="flex flex-col items-center justify-center gap-6 px-4 text-center"
>
<div class="flex flex-col items-center mt-20">
<AlertTriangle size={80} class="text-error" />
<h1 class="text-4xl font-black text-error opacity-50 mb-10">500</h1>
</div>
<div>
<h2 class="text-3xl font-bold logo-gradient italic">System Failure</h2>
<p class="opacity-60 max-w-xs mx-auto mt-2">
The server encountered an unexpected error.
</p>
</div>
<div class="flex gap-4">
<button
onclick="window.location.reload()"
class="btn btn-soft btn-error gap-2"
>
<RefreshCw size={16} /> Retry
</button>
<a href="/" class="btn btn-soft btn-primary">Go Home</a>
</div>
</main>
</Layout>

View File

@@ -0,0 +1,126 @@
---
import Layout from "@src/layouts/Layout.astro";
import { getCollection, render } from "astro:content";
export async function getStaticPaths() {
const sheets = await getCollection("cheatsheets");
return sheets.map((sheet) => ({
params: { slug: sheet.id },
props: { sheet },
}));
}
const { sheet } = Astro.props;
const { Content, headings } = await render(sheet);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<Layout title={`${sheet.data.title} - Cheatsheets`} description={sheet.data.description}>
<div class="pb-4">
<div class="mb-6">
<a href="/cheatsheets" class="btn btn-ghost btn-sm gap-1">← Cheatsheets</a>
</div>
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight">{sheet.data.title}</h1>
{sheet.data.description && (
<p class="text-base-content/50 text-sm mt-1">{sheet.data.description}</p>
)}
{sheet.data.tags && sheet.data.tags.length > 0 && (
<div class="flex gap-2 mt-3">
{sheet.data.tags.map((tag) => (
<a href={`/cheatsheets?tag=${tag}`} class="badge badge-ghost badge-sm">{tag}</a>
))}
</div>
)}
</div>
{toc.length > 0 && (
<details class="lg:hidden mb-6 border border-base-300 rounded-lg">
<summary class="cursor-pointer px-4 py-3 text-sm font-semibold select-none list-none flex items-center justify-between">
<span>On this page</span>
<svg class="size-4 opacity-50 details-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</summary>
<nav class="px-4 pb-4 pt-1">
<ul class="flex flex-col gap-1">
{toc.map((h) => (
<li style={h.depth === 3 ? "padding-left: 0.875rem" : ""}>
<a href={`#${h.slug}`} class="toc-link text-sm text-base-content/60 hover:text-base-content transition-colors">
{h.text}
</a>
</li>
))}
</ul>
</nav>
</details>
)}
<div class="flex gap-10 items-start">
<div class="prose prose-sm max-w-none flex-1 min-w-0 break-words">
<Content />
</div>
{toc.length > 0 && (
<aside class="hidden lg:block w-48 shrink-0">
<nav class="sticky top-8">
<p class="text-xs font-semibold uppercase tracking-wider text-base-content/40 mb-3">
On this page
</p>
<ul class="flex flex-col gap-1.5">
{toc.map((h) => (
<li style={h.depth === 3 ? "padding-left: 0.75rem" : ""}>
<a
href={`#${h.slug}`}
data-toc-link
class="toc-link text-xs text-base-content/50 hover:text-base-content transition-colors leading-snug block"
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
)}
</div>
</div>
</Layout>
<style>
details[open] .details-chevron {
transform: rotate(180deg);
}
.details-chevron {
transition: transform 0.2s;
}
.toc-link.active {
color: var(--color-primary);
}
</style>
<script>
const links = document.querySelectorAll<HTMLAnchorElement>("[data-toc-link]");
if (links.length > 0) {
const headingEls = [...links].map((l) =>
document.querySelector(decodeURIComponent(new URL(l.href).hash))
);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const idx = headingEls.indexOf(entry.target);
links.forEach((l) => l.classList.remove("active"));
if (idx !== -1) links[idx].classList.add("active");
}
}
},
{ rootMargin: "-20% 0px -70% 0px" }
);
headingEls.forEach((el) => el && observer.observe(el));
}
</script>

View File

@@ -0,0 +1,33 @@
---
import Layout from "@src/layouts/Layout.astro";
import { getCollection } from "astro:content";
import CheatsheetList from "@src/components/CheatsheetList.svelte";
const sheets = (await getCollection("cheatsheets"))
.sort((a, b) => (a.data.order ?? 99) - (b.data.order ?? 99))
.map((s) => ({
id: s.id,
title: s.data.title,
description: s.data.description,
tags: s.data.tags,
}));
---
<Layout title="Cheatsheets">
<div class="max-w-3xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
</div>
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight">OSINT Cheatsheets</h1>
<p class="text-base-content/50 text-sm mt-1">
Quick reference cards for common OSINT techniques.
</p>
</div>
<CheatsheetList {sheets} client:only="svelte" />
</div>
</Layout>

172
front/src/pages/help.astro Normal file
View File

@@ -0,0 +1,172 @@
---
import Layout from "@src/layouts/Layout.astro";
---
<Layout title="How it works">
<div class="max-w-3xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
</div>
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight">How it works</h1>
<p class="text-base-content/50 text-sm mt-1">
A guide to iknowyou: concepts, tools, profiles, and configuration.
</p>
</div>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="size-2 rounded-full bg-primary inline-block"></span>
What is it?
</h2>
<p class="text-base-content/70 text-sm leading-relaxed">
<strong>Iknowyou</strong> (IKY) is an OSINT aggregation platform. It runs multiple
open-source intelligence tools against a target in parallel and presents the results
in a unified interface. Targets can be email addresses, usernames, phone numbers, IP
addresses, domains, and more.
</p>
<p class="text-base-content/70 text-sm leading-relaxed">
Instead of running each tool manually, IKY handles orchestration, config management,
and result rendering so you can focus on analysis.
</p>
</section>
<div class="divider my-0"></div>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="size-2 rounded-full bg-primary inline-block"></span>
Tools
</h2>
<p class="text-base-content/70 text-sm leading-relaxed">
Each <strong>tool</strong> is a Go module that knows how to query one data source
(a website, an API, a local binary...). Tools declare:
</p>
<ul class="list-disc list-inside text-base-content/70 text-sm leading-relaxed space-y-1 ml-2">
<li>Which <strong>input types</strong> they accept (email, username, IP...)</li>
<li>Optional <strong>configuration fields</strong> (API keys, options)</li>
<li>Whether they require an <strong>external binary</strong> to be installed</li>
</ul>
<p class="text-base-content/70 text-sm leading-relaxed">
The <a href="/tools" class="link link-primary">Tools page</a> shows all registered tools
grouped by status:
</p>
<div class="flex flex-col gap-2 ml-2">
<div class="flex items-center gap-2 text-sm">
<span class="size-2 rounded-full bg-success shrink-0"></span>
<span><strong>Active</strong> - ready to run</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="size-2 rounded-full bg-warning shrink-0"></span>
<span><strong>Active: config missing</strong> - needs an API key or required field</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="size-2 rounded-full bg-error shrink-0"></span>
<span><strong>Active: unavailable</strong> - required binary not found on the system</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="size-2 rounded-full bg-base-content/20 shrink-0"></span>
<span><strong>Disabled</strong> - excluded by the selected profile</span>
</div>
</div>
</section>
<div class="divider my-0"></div>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="size-2 rounded-full bg-primary inline-block"></span>
Profiles
</h2>
<p class="text-base-content/70 text-sm leading-relaxed">
A <strong>profile</strong> is a named search configuration. When you start a search,
you pick which profile to use. Profiles control:
</p>
<ul class="list-disc list-inside text-base-content/70 text-sm leading-relaxed space-y-1 ml-2">
<li><strong>Enabled list</strong> (whitelist): if set, only these tools run</li>
<li><strong>Disabled list</strong> (blacklist): these tools are always skipped</li>
<li><strong>Tool overrides</strong>: per-profile config that overrides global settings for specific tools</li>
<li><strong>Notes</strong>: a description of what the profile is for</li>
</ul>
<p class="text-base-content/70 text-sm leading-relaxed">
Two profiles are built-in and cannot be modified:
</p>
<div class="flex flex-col gap-2 ml-2">
<div class="card bg-base-200 p-3 text-sm">
<span class="font-mono font-bold">default</span>
<p class="text-base-content/60 text-xs mt-1">
All tools active with default settings. No restrictions.
</p>
</div>
<div class="card bg-base-200 p-3 text-sm">
<span class="font-mono font-bold">hard</span>
<p class="text-base-content/60 text-xs mt-1">
Aggressive mode. All tools active, including those that may send
notifications or leave traces at the target.
</p>
</div>
</div>
<p class="text-base-content/70 text-sm leading-relaxed">
You can create custom profiles on the <a href="/profiles" class="link link-primary">Profiles page</a>.
</p>
</section>
<div class="divider my-0"></div>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="size-2 rounded-full bg-primary inline-block"></span>
Configuration &amp; Overrides
</h2>
<p class="text-base-content/70 text-sm leading-relaxed">
Tool configuration works in two layers:
</p>
<ol class="list-decimal list-inside text-base-content/70 text-sm leading-relaxed space-y-2 ml-2">
<li>
<strong>Global config</strong>: set on the
<a href="/tools" class="link link-primary">Tools page</a>. Applied to every
search regardless of profile.
</li>
<li>
<strong>Profile override</strong>: set inside a profile. Takes precedence over
global config when that profile is used. Useful when you want a tool to behave
differently in specific contexts (e.g. slower rate-limiting in a "quiet" profile).
</li>
</ol>
<p class="text-base-content/70 text-sm leading-relaxed">
Config is stored in <code class="font-mono bg-base-300 px-1 rounded text-xs">config.yaml</code>.
Built-in profiles are hardcoded in Go and are never written to disk.
</p>
</section>
<div class="divider my-0"></div>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="size-2 rounded-full bg-primary inline-block"></span>
How a search runs
</h2>
<ol class="list-decimal list-inside text-base-content/70 text-sm leading-relaxed space-y-2 ml-2">
<li>You enter a target, select its type (email, username...) and pick a profile.</li>
<li>
The backend filters tools by input type and the profile's enabled/disabled rules,
then skips any tool with a missing required config field.
</li>
<li>All eligible tools run in parallel against the target.</li>
<li>
The frontend polls for results and renders them progressively as each tool finishes.
</li>
</ol>
<p class="text-base-content/70 text-sm leading-relaxed">
A search can be cancelled at any time from the results page.
Completed searches are kept in memory.
</p>
</section>
</div>
</div>
</Layout>

View File

@@ -0,0 +1,19 @@
---
import Layout from "@src/layouts/Layout.astro";
import HomePage from "@src/components/HomePage.svelte";
---
<Layout title="iknowyou">
<div class="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-10">
<header class="text-center">
<img src="/logo.svg" class="m-auto w-36" alt="iknowyou" />
<h1 class="font-unbounded logo-gradient text-6xl tracking-[-0.11em] leading-normal mb-2 mt-0">i know you</h1>
<p class="text-base-content/50 text-sm max-w-xl mx-auto">
Centralizing your OSINT tools in one place.<br/>
<strong>Iknowyou</strong> is a self-hosted OSINT (Open-Source Intelligence) platform that centralises reconnaissance tools
into a single reactive web interface. Instead of juggling terminals, browser tabs, and disconnected CLI tools, you type a target once and get results in real time.
</p>
</header>
<HomePage client:only="svelte" />
</div>
</Layout>

View File

@@ -0,0 +1,21 @@
---
import Layout from "@src/layouts/Layout.astro";
import ProfileSettings from "@src/components/ProfileSettings.svelte";
---
<Layout title="Profiles">
<div class="max-w-4xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
</div>
<div class="mb-6">
<h1 class="text-xl font-bold tracking-tight">Profiles</h1>
<p class="text-base-content/50 text-sm mt-1">
Manage search profiles: allowed/blocked tools and per-tool config overrides.
</p>
</div>
<ProfileSettings client:only="svelte" />
</div>
</Layout>

View File

@@ -0,0 +1,22 @@
---
import Layout from "@src/layouts/Layout.astro";
import SearchDetail from "@src/components/SearchDetail.svelte";
export async function getStaticPaths() {
return [{ params: { id: "_" } }];
}
// Shell page — SearchDetail reads the real ID from window.location.
const id = null;
---
<Layout title="Search">
<div class="max-w-4xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
</div>
<SearchDetail {id} client:only="svelte" />
</div>
</Layout>

View File

@@ -0,0 +1,22 @@
---
import Layout from "@src/layouts/Layout.astro";
import ToolDetail from "@src/components/ToolDetail.svelte";
export async function getStaticPaths() {
return [{ params: { name: "_" } }];
}
// Shell page — ToolDetail reads the real name from window.location.
const name = null;
---
<Layout title="Tool">
<div class="max-w-3xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/tools" class="btn btn-ghost btn-sm gap-1">← Tools</a>
</div>
<ToolDetail {name} client:only="svelte" />
</div>
</Layout>

View File

@@ -0,0 +1,20 @@
---
import Layout from "@src/layouts/Layout.astro";
import ToolList from "@src/components/ToolList.svelte";
---
<Layout title="Tools">
<div class="max-w-4xl mx-auto px-4 pb-4">
<div class="mb-6">
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
</div>
<div class="mb-6">
<h1 class="text-xl font-bold tracking-tight">Tools</h1>
<p class="text-base-content/50 text-sm mt-1">All registered OSINT tools.</p>
</div>
<ToolList client:only="svelte" />
</div>
</Layout>

144
front/src/styles/gfm.css Normal file
View File

@@ -0,0 +1,144 @@
/* ANSI Terminal Output */
.ansi-output {
font-family: monospace;
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background-color: var(--color-base-100);
border: 1px solid color-mix(in srgb, var(--color-base-content) 10%, transparent);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
max-height: 600px;
overflow-y: auto;
}
/* GitHub Flavored Markdown - Code Blocks */
.code-block {
border: 1px solid color-mix(in srgb, var(--color-base-content) 12%, transparent);
border-radius: 0.5rem;
overflow: hidden;
margin: 1.25rem 0;
font-size: 0.875rem;
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
background-color: color-mix(in srgb, var(--color-base-content) 6%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-base-content) 10%, transparent);
}
.code-lang {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-base-content);
opacity: 0.5;
text-transform: lowercase;
}
.copy-btn {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 0.3rem;
border: 1px solid color-mix(in srgb, var(--color-base-content) 15%, transparent);
background: transparent;
color: var(--color-base-content);
opacity: 0.5;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-btn:hover { opacity: 1; }
.copy-btn.copied { color: var(--color-success); opacity: 1; }
.code-block pre {
margin: 0 !important;
border-radius: 0 !important;
border: none !important;
padding: 1rem 0 !important;
}
.code-block pre code {
counter-reset: line;
}
.code-block pre code .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2rem;
text-align: right;
margin-right: 1.25rem;
color: var(--color-base-content);
opacity: 0.25;
user-select: none;
}
/* GitHub Flavored Markdown - Alerts */
.markdown-alert {
border-left: 4px solid;
padding: 0.75rem 1rem;
border-radius: 0 0.5rem 0.5rem 0;
margin: 1.25rem 0;
}
.markdown-alert p {
margin: 0;
}
.markdown-alert-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.4rem;
}
.markdown-alert-note {
border-color: var(--color-info);
background-color: color-mix(in srgb, var(--color-info) 10%, transparent);
}
.markdown-alert-note .markdown-alert-title {
color: var(--color-info);
}
.markdown-alert-tip {
border-color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 10%, transparent);
}
.markdown-alert-tip .markdown-alert-title {
color: var(--color-success);
}
.markdown-alert-important {
border-color: var(--color-secondary);
background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent);
}
.markdown-alert-important .markdown-alert-title {
color: var(--color-secondary);
}
.markdown-alert-warning {
border-color: var(--color-warning);
background-color: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.markdown-alert-warning .markdown-alert-title {
color: var(--color-warning);
}
.markdown-alert-caution {
border-color: var(--color-error);
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
}
.markdown-alert-caution .markdown-alert-title {
color: var(--color-error);
}

View File

@@ -0,0 +1,84 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "daisyui" {
themes: dark --prefersdark --default;
}
@plugin "daisyui/theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: dark;
--color-primary: #E9F7A7;
--color-primary-content: #11111b;
--color-secondary: #f5c2e7;
--color-secondary-content: #11111b;
--color-accent: #94e2d5;
--color-accent-content: #11111b;
--color-neutral: #313244;
--color-neutral-content: #cdd6f4;
--color-base-300: #1a1a2a;
--color-base-200: #12121F;
--color-base-100: #0C0C16;
--color-base-content: #cdd6f4;
--color-info: #89b4fa;
--color-info-content: #11111b;
--color-success: #a6e3a1;
--color-success-content: #11111b;
--color-warning: #f9e2af;
--color-warning-content: #11111b;
--color-error: #f38ba8;
--color-error-content: #11111b;
--radius-selector: 1rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@theme {
--font-unbounded: "Unbounded", sans-serif;
}
@utility logo-gradient {
@apply bg-gradient-to-b from-[#E9F7A7] via-[#E9BED9] to-[#BBA6EB] bg-clip-text text-transparent;
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@font-face {
font-family: 'Unbounded';
src: url('/fonts/unbounded-black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,20 @@
@reference "./global.css";
.link-card {
@apply flex items-center justify-between p-4 bg-base-200 rounded-xl border border-base-300 no-underline hover:border-primary transition-all mb-4;
}
.link-card h4 {
@apply font-bold m-0 text-base;
}
.link-card p {
@apply text-sm opacity-70 m-0;
}
.link-card::after {
content: "";
@apply w-5 h-5 bg-current shrink-0 ml-4;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>') no-repeat center;
mask-size: contain;
}