mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 00:47:26 +02:00
add proxy settings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
const navLinks = [
|
||||
{ label: "Search", href: "/", icon: Search },
|
||||
{ label: "Tools", href: "/tools", icon: Hammer },
|
||||
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
|
||||
{ label: "Settings", href: "/settings", icon: SlidersHorizontal },
|
||||
{ label: "Enumerate", href: "/enumerate", icon: ListFilter },
|
||||
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
|
||||
{
|
||||
|
||||
201
front/src/components/ProxySettings.svelte
Normal file
201
front/src/components/ProxySettings.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Plus, Trash2, Save, Shield, AlertTriangle, Lock } from "@lucide/svelte";
|
||||
|
||||
let proxies = $state([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let msg = $state(null);
|
||||
let configReadonly = $state(false);
|
||||
let newUrl = $state("");
|
||||
let newUrlError = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const cfg = await res.json();
|
||||
proxies = cfg.proxies ?? [];
|
||||
configReadonly = cfg.readonly ?? false;
|
||||
} catch (e) {
|
||||
msg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function validateUrl(url) {
|
||||
if (!url) return "URL is required";
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!["socks4:", "socks5:", "http:"].includes(u.protocol)) {
|
||||
return "Protocol must be socks4, socks5, or http";
|
||||
}
|
||||
if (!u.hostname) return "Missing hostname";
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function addProxy() {
|
||||
const url = newUrl.trim();
|
||||
const err = validateUrl(url);
|
||||
if (err) { newUrlError = err; return; }
|
||||
if (proxies.some((p) => p.url === url)) {
|
||||
newUrlError = "This proxy is already in the list";
|
||||
return;
|
||||
}
|
||||
proxies = [...proxies, { url }];
|
||||
newUrl = "";
|
||||
newUrlError = "";
|
||||
}
|
||||
|
||||
function removeProxy(index) {
|
||||
proxies = proxies.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
msg = null;
|
||||
try {
|
||||
const res = await fetch("/api/config/proxies", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(proxies),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
msg = { ok: true, text: "Saved" };
|
||||
setTimeout(() => (msg = null), 3000);
|
||||
} catch (e) {
|
||||
msg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function proxyLabel(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const proto = u.protocol.replace(":", "").toUpperCase();
|
||||
const auth = u.username ? `${u.username}@` : "";
|
||||
return { proto, host: `${auth}${u.hostname}:${u.port || defaultPort(u.protocol)}` };
|
||||
} catch {
|
||||
return { proto: "?", host: url };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPort(protocol) {
|
||||
if (protocol === "http:") return "8080";
|
||||
return "1080";
|
||||
}
|
||||
|
||||
const PROTO_COLORS = {
|
||||
SOCKS5: "badge-primary",
|
||||
SOCKS4: "badge-secondary",
|
||||
HTTP: "badge-neutral",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-200 shadow">
|
||||
<div class="card-body gap-4 p-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield size={15} class="text-base-content/50 shrink-0" />
|
||||
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Proxies</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if msg}
|
||||
<span class="text-xs {msg.ok ? 'text-success' : 'text-error'}">{msg.text}</span>
|
||||
{/if}
|
||||
{#if !configReadonly}
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-1"
|
||||
onclick={save}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Save size={13} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if configReadonly}
|
||||
<div class="flex items-center gap-2 text-xs text-base-content/50">
|
||||
<Lock size={12} />
|
||||
Proxy config is read-only.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if proxies.length === 0}
|
||||
<p class="text-sm text-base-content/40">
|
||||
No proxies configured — tools will connect directly.
|
||||
</p>
|
||||
{:else}
|
||||
{#each proxies as proxy, i}
|
||||
{@const lbl = proxyLabel(proxy.url)}
|
||||
<div class="flex items-center gap-2 bg-base-300 rounded-box px-3 py-2">
|
||||
<span class="badge badge-xs {PROTO_COLORS[lbl.proto] ?? 'badge-ghost'} shrink-0">
|
||||
{lbl.proto}
|
||||
</span>
|
||||
<span class="font-mono text-sm flex-1 truncate">{lbl.host}</span>
|
||||
{#if !configReadonly}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
onclick={() => removeProxy(i)}
|
||||
title="Remove proxy"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !configReadonly}
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm font-mono flex-1 {newUrlError ? 'input-error' : ''}"
|
||||
placeholder="socks5://user:pass@host:1080"
|
||||
bind:value={newUrl}
|
||||
onkeydown={(e) => e.key === "Enter" && addProxy()}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-neutral btn-sm gap-1 shrink-0"
|
||||
onclick={addProxy}
|
||||
disabled={!newUrl.trim()}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{#if newUrlError}
|
||||
<p class="text-xs text-error flex items-center gap-1">
|
||||
<AlertTriangle size={11} />{newUrlError}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/40">
|
||||
Supported: <span class="font-mono">socks5://</span>,
|
||||
<span class="font-mono">socks4://</span>,
|
||||
<span class="font-mono">http://</span> — on failure, the next proxy is tried automatically.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
749
front/src/components/SettingsPage.svelte
Normal file
749
front/src/components/SettingsPage.svelte
Normal file
@@ -0,0 +1,749 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Plus, Trash2, Save, Shield, ChevronRight, X,
|
||||
Lock, AlertTriangle, SlidersHorizontal,
|
||||
} from "@lucide/svelte";
|
||||
import Select from "./comps/Select.svelte";
|
||||
import Badge from "./comps/Badge.svelte";
|
||||
import InfoTip from "./comps/InfoTip.svelte";
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let configReadonly = $state(false);
|
||||
let selectedView = $state("proxies"); // "proxies" | profile name
|
||||
|
||||
// ── Proxy state ───────────────────────────────────────────────────────────
|
||||
let proxies = $state([]);
|
||||
let proxychainsAvailable = $state(true);
|
||||
let proxySaving = $state(false);
|
||||
let proxyMsg = $state(null);
|
||||
let newUrl = $state("");
|
||||
let newUrlError = $state("");
|
||||
|
||||
// ── Profile state ─────────────────────────────────────────────────────────
|
||||
let tools = $state([]);
|
||||
let profiles = $state([]);
|
||||
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;
|
||||
proxies = cfg.proxies ?? [];
|
||||
proxychainsAvailable = cfg.proxychains_available ?? true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proxy helpers ─────────────────────────────────────────────────────────
|
||||
function validateProxyUrl(url) {
|
||||
if (!url) return "URL is required";
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!["socks4:", "socks5:", "http:"].includes(u.protocol))
|
||||
return "Protocol must be socks4, socks5, or http";
|
||||
if (!u.hostname) return "Missing hostname";
|
||||
} catch { return "Invalid URL format"; }
|
||||
return "";
|
||||
}
|
||||
|
||||
function addProxy() {
|
||||
const url = newUrl.trim();
|
||||
const err = validateProxyUrl(url);
|
||||
if (err) { newUrlError = err; return; }
|
||||
if (proxies.some((p) => p.url === url)) { newUrlError = "Already in list"; return; }
|
||||
proxies = [...proxies, { url }];
|
||||
newUrl = "";
|
||||
newUrlError = "";
|
||||
}
|
||||
|
||||
function removeProxy(i) { proxies = proxies.filter((_, j) => j !== i); }
|
||||
|
||||
async function saveProxies() {
|
||||
proxySaving = true;
|
||||
proxyMsg = null;
|
||||
try {
|
||||
const res = await fetch("/api/config/proxies", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(proxies),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
proxyMsg = { ok: true, text: "Saved" };
|
||||
setTimeout(() => (proxyMsg = null), 3000);
|
||||
} catch (e) {
|
||||
proxyMsg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
proxySaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function proxyLabel(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const proto = u.protocol.replace(":", "").toUpperCase();
|
||||
const auth = u.username ? `${u.username}@` : "";
|
||||
const port = u.port || (u.protocol === "http:" ? "8080" : "1080");
|
||||
return { proto, host: `${auth}${u.hostname}:${port}` };
|
||||
} catch { return { proto: "?", host: url }; }
|
||||
}
|
||||
|
||||
const PROTO_COLOR = { SOCKS5: "badge-primary", SOCKS4: "badge-secondary", HTTP: "badge-neutral" };
|
||||
|
||||
// ── Profile helpers ───────────────────────────────────────────────────────
|
||||
async function selectProfile(name) {
|
||||
selectedView = 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 [tn, tc] of Object.entries(profileDetail.tools ?? {})) {
|
||||
const tool = tools.find((t) => t.name === tn);
|
||||
if (!tool?.config_fields?.length) continue;
|
||||
nextEdits[tn] = {};
|
||||
for (const f of tool.config_fields)
|
||||
nextEdits[tn][f.name] = tc?.[f.name] !== undefined ? tc[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 a-z, 0-9 and hyphens";
|
||||
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 (selectedView === name) { selectedView = "proxies"; 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(selectedView)}`, {
|
||||
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(selectedView);
|
||||
} 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(selectedView)}/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 "${selectedView}"?`)) return;
|
||||
try {
|
||||
await fetch(
|
||||
`/api/config/profiles/${encodeURIComponent(selectedView)}/tools/${encodeURIComponent(toolName)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
await selectProfile(selectedView);
|
||||
} 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-20">
|
||||
<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">
|
||||
|
||||
<!-- ── Sidebar ──────────────────────────────────────────────────────── -->
|
||||
<aside class="w-full md:w-56 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-6">
|
||||
|
||||
<!-- Proxies entry -->
|
||||
<p class="text-xs uppercase tracking-widest text-base-content/40 px-2 mb-1">Global</p>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full btn btn-sm
|
||||
{selectedView === 'proxies' ? 'btn-primary' : 'btn-ghost'} justify-start"
|
||||
onclick={() => { selectedView = 'proxies'; profileDetail = null; }}
|
||||
>
|
||||
{#if selectedView === 'proxies'}
|
||||
<ChevronRight size={13} class="shrink-0" />
|
||||
{:else}
|
||||
<span class="size-[13px] shrink-0"></span>
|
||||
{/if}
|
||||
<Shield size={13} class="shrink-0" />
|
||||
Proxies
|
||||
{#if proxies.length > 0}
|
||||
<span class="badge badge-xs badge-primary ml-auto">{proxies.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="divider my-1 text-xs text-base-content/40">
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<span class="uppercase tracking-widest text-[10px]">Profiles</span>
|
||||
{#if !configReadonly}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="New profile"
|
||||
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New profile inline form -->
|
||||
{#if showNewProfile && !configReadonly}
|
||||
<div class="flex flex-col gap-2 p-2 bg-base-300 rounded-box mb-1">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full font-mono
|
||||
{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-[11px] 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}
|
||||
|
||||
<!-- Profile list -->
|
||||
{#each profiles as p}
|
||||
<div class="flex items-center gap-1 group">
|
||||
<button
|
||||
class="flex-1 btn btn-sm justify-start gap-1 truncate
|
||||
{selectedView === p.name ? 'btn-primary' : 'btn-ghost'}"
|
||||
onclick={() => selectProfile(p.name)}
|
||||
>
|
||||
{#if selectedView === p.name}
|
||||
<ChevronRight size={13} class="shrink-0" />
|
||||
{:else}
|
||||
<span class="size-[13px] shrink-0"></span>
|
||||
{/if}
|
||||
{#if p.readonly}
|
||||
<Lock size={10} class="shrink-0 opacity-40" />
|
||||
{/if}
|
||||
<span class="truncate">{p.name}</span>
|
||||
</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-3">No profiles yet.</p>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- ── Main panel ────────────────────────────────────────────────────── -->
|
||||
<div class="flex-1 min-w-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}
|
||||
|
||||
<!-- ╌╌ Proxy panel ╌╌ -->
|
||||
{#if selectedView === 'proxies'}
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<div>
|
||||
<h2 class="font-bold text-base flex items-center gap-2">
|
||||
<Shield size={16} class="text-primary" /> Proxies
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/50 mt-0.5">
|
||||
Route all tool traffic through one or more proxies.
|
||||
On network failure the next proxy is tried automatically (round-robin).
|
||||
External binaries are wrapped with <code class="font-mono text-xs bg-base-300 px-1 rounded">proxychains4</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- proxychains4 missing warning -->
|
||||
{#if proxies.length > 0 && !proxychainsAvailable}
|
||||
<div class="alert alert-warning py-2 px-3 text-sm gap-2">
|
||||
<AlertTriangle size={15} class="shrink-0" />
|
||||
<span>
|
||||
<strong>proxychains4</strong> not found in PATH — external binary tools
|
||||
(maigret, ghunt, etc.) will <strong>not</strong> be proxied.
|
||||
Only HTTP-based tools are affected by the proxy config.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Proxy list -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if proxies.length === 0}
|
||||
<div class="border border-dashed border-base-300 rounded-box py-8 text-center">
|
||||
<Shield size={24} class="mx-auto mb-2 text-base-content/20" />
|
||||
<p class="text-sm text-base-content/40">No proxies — tools connect directly.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each proxies as proxy, i}
|
||||
{@const lbl = proxyLabel(proxy.url)}
|
||||
<div class="flex items-center gap-3 bg-base-200 rounded-box px-4 py-2.5
|
||||
border border-base-300 hover:border-base-content/20 transition-colors">
|
||||
<span class="badge badge-sm {PROTO_COLOR[lbl.proto] ?? 'badge-ghost'} font-mono shrink-0">
|
||||
{lbl.proto}
|
||||
</span>
|
||||
<span class="font-mono text-sm flex-1 truncate">{lbl.host}</span>
|
||||
{#if !configReadonly}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
onclick={() => removeProxy(i)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add row -->
|
||||
{#if !configReadonly}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm font-mono flex-1
|
||||
{newUrlError ? 'input-error' : ''}"
|
||||
placeholder="socks5://user:pass@host:1080"
|
||||
bind:value={newUrl}
|
||||
onkeydown={(e) => e.key === 'Enter' && addProxy()}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-neutral btn-sm gap-1 shrink-0"
|
||||
onclick={addProxy}
|
||||
disabled={!newUrl.trim()}
|
||||
>
|
||||
<Plus size={14} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{#if newUrlError}
|
||||
<p class="text-xs text-error flex items-center gap-1">
|
||||
<AlertTriangle size={11} />{newUrlError}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/40">
|
||||
Supported: <span class="font-mono">socks5://</span>,
|
||||
<span class="font-mono">socks4://</span>,
|
||||
<span class="font-mono">http://</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<span></span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if proxyMsg}
|
||||
<span class="text-sm {proxyMsg.ok ? 'text-success' : 'text-error'}">{proxyMsg.text}</span>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-1"
|
||||
onclick={saveProxies}
|
||||
disabled={proxySaving}
|
||||
>
|
||||
{#if proxySaving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Save size={13} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ╌╌ Profile panel ╌╌ -->
|
||||
{:else if !selectedView}
|
||||
<p class="text-base-content/40 text-sm text-center py-12">Select a profile.</p>
|
||||
|
||||
{:else if profileLoading}
|
||||
<div class="flex justify-center py-16">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
|
||||
{:else if profileDetail}
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<!-- Profile header -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="font-bold text-base flex items-center gap-2">
|
||||
<SlidersHorizontal size={16} class="text-primary" />
|
||||
{#if isReadonly}<Lock size={13} class="text-base-content/40" />{/if}
|
||||
{selectedView}
|
||||
</h2>
|
||||
{#if isReadonly}<Badge text="read-only" size="sm" />{/if}
|
||||
{#if profileDetail.active_tools?.length > 0}
|
||||
<span class="text-xs text-base-content/50 ml-auto">
|
||||
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{#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/40">Notes</span>
|
||||
<textarea
|
||||
class="textarea textarea-bordered text-sm resize-none"
|
||||
placeholder="Describe this profile..."
|
||||
rows="2"
|
||||
bind:value={notesEdit}
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rules card -->
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<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/40">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="If non-empty, only these tools will run for this profile." />
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||
{#if isReadonly}
|
||||
{#each (profileDetail.enabled ?? []) as tn}
|
||||
<span class="badge badge-outline gap-1">{tn}</span>
|
||||
{/each}
|
||||
{#if (profileDetail.enabled ?? []).length === 0}
|
||||
<span class="text-xs text-base-content/40">All tools</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each enabledEdit as tn}
|
||||
<span class="badge badge-outline gap-1">
|
||||
{tn}
|
||||
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== tn))}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<Select
|
||||
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
|
||||
placeholder="add tool" size="xs"
|
||||
onselect={(v) => (enabledEdit = [...enabledEdit, v])}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold">
|
||||
Disabled
|
||||
<InfoTip tooltip="These tools are always skipped, even if they appear in the enabled list." />
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||
{#if isReadonly}
|
||||
{#each (profileDetail.disabled ?? []) as tn}
|
||||
<span class="badge badge-error gap-1">{tn}</span>
|
||||
{/each}
|
||||
{#if (profileDetail.disabled ?? []).length === 0}
|
||||
<span class="text-xs text-base-content/40">None</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each disabledEdit as tn}
|
||||
<span class="badge badge-error gap-1">
|
||||
{tn}
|
||||
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== tn))}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<Select
|
||||
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
|
||||
placeholder="add tool" size="xs"
|
||||
onselect={(v) => (disabledEdit = [...disabledEdit, v])}
|
||||
/>
|
||||
{/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={13} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overrides card -->
|
||||
{#if !isReadonly}
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<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/40">Tool overrides</h3>
|
||||
{#if availableForOverride.length > 0}
|
||||
<Select
|
||||
options={availableForOverride.map((t) => t.name)}
|
||||
placeholder="add override" size="xs"
|
||||
onselect={(v) => addOverrideFor(v)}
|
||||
/>
|
||||
{/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={13} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/40">No configurable fields.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
@@ -111,7 +111,37 @@ import Layout from "@src/layouts/Layout.astro";
|
||||
</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>.
|
||||
You can create custom profiles on the <a href="/settings" class="link link-primary">Settings 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>
|
||||
Proxies
|
||||
</h2>
|
||||
<p class="text-base-content/70 text-sm leading-relaxed">
|
||||
IKY supports routing all tool traffic through one or more proxies. Proxies are configured
|
||||
globally on the <a href="/settings" class="link link-primary">Settings page</a> and apply
|
||||
to every search.
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-base-content/70 text-sm leading-relaxed space-y-1 ml-2">
|
||||
<li>Supported protocols: <code class="font-mono bg-base-300 px-1 rounded text-xs">socks5</code>,
|
||||
<code class="font-mono bg-base-300 px-1 rounded text-xs">socks4</code>,
|
||||
<code class="font-mono bg-base-300 px-1 rounded text-xs">http</code>
|
||||
</li>
|
||||
<li><strong>Multiple proxies</strong>: tried in random order per request — if one fails, the next is used automatically</li>
|
||||
<li><strong>HTTP-based tools</strong> (API calls): use a custom Go HTTP client with fallback transport</li>
|
||||
<li><strong>External binary tools</strong> (maigret, ghunt, etc.): wrapped transparently with
|
||||
<code class="font-mono bg-base-300 px-1 rounded text-xs">proxychains4</code> in
|
||||
<code class="font-mono bg-base-300 px-1 rounded text-xs">dynamic_chain</code> mode,
|
||||
which skips dead proxies automatically
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-base-content/70 text-sm leading-relaxed">
|
||||
If no proxies are configured, tools connect directly — behaviour is identical to before.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -156,6 +186,11 @@ import Layout from "@src/layouts/Layout.astro";
|
||||
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>
|
||||
If proxies are configured, a proxy-aware HTTP client and a
|
||||
<code class="font-mono bg-base-300 px-1 rounded text-xs">proxychains4</code>
|
||||
config are prepared and injected into the search context.
|
||||
</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.
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
---
|
||||
import Layout from "@src/layouts/Layout.astro";
|
||||
import ProfileSettings from "@src/components/ProfileSettings.svelte";
|
||||
// Redirect to /settings
|
||||
return Astro.redirect("/settings", 301);
|
||||
---
|
||||
|
||||
<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/settings.astro
Normal file
22
front/src/pages/settings.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Layout from "@src/layouts/Layout.astro";
|
||||
import SettingsPage from "@src/components/SettingsPage.svelte";
|
||||
---
|
||||
|
||||
<Layout title="Settings">
|
||||
<div class="max-w-5xl mx-auto px-4 pb-8">
|
||||
|
||||
<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">Settings</h1>
|
||||
<p class="text-base-content/50 text-sm mt-1">
|
||||
Proxy configuration, search profiles, and per-tool overrides.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsPage client:only="svelte" />
|
||||
</div>
|
||||
</Layout>
|
||||
Reference in New Issue
Block a user