mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 08:57:26 +02:00
init
This commit is contained in:
83
front/src/components/CheatsheetList.svelte
Normal file
83
front/src/components/CheatsheetList.svelte
Normal 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>
|
||||
24
front/src/components/DemoBanner.svelte
Normal file
24
front/src/components/DemoBanner.svelte
Normal 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}
|
||||
98
front/src/components/HomePage.svelte
Normal file
98
front/src/components/HomePage.svelte
Normal 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>
|
||||
136
front/src/components/Nav.svelte
Normal file
136
front/src/components/Nav.svelte
Normal 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>
|
||||
571
front/src/components/ProfileSettings.svelte
Normal file
571
front/src/components/ProfileSettings.svelte
Normal 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}
|
||||
232
front/src/components/SearchBar.svelte
Normal file
232
front/src/components/SearchBar.svelte
Normal 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-ZÀ-ÿ'-]+(?: [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>
|
||||
349
front/src/components/SearchDetail.svelte
Normal file
349
front/src/components/SearchDetail.svelte
Normal 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>
|
||||
71
front/src/components/SearchList.svelte
Normal file
71
front/src/components/SearchList.svelte
Normal 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}
|
||||
265
front/src/components/ToolDetail.svelte
Normal file
265
front/src/components/ToolDetail.svelte
Normal 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}
|
||||
346
front/src/components/ToolList.svelte
Normal file
346
front/src/components/ToolList.svelte
Normal 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}
|
||||
29
front/src/components/comps/Badge.svelte
Normal file
29
front/src/components/comps/Badge.svelte
Normal 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>
|
||||
13
front/src/components/comps/InfoTip.svelte
Normal file
13
front/src/components/comps/InfoTip.svelte
Normal 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>
|
||||
84
front/src/components/comps/Select.svelte
Normal file
84
front/src/components/comps/Select.svelte
Normal 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>
|
||||
29
front/src/components/comps/ToolIcon.svelte
Normal file
29
front/src/components/comps/ToolIcon.svelte
Normal 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}
|
||||
63
front/src/components/comps/TtyOutput.svelte
Normal file
63
front/src/components/comps/TtyOutput.svelte
Normal 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>
|
||||
14
front/src/content.config.ts
Normal file
14
front/src/content.config.ts
Normal 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 };
|
||||
134
front/src/content/cheatsheets/github-osint.md
Normal file
134
front/src/content/cheatsheets/github-osint.md
Normal 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 Github’s 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 user’s 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 user’s 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**, it’s 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. Here’s 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!
|
||||
100
front/src/content/cheatsheets/google-dorks.md
Normal file
100
front/src/content/cheatsheets/google-dorks.md
Normal 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)
|
||||
61
front/src/content/cheatsheets/sock-puppets.md
Normal file
61
front/src/content/cheatsheets/sock-puppets.md
Normal 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>
|
||||
23
front/src/content/cheatsheets/tips.md
Normal file
23
front/src/content/cheatsheets/tips.md
Normal 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/)
|
||||
88
front/src/content/cheatsheets/x-twitter-osint.md
Normal file
88
front/src/content/cheatsheets/x-twitter-osint.md
Normal 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.
|
||||
92
front/src/layouts/Layout.astro
Normal file
92
front/src/layouts/Layout.astro
Normal 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
17
front/src/lib/utils.ts
Normal 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
16
front/src/lib/vars.ts
Normal 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
30
front/src/pages/403.astro
Normal 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
26
front/src/pages/404.astro
Normal 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
32
front/src/pages/500.astro
Normal 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>
|
||||
126
front/src/pages/cheatsheets/[slug].astro
Normal file
126
front/src/pages/cheatsheets/[slug].astro
Normal 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>
|
||||
33
front/src/pages/cheatsheets/index.astro
Normal file
33
front/src/pages/cheatsheets/index.astro
Normal 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
172
front/src/pages/help.astro
Normal 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 & 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>
|
||||
19
front/src/pages/index.astro
Normal file
19
front/src/pages/index.astro
Normal 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>
|
||||
21
front/src/pages/profiles.astro
Normal file
21
front/src/pages/profiles.astro
Normal 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>
|
||||
22
front/src/pages/search/[id].astro
Normal file
22
front/src/pages/search/[id].astro
Normal 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>
|
||||
22
front/src/pages/tools/[name].astro
Normal file
22
front/src/pages/tools/[name].astro
Normal 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>
|
||||
20
front/src/pages/tools/index.astro
Normal file
20
front/src/pages/tools/index.astro
Normal 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
144
front/src/styles/gfm.css
Normal 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);
|
||||
}
|
||||
84
front/src/styles/global.css
Normal file
84
front/src/styles/global.css
Normal 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;
|
||||
}
|
||||
|
||||
20
front/src/styles/markdown.css
Normal file
20
front/src/styles/markdown.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user