mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
common/ had become the backend's library: 30 of its 41 modules were imported by nothing in the frontend, so the name no longer said what belonged there. It now holds only what the frontend or the build imports too; backend-only code moved to server/, flat except for datasets/ (the dataset engine and its readers). The *-db/ dataset directories stay in common/: self-hosting docs and Docker volumes mount those paths. frontend/utils/ keeps its app-wide modules at the root and gains ip/ (IP / ASN shaping and validation), report/ (the report pipeline) and features/ (the pure layer of a single feature). Paths only — no logic changes. The Dockerfile copies server/, and the AGENTS.md files say where a new module goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.7 KiB
Vue
191 lines
7.7 KiB
Vue
<template>
|
|
<div class="whois-section my-4 space-y-4">
|
|
<!-- Top note -->
|
|
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('whois.Note') }}</p>
|
|
|
|
<!-- Input area -->
|
|
<div class="space-y-2">
|
|
<Label for="queryURLorIP">{{ t('whois.Note2') }}</Label>
|
|
<div class="flex items-center gap-2">
|
|
<Input type="text" id="queryURLorIP" name="queryURLorIP" data-1p-ignore data-lpignore="true"
|
|
class="font-mono" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
:disabled="whoisCheckStatus === 'running'" :placeholder="t('whois.Placeholder')"
|
|
v-model="queryURLorIP" @keyup.enter="onSubmit" :aria-invalid="errorMsg !== ''" />
|
|
<Button variant="action" :disabled="whoisCheckStatus === 'running' || !queryURLorIP" @click="onSubmit"
|
|
class="cursor-pointer">
|
|
<Spinner v-if="whoisCheckStatus === 'running'" />
|
|
<template v-else>
|
|
<Search class="size-4 shrink-0" />
|
|
</template>
|
|
</Button>
|
|
</div>
|
|
<p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
|
|
</div>
|
|
|
|
<!-- Result area -->
|
|
<div v-if="whoisResults && Object.keys(whoisResults).length" class="space-y-3">
|
|
<!-- Top success prompt bar -->
|
|
<div
|
|
class="flex items-start gap-2 p-3 rounded-md border border-success/30 bg-success/10 text-sm text-success">
|
|
<Info class="size-4 mt-0.5 shrink-0" />
|
|
<span class="leading-relaxed">{{ t('whois.Note3') }}</span>
|
|
</div>
|
|
|
|
<!-- domain type: multiple provider Accordion -->
|
|
<Accordion v-if="type === 'domain'" type="single" collapsible default-value="0" class="space-y-2">
|
|
<AccordionItem v-for="(provider, index) in providers" :key="provider" :value="String(index)"
|
|
class="rounded-lg border bg-card px-4 data-[state=open]:border-primary/30">
|
|
<AccordionTrigger class="hover:no-underline cursor-pointer my-1">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<span
|
|
class="shrink-0 inline-flex size-5 items-center justify-center rounded-full bg-foreground text-background text-xs font-semibold">
|
|
{{ index + 1 }}
|
|
</span>
|
|
<span class="text-sm text-muted-foreground">{{ t('whois.Provider') }}:</span>
|
|
<span class="text-sm font-semibold truncate">{{ provider.toUpperCase() }}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<pre
|
|
class="mt-2 p-4 rounded-md bg-muted font-mono text-xs leading-relaxed overflow-x-auto whitespace-pre-wrap wrap-break-word">{{ filterDomainWhoisRawData(whoisResults[providers[index]].__raw) }}</pre>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
|
|
<!-- ip / asn type: single block raw output -->
|
|
<div v-else>
|
|
<pre
|
|
class="p-4 rounded-md bg-muted font-mono text-xs leading-relaxed overflow-x-auto whitespace-pre-wrap wrap-break-word">{{ filterIPWhoisRawData(whoisResults.__raw) }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { trackEvent } from '@/utils/analytics';
|
|
import { emitAppEvent } from '@/utils/app-events.js';
|
|
import { isValidIP, isValidDomain, isUsablePublicIP } from '@/utils/ip/valid-ip.js';
|
|
import { parseAsnInput } from '@/utils/ip/asn-input.js';
|
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { Info, Search } from '@lucide/vue';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const queryURLorIP = ref('');
|
|
const whoisCheckStatus = ref('idle');
|
|
const errorMsg = ref('');
|
|
const providers = ref([]);
|
|
const type = ref('');
|
|
const whoisResults = ref({});
|
|
|
|
const formatURL = (domain) => {
|
|
if (!domain.match(/^https?:\/\//)) domain = 'http://' + domain;
|
|
try {
|
|
const url = new URL(domain);
|
|
const parts = url.hostname.split('.');
|
|
const mainDomain = parts.slice(-2).join('.');
|
|
if (isValidDomain(mainDomain)) return mainDomain;
|
|
} catch { /* noop */ }
|
|
return false;
|
|
};
|
|
|
|
const validInput = (input) => {
|
|
// `AS` + digits is an AS number; a bare number is not accepted.
|
|
const asn = parseAsnInput(input, { requirePrefix: true });
|
|
if (asn !== null) {
|
|
type.value = 'asn';
|
|
return `AS${asn}`;
|
|
}
|
|
if (formatURL(input)) {
|
|
type.value = 'domain';
|
|
return formatURL(input);
|
|
} else if (isValidIP(input)) {
|
|
// only publicly routable space has a registry record
|
|
if (!isUsablePublicIP(input)) {
|
|
errorMsg.value = t('whois.reservedIP');
|
|
return false;
|
|
}
|
|
type.value = 'ip';
|
|
return input;
|
|
} else {
|
|
errorMsg.value = t('whois.invalidURL');
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const onSubmit = () => {
|
|
trackEvent('Section', 'StartClick', 'Whois');
|
|
errorMsg.value = '';
|
|
providers.value = [];
|
|
whoisResults.value = {};
|
|
const query = validInput(queryURLorIP.value);
|
|
if (query) getWhoisResults(query);
|
|
};
|
|
|
|
const getWhoisResults = async (query) => {
|
|
whoisCheckStatus.value = 'running';
|
|
try {
|
|
const response = await fetch(`/api/whois?q=${query}`);
|
|
// A 4xx is the backend answering "no registry data for this input" —
|
|
// unsupported TLD, unregistered domain, malformed query.
|
|
if (!response.ok) {
|
|
if (response.status < 500) {
|
|
errorMsg.value = t('whois.noData');
|
|
whoisCheckStatus.value = 'idle';
|
|
return;
|
|
}
|
|
throw new Error(`Whois request failed with status ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
getProviders(data);
|
|
if (type.value === 'domain' && providers.value.length >= 1) {
|
|
whoisResults.value = data;
|
|
// Achievement rule (CuriousCat, ipcheck.ing lookups only) lives in
|
|
// data/achievement-rules.js.
|
|
emitAppEvent('whois:lookup', { query });
|
|
errorMsg.value = '';
|
|
} else if ((type.value === 'ip' || type.value === 'asn') && data.__raw) {
|
|
whoisResults.value = data;
|
|
errorMsg.value = '';
|
|
} else {
|
|
errorMsg.value = t('whois.fetchError');
|
|
}
|
|
whoisCheckStatus.value = 'idle';
|
|
} catch (error) {
|
|
console.error('Error fetching Whois results:', error);
|
|
whoisCheckStatus.value = 'idle';
|
|
errorMsg.value = t('whois.fetchError');
|
|
}
|
|
};
|
|
|
|
const getProviders = (data) => {
|
|
if (type.value === 'domain') {
|
|
for (const [key] of Object.entries(data)) {
|
|
if (key.match(/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i)) {
|
|
if (data[key].__raw) providers.value.push(key);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const filterDomainWhoisRawData = (text) => {
|
|
text = text.replace(/^( {1,20})/gm, '');
|
|
const cutoffIndex = text.indexOf('\nFor more information');
|
|
return cutoffIndex !== -1 ? text.substring(0, cutoffIndex) : text;
|
|
};
|
|
|
|
const filterIPWhoisRawData = (text) => {
|
|
text = text.replace(/^#.*\n/gm, '');
|
|
text = text.replace(/^\n*/, '');
|
|
text = text.replace(/\n$/, '');
|
|
return text;
|
|
};
|
|
|
|
</script>
|