Merge pull request #362 from jason5ng32/dev

Dev
This commit is contained in:
Jason Ng authored and GitHub committed 2026-07-30 10:59:54 +08:00
commit b7cd8718c7
40 files changed
+516 -314

No files matched your search

+5 -5
View File
@@ -96,15 +96,15 @@ docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
Les guides complets se trouvent dans le centre de documentation MyIP : **[docs.ipcheck.ing](https://docs.ipcheck.ing)** (sélecteur de langue en haut à droite)
* [Guide du développeur](https://docs.ipcheck.ing/developer) — déploiement, configuration, architecture et contribution
* [Base de connaissances](https://docs.ipcheck.ing/knowledge-base) — utilisation de chaque outil, diagnostic réseau pas à pas, concepts réseau
* [Guide du développeur](https://docs.ipcheck.ing/developer/fr) — déploiement, configuration, architecture et contribution
* [Base de connaissances](https://docs.ipcheck.ing/knowledge-base/fr) — utilisation de chaque outil, diagnostic réseau pas à pas, concepts réseau
## ⚙️ Configuration
Deux réglages comptent avant tout :
* **MaxMind GeoLite2 (requis)** — identifiants gratuits pour la géolocalisation IP et les recherches ASN. Sans eux, la source MaxMind renvoie 503. → [Configuration MaxMind](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **`ALLOWED_DOMAINS` (requis sur un vrai domaine)** — liste blanche de noms d'hôte pour l'API backend. Sans elle, toute requête venant d'un domaine autre que localhost reçoit un 403. → [Reverse proxy et domaines](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
* **MaxMind GeoLite2 (requis)** — identifiants gratuits pour la géolocalisation IP et les recherches ASN. Sans eux, la source MaxMind renvoie 503. → [Configuration MaxMind](https://docs.ipcheck.ing/developer/getting-started/fr/maxmind-setup)
* **`ALLOWED_DOMAINS` (requis sur un vrai domaine)** — liste blanche de noms d'hôte pour l'API backend. Sans elle, toute requête venant d'un domaine autre que localhost reçoit un 403. → [Reverse proxy et domaines](https://docs.ipcheck.ing/developer/getting-started/fr/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
@@ -116,7 +116,7 @@ docker run -d -p 18966:18966 \
jason5ng32/myip:latest
```
Tout le reste — clés API optionnelles, sécurité et limitation de débit, journalisation, Sentry, domaines de l'API curl — est documenté dans la [référence des variables d'environnement](https://docs.ipcheck.ing/developer/reference/environment-variables).
Tout le reste — clés API optionnelles, sécurité et limitation de débit, journalisation, Sentry, domaines de l'API curl — est documenté dans la [référence des variables d'environnement](https://docs.ipcheck.ing/developer/reference/fr/environment-variables).
## 👩🏻‍💻 Utilisation avancée
+5 -5
View File
@@ -96,15 +96,15 @@ docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
Полные руководства — в центре документации MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)** (переключатель языка в правом верхнем углу)
* [Руководство разработчика](https://docs.ipcheck.ing/developer) — развёртывание, настройка, архитектура и участие в разработке
* [База знаний](https://docs.ipcheck.ing/knowledge-base) — как пользоваться каждым инструментом, пошаговая диагностика сети, сетевые концепции
* [Руководство разработчика](https://docs.ipcheck.ing/developer/ru) — развёртывание, настройка, архитектура и участие в разработке
* [База знаний](https://docs.ipcheck.ing/knowledge-base/ru) — как пользоваться каждым инструментом, пошаговая диагностика сети, сетевые концепции
## ⚙️ Конфигурация
Прежде всего важны две настройки:
* **MaxMind GeoLite2 (обязательно)** — бесплатные учётные данные для геолокации IP и запросов ASN. Без них источник MaxMind возвращает 503. → [Настройка MaxMind](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **`ALLOWED_DOMAINS` (обязательно на реальном домене)** — белый список хостов для backend API. Без него любой запрос с домена, отличного от localhost, получает 403. → [Обратный прокси и домены](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
* **MaxMind GeoLite2 (обязательно)** — бесплатные учётные данные для геолокации IP и запросов ASN. Без них источник MaxMind возвращает 503. → [Настройка MaxMind](https://docs.ipcheck.ing/developer/getting-started/ru/maxmind-setup)
* **`ALLOWED_DOMAINS` (обязательно на реальном домене)** — белый список хостов для backend API. Без него любой запрос с домена, отличного от localhost, получает 403. → [Обратный прокси и домены](https://docs.ipcheck.ing/developer/getting-started/ru/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
@@ -116,7 +116,7 @@ docker run -d -p 18966:18966 \
jason5ng32/myip:latest
```
Всё остальное — необязательные ключи API, безопасность и ограничение частоты запросов, логирование, Sentry, домены curl API — описано в [справочнике переменных окружения](https://docs.ipcheck.ing/developer/reference/environment-variables).
Всё остальное — необязательные ключи API, безопасность и ограничение частоты запросов, логирование, Sentry, домены curl API — описано в [справочнике переменных окружения](https://docs.ipcheck.ing/developer/reference/ru/environment-variables).
## 👩🏻‍💻 Расширенное использование
+5 -5
View File
@@ -96,15 +96,15 @@ docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
完整文档在 MyIP 文档中心:**[docs.ipcheck.ing](https://docs.ipcheck.ing)**(右上角可切换中文)
* [开发者指南](https://docs.ipcheck.ing/developer) —— 部署、配置、架构说明与参与贡献
* [知识库](https://docs.ipcheck.ing/knowledge-base) —— 每个工具的使用说明、网络问题排查指南、网络概念科普
* [开发者指南](https://docs.ipcheck.ing/developer/zh) —— 部署、配置、架构说明与参与贡献
* [知识库](https://docs.ipcheck.ing/knowledge-base/zh) —— 每个工具的使用说明、网络问题排查指南、网络概念科普
## ⚙️ 配置
开始之前,有两项配置最重要:
* **MaxMind GeoLite2(必须)** —— 免费凭证,为 IP 地理位置与 ASN 查询提供数据。不配置时 MaxMind 数据源会返回 503。→ [MaxMind 配置指南](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **`ALLOWED_DOMAINS`(使用真实域名时必须)** —— 后端 API 的域名白名单。不配置时,来自非 localhost 域名的请求都会收到 403。→ [反向代理与域名](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
* **MaxMind GeoLite2(必须)** —— 免费凭证,为 IP 地理位置与 ASN 查询提供数据。不配置时 MaxMind 数据源会返回 503。→ [MaxMind 配置指南](https://docs.ipcheck.ing/developer/getting-started/zh/maxmind-setup)
* **`ALLOWED_DOMAINS`(使用真实域名时必须)** —— 后端 API 的域名白名单。不配置时,来自非 localhost 域名的请求都会收到 403。→ [反向代理与域名](https://docs.ipcheck.ing/developer/getting-started/zh/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
@@ -116,7 +116,7 @@ docker run -d -p 18966:18966 \
jason5ng32/myip:latest
```
其余全部为可选配置 —— 第三方 API Key、安全与限流、日志、Sentry、CURL API 域名等,详见[环境变量参考](https://docs.ipcheck.ing/developer/reference/environment-variables)。
其余全部为可选配置 —— 第三方 API Key、安全与限流、日志、Sentry、CURL API 域名等,详见[环境变量参考](https://docs.ipcheck.ing/developer/reference/zh/environment-variables)。
## 👩🏻‍💻 高级用法
+7 -3
View File
@@ -12,9 +12,13 @@ function buildUrl(req) {
return `https://api.ipapi.is?q=${ipAddress}&key=${key}`;
}
function modifyJsonForIPAPI(json) {
let asn = json.asn || {};
const { ip, location, is_datacenter, is_proxy, is_vpn, is_tor } = json;
// Both nested objects are optional: the upstream answers 200 with a null
// `location` (and no `asn`) for addresses it can't place — anycast ranges,
// bogons — so neither may be dereferenced directly.
export function modifyJsonForIPAPI(json) {
const asn = json.asn || {};
const location = json.location || {};
const { ip, is_datacenter, is_proxy, is_vpn, is_tor } = json;
return {
ip: ip,
+4 -1
View File
@@ -134,7 +134,10 @@ export const rdapIp = async (ip, { timeoutMs = 5000 } = {}) => {
throw new Error(`No RDAP endpoint for ${ip}`);
}
const url = `${trimSlash(base)}/ip/${encodeURIComponent(ip)}`;
// Literal IP, not encodeURIComponent: colons are legal in a URL path,
// the guard layer already ensures a well-formed IP, and some RIR
// delegates (e.g. IDNIC behind APNIC's redirect) reject %3A with a 400.
const url = `${trimSlash(base)}/ip/${ip}`;
const res = await fetchUpstream(url, { timeoutMs });
if (res.status === 404) {
throw new Error(`IP not found: ${ip}`);
-3
View File
@@ -88,9 +88,6 @@ const openCurlModal = () => {
trackEvent('Additional', 'AdditionalClick', 'Curl');
};
defineExpose({
openCurlModal,
});
</script>
<style scoped>
+1 -4
View File
@@ -15,7 +15,7 @@
</RouterLink>
</Button>
<Button v-if="isDocsConfigured" variant="link" size="default" as-child class="cursor-pointer">
<a :href="DOCS_URL" target="_blank" rel="noopener"
<a :href="DOCS_URL+'/'+(locale === 'en' ? '' : locale)" target="_blank" rel="noopener"
@click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
@@ -250,7 +250,4 @@ watch(content, () => {
});
});
defineExpose({
openAbout,
});
</script>
+7 -10
View File
@@ -1,8 +1,8 @@
<template>
<NavBar ref="navBarRef" />
<NavBar />
<User ref="userRef" />
<Achievements ref="achievementsRef" />
<Preferences ref="preferencesRef" />
<Preferences />
<main id="mainpart" class="mx-auto w-full px-4 jn-container">
<div class="rounded-md" tabindex="0">
<IPCheck ref="IPCheckRef" />
@@ -16,13 +16,13 @@
<FloatingDock :ready="showMaskButton" :mask-active="infoMaskLevel > 0">
<InfoMask :showMaskButton.value="showMaskButton" :infoMaskLevel.value="infoMaskLevel"
:toggleInfoMask="toggleInfoMask" />
<ShareReport />
<ShareReport ref="shareReportRef" />
<IPHistory />
<QueryIP ref="queryIPRef" />
</FloatingDock>
<HelpModal ref="helpModalRef" />
<Additional ref="additionalRef" />
<Footer ref="footerRef" />
<Additional />
<Footer />
</template>
<script setup>
@@ -81,14 +81,11 @@ const userPreferences = computed(() => store.userPreferences);
const isToolOpen = computed(() => !!route.query.tool);
// Template refs
const navBarRef = ref(null);
const userRef = ref(null);
const achievementsRef = ref(null);
const preferencesRef = ref(null);
const queryIPRef = ref(null);
const helpModalRef = ref(null);
const additionalRef = ref(null);
const footerRef = ref(null);
const shareReportRef = ref(null);
const speedTestRef = ref(null);
const advancedToolsRef = ref(null);
const IPCheckRef = ref(null);
@@ -114,7 +111,7 @@ const { loadingControl } = useRefreshOrchestrator({
// Shortcuts
const { loadShortcuts } = useShortcuts({
refs: {
navBarRef, preferencesRef, queryIPRef, helpModalRef, additionalRef, footerRef,
queryIPRef, helpModalRef, shareReportRef,
speedTestRef, advancedToolsRef, IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef,
isInfosLoaded, isToolOpen, toggleInfoMask,
},
+11 -5
View File
@@ -297,9 +297,8 @@ const fetchIPDetails = async (cardIndex, ip, sourceID = null) => {
const fetchPromise = (async () => {
const sources = store.ipDBs.filter(source => source.enabled);
// The preferred source may no longer be in the enabled list (disabled by
// a concurrent card's failure, or a stale stored preference); findIndex
// then yields -1 and sources[-1] is undefined — start from the first one.
// The requested source may be absent from the enabled list (config flag
// off, or configs not loaded yet); findIndex yields -1 — start from 0.
let currentSourceIndex = sourceID !== null ? sources.findIndex(source => source.id === sourceID) : 0;
if (currentSourceIndex === -1) currentSourceIndex = 0;
let attempts = 0;
@@ -312,16 +311,23 @@ const fetchIPDetails = async (cardIndex, ip, sourceID = null) => {
const cardData = transformDataFromIPapi(response, source.id, t, lang.value);
if (cardData) {
// Fallback landed off the requested source: toast once (parallel
// cards dedupe via usingSource), drift the runtime refs, but never
// rewrite the user's stored preference.
if (source.id !== sourceID && usingSource.value !== source.id) {
const from = store.ipDBs.find(db => db.id === sourceID)?.text || `#${sourceID}`;
store.setAlert(true, 'text-warning',
t('alert.IpGeoSourceFallbackMessage', { from, to: source.text }),
t('alert.IpGeoSourceFallbackTitle'), 5000);
}
ipGeoSource.value = source.id;
usingSource.value = source.id;
store.updatePreference('ipGeoSource', source.id);
Object.assign(card, cardData);
ipDataCache.set(ip, cardData);
return;
}
} catch (error) {
console.error("Error fetching IP details from source " + source.id + ":", error);
store.updateIPDBs({ id: source.id, enabled: false });
currentSourceIndex = (currentSourceIndex + 1) % sources.length;
attempts++;
}
-2
View File
@@ -455,8 +455,6 @@ onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll);
clearTimeout(openToolTimer);
});
defineExpose({ OpenPreferences });
</script>
<style scoped>
+13 -6
View File
@@ -176,6 +176,7 @@ import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { isValidIP } from '@/utils/valid-ip.js';
import { parseTrace } from '@/utils/parse-trace.js';
import getCountryName from '@/data/country-name.js';
import SpeedTestEngine from '@cloudflare/speedtest';
import useSpeedTestCharts from '@/composables/use-speedtest-charts.js';
@@ -294,12 +295,15 @@ const connectionMethods = {
try {
const response = await fetchWithTimeout('https://speed.cloudflare.com/cdn-cgi/trace');
const data = await response.text();
const lines = data.split('\n');
const ip = lines.find((l) => l.startsWith('ip='))?.split('=')[1];
const colo = lines.find((l) => l.startsWith('colo='))?.split('=')[1];
const loc = lines.find((l) => l.startsWith('loc='))?.split('=')[1];
const { ip, colo, loc } = parseTrace(data);
if (!isValidIP(ip)) throw new Error('Invalid IP from SpeedTest Server');
if (!isValidIP(ip)) {
// Self-describing diagnostic.
const detail = ip == null ? 'no-ip-line'
: ip === '' ? 'empty-value'
: `len=${ip.length} tail=0x${ip.charCodeAt(ip.length - 1).toString(16)}`;
throw new Error(`Invalid IP from SpeedTest Server (${detail})`);
}
const { default: getColoCountry } = await import('@/data/speedtest-colos.js');
@@ -348,8 +352,11 @@ const engineMethods = {
});
},
// The engine's queued timers deliver one last measurement after onFinish /
// onUnmounted dropped the reference, so the engine may already be gone.
updateProgress() {
const rawData = testEngine.results.raw;
const rawData = testEngine?.results?.raw;
if (!rawData) return;
// 3 stages: latency / download / upload.
const perStage = 100 / 3;
let progress = 0;
+1 -1
View File
@@ -10,7 +10,7 @@
<!-- Benefits List -->
<ul class="rounded-lg border bg-card divide-y">
<li v-for="n in 5" :key="n" class="flex items-start gap-3 p-3 text-sm">
<li v-for="n in 6" :key="n" class="flex items-start gap-3 p-3 text-sm">
<span
class="shrink-0 inline-flex items-center justify-center size-5 rounded-full bg-success/15 text-success mt-0.5">
<CircleCheck class="size-3.5" />
@@ -85,6 +85,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { emitAppEvent } from '@/utils/app-events.js';
import { parseTrace } from '@/utils/parse-trace.js';
import getCountryName from '@/data/country-name.js';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -138,12 +139,8 @@ const fetchTrace = async (id, url) => {
try {
const response = await fetch(`https://${url}/cdn-cgi/trace`);
const data = await response.text();
const lines = data.split('\n');
const ipLine = lines.find((line) => line.startsWith('ip='));
const countryLine = lines.find((line) => line.startsWith('loc='));
const country = countryLine ? countryLine.split('=')[1] : '';
if (ipLine) {
const ip = ipLine.split('=')[1];
const { ip, loc: country = '' } = parseTrace(data);
if (ip) {
ruleTests.value[id].ip = ip;
IPArray.value = [...IPArray.value, { ip, country }];
}
@@ -155,8 +152,7 @@ const fetchTrace = async (id, url) => {
// no org — MaxMind fills that gap. Country resolution stays on
// trace (authoritative for each ptest worker's egress), so a
// MaxMind miss leaves the existing country untouched.
if (ipLine) {
const ip = ruleTests.value[id].ip;
if (ip) {
const geo = await lookupMaxmind(ip);
ruleTests.value[id].org = geo ? geo.org : t('ruletest.StatusError');
if (geo) {
@@ -209,4 +209,7 @@ const onDownloadJson = () => {
const expiresAtDisplay = computed(() =>
expiresAt.value ? new Date(expiresAt.value).toLocaleString() : '');
// For the `e` keyboard shortcut (use-shortcuts.js).
defineExpose({ openDialog });
</script>
+52 -13
View File
@@ -1,36 +1,64 @@
<template>
<!-- Touch-only chrome for the GitBook Assistant panel (see
composables/use-docs-assistant.js). The panel opens and closes through
the embed's own launcher; what it lacks on phones is a way to dismiss
by tapping outside, and a scroll lock — without one, swiping a chat too
short to scroll drags the page underneath instead. Desktop keeps page
scrolling, as with our other panels. Placement of the launcher and the
panel lives in style.css next to the embed overrides. -->
<!-- Our own chrome for the GitBook Assistant panel (see
composables/use-docs-assistant.js). The embed's stock launcher is hidden
in style.css and replaced by the toggle below, so the control matches
the FAB dock's buttons instead of GitBook's own pill — and mirrors it
across the page, on the same baseline and the same inset from the
1600px content area.
On touch screens the panel also gets a tap-outside backdrop and a body
scroll lock: without one, swiping a chat too short to scroll drags the
page underneath. Desktop keeps page scrolling, as with our other panels. -->
<Teleport to="body">
<Transition enter-active-class="transition-opacity duration-200" enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-200" leave-to-class="opacity-0">
<div v-if="isOpen && isMobile" class="fixed inset-0 z-1090 bg-black/40" aria-hidden="true" @click="closeDocs">
</div>
</Transition>
<!-- Mirror of FloatingDock: same bottom-9 baseline, same 18px gutter off
the content area, so the two sides stay symmetric on wide screens.
No enter/leave transition — the toggle swaps state often enough that
animating it just reads as a flicker. -->
<div v-if="isLoaded && isSignedIn" class="fixed bottom-9 z-1100" :style="{ left: DOCK_INSET }">
<JnTooltip :text="isOpen ? t('about.Close') : t('nav.AskCopilot')" side="right">
<Button size="icon" type="button" :variant="isOpen ? 'secondary' : 'outline'"
:aria-label="isOpen ? t('about.Close') : t('nav.AskCopilot')" :aria-expanded="isOpen"
class="rounded-full shadow-lg cursor-pointer" @click="toggleDocs">
<component :is="isOpen ? X : Sparkle" class="size-4" />
</Button>
</JnTooltip>
</div>
</Teleport>
</template>
<script setup>
import { computed, watch, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { useDocsAssistant } from '@/composables/use-docs-assistant';
import { useDocsAssistant, isSettling } from '@/composables/use-docs-assistant';
import { Button } from '@/components/ui/button';
import { JnTooltip } from '@/components/ui/tooltip';
import { Sparkle, X } from '@lucide/vue';
const PANEL_SELECTOR = '#gitbook-widget-window';
const SYNC_INTERVAL = 300;
// FloatingDock hugs the 1600px content area with an 18px gutter; past that
// width it steps inward by half the overflow. max() keeps it at 18px below.
const DOCK_INSET = 'max(18px, calc((100vw - 1600px) / 2 + 18px))';
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// The assistant is a sign-in benefit (see DocsSearch.vue) — signing out mid
// session takes the toggle away with the rest of the entry points.
const isSignedIn = computed(() => store.isSignedIn === true);
const { isOpen, isLoaded, closeDocs, setOpen } = useDocsAssistant();
const { isOpen, isLoaded, askDocs, closeDocs, setOpen } = useDocsAssistant();
// The embed fires no open/close callbacks and its launcher toggles the panel
// on its own, so read the panel element instead — both directions. Polling
// starts only once the embed has been loaded (first docs search of the
const toggleDocs = () => (isOpen.value ? closeDocs() : askDocs(''));
// The embed fires no open/close callbacks, so read the panel element instead.
// Polling starts once the embed has been loaded (first docs search of the
// session) and stops on unmount.
let syncTimer = null;
@@ -43,7 +71,12 @@ const panelIsVisible = () => {
const startSync = () => {
if (syncTimer) return;
syncTimer = setInterval(() => setOpen(panelIsVisible()), SYNC_INTERVAL);
syncTimer = setInterval(() => {
// Our own open/close already set the state; skip while the panel is still
// animating into it, or the stale measurement flips the toggle back.
if (isSettling()) return;
setOpen(panelIsVisible());
}, SYNC_INTERVAL);
};
const stopSync = () => {
@@ -67,6 +100,12 @@ watch([isOpen, isMobile], ([open, mobile]) => {
lockScroll(open && mobile);
}, { immediate: true });
// Signing out mid-conversation closes the panel too — otherwise it would sit
// there with no visible way back to it.
watch(isSignedIn, (signedIn) => {
if (!signedIn && isOpen.value) closeDocs();
});
const onKeydown = (event) => {
if (event.key === 'Escape' && isOpen.value) closeDocs();
};
+26 -22
View File
@@ -1,20 +1,22 @@
<template>
<!-- Nav entry point for the docs assistant. Desktop gets an ask box whose
placeholder rotates through preset questions — so it reads as "ask
anything", and Enter on an empty box asks whatever is on show. Mobile
gets the same entry as a single icon. Both are inert while the panel
itself is open (it owns the conversation then).
<!-- Nav entry point for the docs assistant. Desktop gets an ask box, mobile
the same entry as a single icon. Both are inert while the panel itself
is open (it owns the conversation then).
The embed lives in widgets/DocsAssistant.vue; the loading and
configuration in composables/use-docs-assistant.js. -->
<form v-if="enabled && !isMobile" class="relative" @submit.prevent="submitDocsSearch">
<Sparkle class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground z-10" />
<Input v-model="query" type="text" :aria-label="t('nav.SearchDocs')" :disabled="isDocsOpen"
<!-- Focused, the native placeholder prompts for a question; idle, the
rotating overlay below advertises what can be asked. -->
<Input v-model="query" type="text" :aria-label="t('nav.AskCopilot')" :disabled="isDocsOpen"
:placeholder="isFocused ? t('nav.DocsPrompt') : ''"
class="h-8 w-44 focus:w-64 transition-[width] duration-300 pl-8 text-sm" autocomplete="off" autocorrect="off"
autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true"
@keydown.enter.prevent="submitDocsSearch" @focus="isFocused = true" @blur="isFocused = false" />
<!-- Rotating placeholder rendered as an overlay (native placeholders
can't animate). Hidden as soon as the visitor types. -->
<div v-if="!query" class="pointer-events-none absolute inset-y-0 left-8 right-2 flex items-center overflow-hidden">
<!-- Rotating sample questions, as an overlay because native placeholders
can't animate. Hidden once the box is focused or has text. -->
<div v-if="!query && !isFocused"
class="pointer-events-none absolute inset-y-0 left-8 right-2 flex items-center overflow-hidden">
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 translate-y-3"
enter-to-class="opacity-100 translate-y-0" leave-active-class="transition-all duration-300 ease-in absolute"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 -translate-y-3">
@@ -25,8 +27,8 @@
</div>
</form>
<JnTooltip v-else-if="enabled" :text="t('nav.SearchDocs')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" :aria-label="t('nav.SearchDocs')"
<JnTooltip v-else-if="enabled" :text="t('nav.AskCopilot')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" :aria-label="t('nav.AskCopilot')"
:disabled="isDocsOpen" @click="openAssistant">
<Sparkle />
</Button>
@@ -50,9 +52,12 @@ const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Needs a docs site to answer from: configured at build time, and only on the
// canonical deployment — same gate as the original-site-only advanced tools.
const enabled = computed(() => isDocsConfigured && store.configs?.originalSite === true);
// Three gates: a docs site to answer from (build time), the canonical
// deployment (same as the original-site-only advanced tools), and a signed-in
// visitor.
const enabled = computed(() => isDocsConfigured
&& store.configs?.originalSite === true
&& store.isSignedIn === true);
// While the assistant panel is open it owns the conversation, so this entry
// point goes inert — otherwise there are two places to type. State comes from
@@ -63,9 +68,9 @@ const { askDocs, docsQuestions, isOpen: isDocsOpen } = useDocsAssistant();
const query = ref('');
const isFocused = ref(false);
// The placeholder freezes once the box has focus or any text — the visible
// question is the one Enter sends, so it must not change underfoot. Locale
// switches re-resolve through docsQuestions().
// Sample questions cycle while the box sits idle. Purely advertising — they
// are never submitted on the visitor's behalf, so an idle box costs nothing.
// Locale switches re-resolve through docsQuestions().
const placeholderIndex = ref(0);
const placeholder = computed(() => {
const questions = docsQuestions();
@@ -82,14 +87,13 @@ onBeforeUnmount(() => {
if (rotateTimer) clearInterval(rotateTimer);
});
// Enter on an empty box asks whatever question is currently on show, so the
// rotating placeholder doubles as a one-keystroke suggestion.
// Only a typed question is ever sent — an empty box does nothing, so a stray
// Enter can't spend assistant quota.
const submitDocsSearch = () => {
if (isDocsOpen.value) return;
const typed = query.value.trim();
const question = typed || placeholder.value;
const question = query.value.trim();
if (!question) return;
trackEvent('Nav', 'DocsSearch', typed ? 'submit' : 'placeholder');
trackEvent('Nav', 'DocsSearch', 'submit');
askDocs(question);
query.value = '';
};
+1 -16
View File
@@ -168,7 +168,7 @@
</template>
<script setup>
import { computed, ref, watch, onMounted, h } from 'vue';
import { computed, ref, watch, h } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -198,7 +198,6 @@ import {
const { t } = useI18n();
const store = useMainStore();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const ipDBs = computed(() => store.ipDBs);
@@ -231,15 +230,6 @@ const currentIpDB = computed(() =>
ipDBs.value.find(db => db.id === userPreferences.value.ipGeoSource)
);
const updateIPDBs = () => {
if (configs.value && Object.keys(configs.value).length > 0) {
store.updateIPDBs({ id: 0, enabled: configs.value.ipChecking });
store.updateIPDBs({ id: 1, enabled: configs.value.ipInfo });
store.updateIPDBs({ id: 3, enabled: configs.value.ipapiis });
store.updateIPDBs({ id: 4, enabled: configs.value.ip2location });
}
};
const prefTheme = (value) => {
// Application is handled by use-theme.js, which watches this preference.
store.updatePreference('theme', value);
@@ -310,11 +300,6 @@ const prefipGeoSource = (value) => {
trackEvent('IPCheck', 'SelectSource', ipDBs.value.find(x => x.id === value).text);
};
onMounted(() => {
setTimeout(updateIPDBs, 4000);
});
// Section title: lucide icon + text, unified rhythm
const SectionTitle = (props, { slots }) =>
h('h3', { class: 'flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2' }, [
@@ -47,6 +47,15 @@ let loadPromise = null;
const isOpen = ref(false);
const isLoaded = ref(false);
// Opening and closing are animated, so for a moment after we act the panel
// still measures as its previous state. DocsAssistant.vue's watcher consults
// this before overwriting `isOpen` — otherwise a close reads as "still open"
// on the next tick and the toggle icon flickers back and forth.
const SETTLE_MS = 700;
let lastIntentAt = 0;
const markIntent = () => { lastIntentAt = Date.now(); };
export const isSettling = () => Date.now() - lastIntentAt < SETTLE_MS;
const loadEmbedScript = () => {
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve, reject) => {
@@ -116,6 +125,7 @@ export function useDocsAssistant() {
};
const closeDocs = () => {
markIntent();
isOpen.value = false;
if (typeof window.GitBook === 'function') window.GitBook('close');
};
@@ -142,6 +152,8 @@ export function useDocsAssistant() {
tools: [readResultsTool],
});
window.GitBook('open');
markIntent();
isOpen.value = true;
if (question) {
window.GitBook('navigateToAssistant');
window.GitBook('postUserMessage', question);
+4 -17
View File
@@ -23,12 +23,9 @@ import { scrollToElement } from '../utils/scroll-to.js';
function buildShortcutConfig({ refs, store, t, configs, userPreferences }) {
const {
navBarRef,
preferencesRef,
queryIPRef,
helpModalRef,
additionalRef,
footerRef,
shareReportRef,
speedTestRef,
advancedToolsRef,
IPCheckRef,
@@ -191,19 +188,9 @@ function buildShortcutConfig({ refs, store, t, configs, userPreferences }) {
description: t('shortcutKeys.IPHistory'),
},
{
keys: 'p',
action: () => { navBarRef.value.OpenPreferences(); trackEvent('ShortCut', 'ShortCut', 'Preferences'); },
description: t('shortcutKeys.Preferences'),
},
{
keys: 'a',
action: () => { footerRef.value?.openAbout(); trackEvent('ShortCut', 'ShortCut', 'About'); },
description: t('shortcutKeys.About'),
},
{
keys: 'x',
action: () => { additionalRef.value?.openCurlModal(); trackEvent('ShortCut', 'ShortCut', 'Curl'); },
description: t('shortcutKeys.Curl'),
keys: 'e',
action: () => { shareReportRef.value?.openDialog(); trackEvent('ShortCut', 'ShortCut', 'ShareReport'); },
description: t('shortcutKeys.ShareReport'),
},
{
keys: '?',
+2 -2
View File
@@ -88,7 +88,7 @@
"type": "add",
"change": {
"en": "Added DNS leak test",
"zh": "添加了 DNS 泄漏测试",
"zh": "添加了 DNS 泄露测试",
"fr": "Ajout du test de fuite DNS",
"ru": "Добавлен тест утечки DNS"
}
@@ -1021,7 +1021,7 @@
"type": "add",
"change": {
"en": "In-depth DNS Leak Test: captures every recursive resolver you use, with ECS and DNSSEC analysis",
"zh": "深度 DNS 泄漏测试:捕获所有递归解析器并分析 ECS 与 DNSSEC",
"zh": "深度 DNS 泄露测试:捕获所有递归解析器并分析 ECS 与 DNSSEC",
"fr": "Test de fuite DNS approfondi : capture tous les résolveurs récursifs utilisés, avec analyse ECS et DNSSEC",
"ru": "Углублённый тест утечки DNS: выявляет все используемые рекурсивные резолверы и анализирует ECS и DNSSEC"
}
+33 -6
View File
@@ -1,17 +1,18 @@
// IP database definitions
//
// Each item: { id, text, url, enabled }
// Each item: { id, text, url, enabled, configKey? }
// - id numeric identifier, referenced elsewhere in the app (e.g. userPreferences.ipGeoSource)
// - text UI display name, also lookup key (e.g. `WebRtcTest.vue` looks for "MaxMind")
// - url template string, {{ip}} and {{lang}} will be replaced by buildDbUrl()
// - enabled initial enabled state (user can toggle in Preferences, store will write back this field)
// - enabled availability, derived from /api/configs (runtime failures never touch it)
// - configKey /api/configs flag gating this source; absent = key-free, always available
export const IP_DATABASES = [
{ id: 0, text: 'IPCheck.ing', url: '/api/ipchecking?ip={{ip}}&lang={{lang}}', enabled: true },
{ id: 1, text: 'IPinfo.io', url: '/api/ipinfo?ip={{ip}}', enabled: true },
{ id: 0, text: 'IPCheck.ing', url: '/api/ipchecking?ip={{ip}}&lang={{lang}}', enabled: true, configKey: 'ipChecking' },
{ id: 1, text: 'IPinfo.io', url: '/api/ipinfo?ip={{ip}}', enabled: true, configKey: 'ipInfo' },
{ id: 2, text: 'IP-API.com', url: '/api/ipapicom?ip={{ip}}&lang={{lang}}', enabled: true },
{ id: 3, text: 'IPAPI.is', url: '/api/ipapiis?ip={{ip}}', enabled: true },
{ id: 4, text: 'IP2Location.io', url: '/api/ip2location?ip={{ip}}', enabled: true },
{ id: 3, text: 'IPAPI.is', url: '/api/ipapiis?ip={{ip}}', enabled: true, configKey: 'ipapiis' },
{ id: 4, text: 'IP2Location.io', url: '/api/ip2location?ip={{ip}}', enabled: true, configKey: 'ip2location' },
{ id: 5, text: 'IP.sb', url: '/api/ipsb?ip={{ip}}', enabled: true },
{ id: 6, text: 'MaxMind', url: '/api/maxmind?ip={{ip}}&lang={{lang}}', enabled: true },
];
@@ -31,3 +32,29 @@ export function buildDbUrl(db, ip, lang) {
if (!db || !db.url) return null;
return db.url.replace('{{ip}}', ip).replace('{{lang}}', lang || 'en');
}
/**
* Pure function: derive availability from /api/configs — keyed sources follow
* their flag, key-free sources stay enabled. Returns a fresh array.
*/
export const applyConfigAvailability = (dbs, configs) =>
dbs.map((db) => ({
...db,
enabled: db.configKey ? !!configs?.[db.configKey] : true,
}));
/**
* Pure function: nearest available source id, walking forward cyclically from
* preferredId (the direction the fetch fallback walks). Unknown ids start
* from the head; nothing enabled → preferredId unchanged.
*/
export const nearestEnabledId = (preferredId, dbs) => {
if (!dbs.length) return preferredId;
const idx = dbs.findIndex((db) => db.id === preferredId);
const start = idx === -1 ? 0 : idx;
for (let i = 0; i < dbs.length; i++) {
const db = dbs[(start + i) % dbs.length];
if (db.enabled) return db.id;
}
return preferredId;
};
+12 -8
View File
@@ -28,11 +28,12 @@
"Benefit": "Benefit",
"Note1": "As an open-source product, your use of IPCheck.ing is free of charge.",
"Note2": "However, to prevent abuse, we limit some features. By logging in, you will receive the following benefits:",
"Benefit1": "When you use IPCheck.ing as IP data source, all fields are displayed in full.",
"Benefit2": "You can use the invisibility test.",
"Benefit3": "You can use the in-depth DNS leak test.",
"Benefit1": "When you use IPCheck.ing as IP data source, all fields are displayed in full",
"Benefit2": "You can use the invisibility test",
"Benefit3": "You can use the in-depth DNS leak test",
"Benefit4": "You can view your own achievement badges",
"Benefit5": "Reduces access restrictions on some features.",
"Benefit5": "You can ask IPChecking Copilot, our AI assistant",
"Benefit6": "Reduces access restrictions on some features",
"FootNote": "More benefits are being developed..."
},
"Achievements": {
@@ -151,6 +152,7 @@
"SearchDocs": "Search docs…",
"DocsToolConfirm": "Share your current test results?",
"DocsToolSummary": "Read your on-page results",
"AskCopilot": "Ask IPChecking Copilot",
"OpenDocs": "Open docs",
"DocsGreetingTitle": "How can I help?",
"DocsGreetingSubtitle": "Ask me anything about MyIP — the tools, your results, or self-hosting.",
@@ -188,7 +190,8 @@
"ipDBTips": "You can select the default IP geolocation source to use. If the selected source is unavailable, the system will use the subsequent sources in order.",
"language": "Language Setting",
"languageTips": "Refreshing the browser will take effect."
}
},
"DocsPrompt": "Type your question…"
},
"securitychecklist": {
"Title": "Security Checklist",
@@ -923,7 +926,9 @@
"unmaskedInfoTitle": "IPs Unmasked",
"unmaskedInfoMessage": "Be careful when sharing screenshots.",
"SignInFailed": "Sign In Failed",
"SignInFailedReason": "Reason"
"SignInFailedReason": "Reason",
"IpGeoSourceFallbackTitle": "IP Geolocation Source Switched",
"IpGeoSourceFallbackMessage": "{from} is temporarily unavailable — this result comes from {to} for now."
},
"shortcutKeys": {
"GoToTop": "Go to Top",
@@ -942,6 +947,7 @@
"IPCheck": "Query IP Address",
"ToggleInfoMask": "Toggle Info Masking",
"IPHistory": "Open IP History panel",
"ShareReport": "Open Share Report dialog",
"Help": "Show Shortcut Keys",
"HelpNote": "People who use shortcuts are really awesome!",
"GoNext": "Next Card",
@@ -950,7 +956,6 @@
"RefreshRuleTests": "Refresh Rule Tests",
"CensorshipCheck": "Open Censorship Check panel",
"Preferences": "Open Preferences",
"About": "Open About panel",
"Whois": "Open Whois Search panel",
"ServiceStatus": "Open Service Status panel",
"InvisibilityTest": "Open Invisibility Test panel",
@@ -958,7 +963,6 @@
"MacChecker": "Open MAC lookup panel",
"BrowserInfo": "Open Browser Info panel",
"fullScreenAdvancedTools": "Full Screen Advanced Tools",
"Curl": "Command Line API",
"SecurityChecklist": "Open Security Checklist panel"
},
"page": {
+12 -8
View File
@@ -28,11 +28,12 @@
"Benefit": "Avantage",
"Note1": "En tant que produit open-source, l'utilisation de IPCheck.ing est gratuite.",
"Note2": "Cependant, afin de prévenir les abus, nous limitons certaines fonctionnalités. En vous connectant, vous obtiendrez les avantages suivants :",
"Benefit1": "Lorsque vous utilisez IPCheck.ing comme source de données IP, tous les champs sont affichés intégralement.",
"Benefit2": "Peut utiliser le test d'invisibilité.",
"Benefit3": "Peut utiliser le test d'invisibilité.",
"Benefit1": "Lorsque vous utilisez IPCheck.ing comme source de données IP, tous les champs sont affichés intégralement",
"Benefit2": "Peut utiliser le test d'invisibilité",
"Benefit3": "Peut utiliser le test de fuite DNS en profondeur",
"Benefit4": "Peut voir les badges de réussite qu'il a obtenus",
"Benefit5": "Réduit les restrictions d'accès à certaines fonctionnalités.",
"Benefit5": "Peut poser des questions à IPChecking Copilot, notre assistant IA",
"Benefit6": "Réduit les restrictions d'accès à certaines fonctionnalités",
"FootNote": "D'autres avantages sont en cours de développement..."
},
"Achievements": {
@@ -151,6 +152,7 @@
"SearchDocs": "Rechercher dans la doc…",
"DocsToolConfirm": "Partager vos résultats actuels ?",
"DocsToolSummary": "Résultats de la page lus",
"AskCopilot": "Demander à IPChecking Copilot",
"OpenDocs": "Ouvrir la doc",
"DocsGreetingTitle": "Comment puis-je vous aider ?",
"DocsGreetingSubtitle": "Posez vos questions sur MyIP : les outils, vos résultats ou l'auto-hébergement.",
@@ -188,7 +190,8 @@
"ipDBTips": "Vous pouvez sélectionner la source de données de géolocalisation IP par défaut. Si la source sélectionnée n'est pas disponible, le système utilisera les sources suivantes.",
"language": "Paramètres de langue",
"languageTips": "Rafraîchissement du navigateur pour prendre effet."
}
},
"DocsPrompt": "Saisissez votre question…"
},
"securitychecklist": {
"Title": "Liste de vérification de sécurité",
@@ -924,7 +927,9 @@
"unmaskedInfoTitle": "IPs démasquées",
"unmaskedInfoMessage": "Faites attention lors de la partage des captures d'écran.",
"SignInFailed": "Echec de connexion",
"SignInFailedReason": "Raison"
"SignInFailedReason": "Raison",
"IpGeoSourceFallbackTitle": "Source de géolocalisation IP changée",
"IpGeoSourceFallbackMessage": "{from} est momentanément indisponible — ce résultat provient temporairement de {to}."
},
"shortcutKeys": {
"GoToTop": "Aller en haut",
@@ -943,6 +948,7 @@
"IPCheck": "Vérification IP",
"ToggleInfoMask": "Masquer/Démasquer les informations",
"IPHistory": "Ouvrir l'historique des IP",
"ShareReport": "Ouvrir le partage de rapport",
"Help": "Afficher les raccourcis clavier",
"HelpNote": "Les personnes qui utilisent des raccourcis clavier sont vraiment géniales !",
"GoNext": "Carte suivante",
@@ -951,7 +957,6 @@
"RefreshRuleTests": "Actualiser les tests de règles",
"CensorshipCheck": "Ouvrir le Test de censorship",
"Preferences": "Ouvrir les Préférences",
"About": "Ouvrir À propos",
"Whois": "Ouvrir la Recherche Whois",
"ServiceStatus": "Ouvrir l'état des services",
"InvisibilityTest": "Ouvrir le Test d'invisibilité",
@@ -959,7 +964,6 @@
"MacChecker": "Ouvrir le Recherche de MAC",
"BrowserInfo": "Ouvrir l'Info du navigateur",
"fullScreenAdvancedTools": "Outils avancés en plein écran",
"Curl": "Ouvrir le Shell",
"SecurityChecklist": "Ouvrir la Liste de sécurité"
},
"page": {
+12 -8
View File
@@ -28,11 +28,12 @@
"Benefit": "Преимущество",
"Note1": "IPCheck.ing — продукт с открытым исходным кодом, которым можно пользоваться бесплатно.",
"Note2": "Однако для предотвращения злоупотреблений некоторые функции ограничены. После входа вам будут доступны следующие преимущества:",
"Benefit1": "При использовании IPCheck.ing как источника IP-данных все поля отображаются полностью.",
"Benefit2": "Можно использовать тест незаметности.",
"Benefit3": "Можно использовать углублённый тест утечки DNS.",
"Benefit1": "При использовании IPCheck.ing как источника IP-данных все поля отображаются полностью",
"Benefit2": "Можно использовать тест незаметности",
"Benefit3": "Можно использовать углублённый тест утечки DNS",
"Benefit4": "Можно просматривать свои значки достижений",
"Benefit5": "Снижаются ограничения доступа к некоторым функциям.",
"Benefit5": "Вы можете задавать вопросы IPChecking Copilot — нашему ИИ-помощнику",
"Benefit6": "Снижаются ограничения доступа к некоторым функциям",
"FootNote": "Мы разрабатываем новые преимущества..."
},
"Achievements": {
@@ -151,6 +152,7 @@
"SearchDocs": "Поиск по документации…",
"DocsToolConfirm": "Показать ваши текущие результаты?",
"DocsToolSummary": "Результаты со страницы прочитаны",
"AskCopilot": "Спросить IPChecking Copilot",
"OpenDocs": "Открыть документацию",
"DocsGreetingTitle": "Чем могу помочь?",
"DocsGreetingSubtitle": "Спросите о MyIP: инструменты, ваши результаты или самостоятельный хостинг.",
@@ -188,7 +190,8 @@
"ipDBTips": "Можно выбрать основной источник геолокации IP. Если он недоступен, система по очереди использует следующие источники.",
"language": "Язык",
"languageTips": "Изменение вступит в силу после обновления страницы."
}
},
"DocsPrompt": "Введите свой вопрос…"
},
"securitychecklist": {
"Title": "Контрольный список безопасности",
@@ -924,7 +927,9 @@
"unmaskedInfoTitle": "IP показаны",
"unmaskedInfoMessage": "Будьте осторожны, публикуя снимки экрана.",
"SignInFailed": "Не удалось войти",
"SignInFailedReason": "Причина"
"SignInFailedReason": "Причина",
"IpGeoSourceFallbackTitle": "Источник IP-геоданных переключён",
"IpGeoSourceFallbackMessage": "{from} временно недоступен — этот результат временно получен из {to}."
},
"shortcutKeys": {
"GoToTop": "Перейти наверх",
@@ -943,6 +948,7 @@
"IPCheck": "Запросить IP-адрес",
"ToggleInfoMask": "Переключить скрытие сведений",
"IPHistory": "Открыть панель истории IP",
"ShareReport": "Открыть панель отчёта",
"Help": "Показать сочетания клавиш",
"HelpNote": "Пользователи сочетаний клавиш — просто супер!",
"GoNext": "Следующая карточка",
@@ -951,7 +957,6 @@
"RefreshRuleTests": "Обновить проверки правил",
"CensorshipCheck": "Открыть панель проверки цензуры",
"Preferences": "Открыть настройки",
"About": "Открыть панель «О проекте»",
"Whois": "Открыть панель поиска Whois",
"ServiceStatus": "Открыть панель состояния сервисов",
"InvisibilityTest": "Открыть панель теста незаметности",
@@ -959,7 +964,6 @@
"MacChecker": "Открыть панель поиска MAC-адреса",
"BrowserInfo": "Открыть панель сведений о браузере",
"fullScreenAdvancedTools": "Расширенные инструменты во весь экран",
"Curl": "API командной строки",
"SecurityChecklist": "Открыть панель контрольного списка безопасности"
},
"page": {
+2 -2
View File
@@ -137,7 +137,7 @@
"title": "网页浏览",
"slug": "web-browsing",
"description": "避免在线跟踪、审查和数据收集",
"intro": "互联网上的大多数网站都会使用某种形式的跟踪,通常是为了深入了解用户的行为和偏好。这些数据可能非常详细,因此对企业、政府和知识产权窃贼来说非常有价值。数据泄露和泄漏很常见,而对用户的网络活动进行去匿名化通常是一项微不足道的任务。\n\n有两种主要的跟踪方法:有状态(基于 cookie)和无状态(基于指纹)。Cookie 是存储在浏览器中的一小段信息,具有用于识别您的唯一 ID。浏览器指纹是一种高度准确的方法,可以识别和跟踪用户在网上任何地方的行踪。收集的信息非常全面,通常包括浏览器详细信息、操作系统、屏幕分辨率、支持的字体、插件、时区、语言和字体偏好,甚至硬件配置。\n\n本节概述了您可以采取的步骤,以更好地保护自己免受威胁,最大程度地减少在线跟踪并提高隐私性。",
"intro": "互联网上的大多数网站都会使用某种形式的跟踪,通常是为了深入了解用户的行为和偏好。这些数据可能非常详细,因此对企业、政府和知识产权窃贼来说非常有价值。数据泄露和泄露很常见,而对用户的网络活动进行去匿名化通常是一项微不足道的任务。\n\n有两种主要的跟踪方法:有状态(基于 cookie)和无状态(基于指纹)。Cookie 是存储在浏览器中的一小段信息,具有用于识别您的唯一 ID。浏览器指纹是一种高度准确的方法,可以识别和跟踪用户在网上任何地方的行踪。收集的信息非常全面,通常包括浏览器详细信息、操作系统、屏幕分辨率、支持的字体、插件、时区、语言和字体偏好,甚至硬件配置。\n\n本节概述了您可以采取的步骤,以更好地保护自己免受威胁,最大程度地减少在线跟踪并提高隐私性。",
"checklist": [
{
"point": "确保网站合法",
@@ -766,7 +766,7 @@
"slug": "networks-implement-a-network-wide-vpn"
},
{
"point": "防止 DNS 泄漏",
"point": "防止 DNS 泄露",
"priority": "Optional",
"details": "使用 VPN 时,仅使用 VPN 提供商或安全服务的 DNS 服务器非常重要。",
"slug": "networks-protect-against-dns-leaks"
+27 -23
View File
@@ -30,9 +30,10 @@
"Note2": "但是,为了避免滥用,我们会限制一些功能。当你登录后,你将得到以下权益:",
"Benefit1": "当你使用 IPCheck.ing 作为 IP 数据解析源时,完整显示所有字段",
"Benefit2": "可以使用隐身测试",
"Benefit3": "可以使用深度 DNS 泄漏测试",
"Benefit3": "可以使用深度 DNS 泄露测试",
"Benefit4": "可以看到自己获得的成就勋章",
"Benefit5": "减少部分功能的访问限制",
"Benefit5": "可以使用 AI 助手 IPChecking Copilot 提问",
"Benefit6": "减少部分功能的访问限制",
"FootNote": "更多权益陆续开发中..."
},
"Achievements": {
@@ -142,7 +143,7 @@
"Connectivity": "网络连通性",
"ServiceStatus": "服务可用性",
"WebRTC": "WebRTC 泄露测试",
"DNSLeakTest": "DNS 泄漏测试",
"DNSLeakTest": "DNS 泄露测试",
"SpeedTest": "网速测试",
"PingTest": "全球延迟",
"MTRTest": "MTR 测试",
@@ -151,13 +152,14 @@
"SearchDocs": "搜索文档…",
"DocsToolConfirm": "把当前的检测结果给助手看?",
"DocsToolSummary": "已读取页面上的检测结果",
"AskCopilot": "询问 IPChecking Copilot",
"OpenDocs": "打开文档",
"DocsGreetingTitle": "有什么可以帮你?",
"DocsGreetingSubtitle": "关于 MyIP 的工具、测试结果或自部署,都可以问我。",
"DocsQuestions": [
"我的 VPN 泄漏真实 IP 了吗?",
"我的 VPN 泄露真实 IP 了吗?",
"为什么我的 IP 归属地不对?",
"对比我的 WebRTC 测试和 DNS 泄漏测试结果并给出建议",
"对比我的 WebRTC 测试和 DNS 泄露测试结果并给出建议",
"MTR 结果怎么看?",
"怎么部署一个自己的 MyIP?",
"我想看更多帮助文档"
@@ -188,7 +190,8 @@
"ipDBTips": "你可以选择你默认使用的 IP 地理位置数据源,如果你选定的不可用,系统会依次使用后续的数据源。",
"language": "语言设置",
"languageTips": "刷新浏览器后生效。"
}
},
"DocsPrompt": "请输入你要询问的问题"
},
"securitychecklist": {
"Title": "安全检查清单",
@@ -479,15 +482,15 @@
"Provider": "信息服务商"
},
"enhanceddnsleaktest": {
"Title": "深度 DNS 泄漏测试",
"Note": "你已经在首页的 DNS 泄漏测试中大致了解了当前是否存在 DNS 泄漏,以及 DNS 请求的出口地理归属。在这个深度测试里,我们会从更多维度进行检测:捕获更多 DNS 解析器出口 IP、检查这些解析器是否通过 ECS 把你真实 IP 段的前缀转发给了权威服务器、以及它们是否支持 DNSSEC,从而帮助你对自己的 DNS 链路安全性形成更完整的认识。",
"Title": "深度 DNS 泄露测试",
"Note": "你已经在首页的 DNS 泄露测试中大致了解了当前是否存在 DNS 泄露,以及 DNS 请求的出口地理归属。在这个深度测试里,我们会从更多维度进行检测:捕获更多 DNS 解析器出口 IP、检查这些解析器是否通过 ECS 把你真实 IP 段的前缀转发给了权威服务器、以及它们是否支持 DNSSEC,从而帮助你对自己的 DNS 链路安全性形成更完整的认识。",
"Note2": "点击「开始测试」即可开始,通常几秒内就能得到结果。我们会在后台发起多次 DNS 探测,由我们的权威 DNS 服务器捕获你所使用的递归解析器出口 IP,一次测试通常可以捕获 20–40 条记录。若完全没有捕获到解析器,可能是浏览器的 DNS 缓存吃掉了请求,稍等片刻再试一次即可。",
"Note3": "由于这是深度测试,页面里会出现一些较为专业的概念(例如 ECS、DNSSEC 等)。不用担心,运行测试后你可以参考下方的「字段说明」来了解每个字段的具体含义。",
"Run": "开始测试",
"RunAgain": "再测一次",
"RunningProbes": "正在发起 DNS 探测...",
"PollingResult": "正在收集结果...",
"FetchError": "获取 DNS 泄漏测试结果失败,请稍后重试。",
"FetchError": "获取 DNS 泄露测试结果失败,请稍后重试。",
"Empty": "本次会话没有捕获到任何 DNS 解析器,可能是浏览器的 DNS 缓存吃掉了请求,请稍后重试。",
"TableTitle": "捕获到的 DNS 查询记录",
"DedupeToggle": "隐藏重复项",
@@ -522,7 +525,7 @@
},
"Legend": {
"Title": "字段说明",
"ResolverIP": "实际与我们权威 DNS 服务器通信的递归解析器出口 IP。如果你使用 VPN,把它的 ASN 与你当前 VPN 出口的 ASN 做对比,就能看出 DNS 请求是否绕过了 VPN 通道(即发生了泄漏)。",
"ResolverIP": "实际与我们权威 DNS 服务器通信的递归解析器出口 IP。如果你使用 VPN,把它的 ASN 与你当前 VPN 出口的 ASN 做对比,就能看出 DNS 请求是否绕过了 VPN 通道(即发生了泄露)。",
"QueryType": "解析器向权威服务器请求的记录类型(A、AAAA、HTTPS 等)。浏览器通常会同时发起 A 与 AAAA 查询。",
"Transport": "DNS 默认走 UDP;出现 TCP 通常意味着响应过大,而响应过大往往是因为带上了 DNSSEC 相关记录。",
"ECS": "EDNS Client Subnet —— 部分递归 DNS 会在向上游转发查询时附带你真实 IP 的一段前缀(通常是 IPv4 的 /24 或 IPv6 的 /48)。这意味着你的 IP 段会被 DNS 链路上的权威服务器获知,可能用于 CDN 的就近调度,也可能在一定程度上削弱匿名性。",
@@ -555,7 +558,7 @@
"SaveAsImage": "保存为图片",
"RefreshConnectivityTests": "刷新连通性测试",
"RefreshWebRTC": "刷新 WebRTC 泄露测试",
"RefreshDNSLeakTest": "刷新 DNS 泄漏测试",
"RefreshDNSLeakTest": "刷新 DNS 泄露测试",
"SpeedTestButton": "开始/暂停网速测试",
"ShowASNInfo": "显示 AS 详细信息",
"ShowASNHistory": "显示 ASN 历史",
@@ -594,12 +597,12 @@
"Heading": "MyIP 网络诊断报告",
"Intro": "本报告由 MyIP({origin})于 {time} 生成,数据来自在用户浏览器中运行的网络测试。字段名为稳定的 schema 标识符;所有延迟数值单位为毫秒。",
"Masked": "注意:出于隐私考虑,报告中的 IP 地址已遮盖尾段(IPv4 末段、IPv6 接口位,以 \"x\" 表示)。",
"Instruction": "请基于以上数据分析该网络的健康状况,指出异常项(DNS 泄漏、WebRTC 暴露、代理/VPN 特征、丢包、高延迟、路由问题等),并给出具体的排查建议。",
"Instruction": "请基于以上数据分析该网络的健康状况,指出异常项(DNS 泄露、WebRTC 暴露、代理/VPN 特征、丢包、高延迟、路由问题等),并给出具体的排查建议。",
"Sections": {
"ipinfo": "多个独立来源看到的公网 IP(存在代理或分流时不同来源可能不一致)。",
"connectivity": "浏览器对知名网站的可达性探测;timeMs 为展示的往返耗时,minTimeMs 为多轮中的最佳值。",
"webrtc": "通过 WebRTC STUN 暴露的 IP——若与主公网 IP 不同,真实地址可能绕过代理/VPN 泄漏。",
"dnsleak": "实际应答浏览器查询的 DNS 解析器出口 IP——解析器不属于用户的 ISP/VPN 供应商即为 DNS 泄漏迹象。",
"webrtc": "通过 WebRTC STUN 暴露的 IP——若与主公网 IP 不同,真实地址可能绕过代理/VPN 泄露。",
"dnsleak": "实际应答浏览器查询的 DNS 解析器出口 IP——解析器不属于用户的 ISP/VPN 供应商即为 DNS 泄露迹象。",
"speedtest": "对 Cloudflare 测得的带宽、延迟与负载延迟(bufferbloat);分数衡量流媒体/游戏/实时通话体验,越高越好。",
"pingtest": "全球 Globalping 探针对用户 IP 的 ICMP ping 统计。",
"mtrtest": "全球探针到用户 IP 的逐跳 MTR 路由追踪;中间跳的丢包可能只是 ICMP 限速,从某一跳起持续丢包才代表真实问题。",
@@ -812,7 +815,7 @@
},
"dnsleaktest": {
"Name": "DNS 出口检测",
"Title": "DNS 泄漏测试",
"Title": "DNS 泄露测试",
"Note": "DNS 泄露(DNS Leaks)的意思是,当你挂上 VPN/代理后,你解析域名时,依然通过当地的运营商进行解析,这时就有 DNS 泄露的风险。",
"Note2": "泄露测试的方法是通过访问新生成的域名,检测你是通过哪个地区的 DNS 出口进行解析,如果返回的出口区域和你当地的运营商区域相同,则有 DNS 泄露风险,你可能需要修改 VPN/代理设置。",
"EndpointCountry": "出口地区",
@@ -820,8 +823,8 @@
"StatusError": "测试出错",
"EnhancedBanner": {
"Title": "想做更深入的检查?",
"Note": "如果想进一步了解你正在使用的所有递归解析器是谁、它们的 ASN 归属、是否通过 ECS 把你真实 IP 的前缀转发给了上游、以及是否启用了 DNSSEC 校验,可以打开下方的「深度 DNS 泄漏测试」。",
"CTA": "打开深度 DNS 泄漏测试"
"Note": "如果想进一步了解你正在使用的所有递归解析器是谁、它们的 ASN 归属、是否通过 ECS 把你真实 IP 的前缀转发给了上游、以及是否启用了 DNSSEC 校验,可以打开下方的「深度 DNS 泄露测试」。",
"CTA": "打开深度 DNS 泄露测试"
}
},
"speedtest": {
@@ -924,7 +927,9 @@
"unmaskedInfoTitle": "IP 信息已显示",
"unmaskedInfoMessage": "分享截图时请注意隐私。",
"SignInFailed": "登录失败",
"SignInFailedReason": "原因"
"SignInFailedReason": "原因",
"IpGeoSourceFallbackTitle": "IP 解析数据源已切换",
"IpGeoSourceFallbackMessage": "解析源 {from} 暂时不可用,本次结果临时改由 {to} 提供。"
},
"shortcutKeys": {
"GoToTop": "回到顶部",
@@ -934,15 +939,16 @@
"RefreshIPCard": "刷新某张 IP 卡片",
"RefreshConnectivityTests": "刷新连通性测试",
"RefreshWebRTC": "刷新 WebRTC 泄露测试",
"RefreshDNSLeakTest": "刷新 DNS 泄漏测试",
"RefreshDNSLeakTest": "刷新 DNS 泄露测试",
"SpeedTestButton": "开始/暂停网速测试",
"PingTest": "打开全球延迟测试面板",
"MTRTest": "打开全球路由测试面板",
"MTRTest": "打开 MTR 测试面板",
"RuleTest": "打开规则测试面板",
"DNSResolver": "打开 DNS 解析器面板",
"IPCheck": "IP 查询",
"ToggleInfoMask": "切换信息隐藏",
"IPHistory": "打开 IP 历史记录面板",
"ShareReport": "打开分享报告面板",
"Help": "显示快捷键",
"HelpNote": "使用快捷键的人,是喜欢效率的人,你真棒!",
"GoNext": "下一张卡片",
@@ -951,15 +957,13 @@
"RefreshRuleTests": "刷新规则测试",
"CensorshipCheck": "打开封锁测试面板",
"Preferences": "打开偏好设置面板",
"About": "打开关于页面",
"Whois": "打开 Whois 查询面板",
"ServiceStatus": "打开服务可用性面板",
"InvisibilityTest": "打开隐身测试面板",
"EnhancedDnsLeakTest": "打开深度 DNS 泄漏测试面板",
"EnhancedDnsLeakTest": "打开深度 DNS 泄露测试面板",
"MacChecker": "打开物理地址查询面板",
"BrowserInfo": "打开浏览器信息面板",
"fullScreenAdvancedTools": "全屏展开高级工具面板",
"Curl": "命令行 API",
"SecurityChecklist": "打开安全检查清单"
},
"page": {
+16 -8
View File
@@ -4,7 +4,7 @@ import { loadFirebaseAuth } from './firebase-init.js';
import { writeAuthHint } from './utils/auth-hint.js';
import i18n from './locales/i18n.js';
import { createInitialAchievementsState } from './data/achievements.js';
import { createInitialIpDBs, buildDbUrl } from './data/ip-databases.js';
import { createInitialIpDBs, buildDbUrl, applyConfigAvailability, nearestEnabledId } from './data/ip-databases.js';
import { createDefaultPreferences, migrateLegacyPreferences, PREFS_STORAGE_KEY, LEGACY_PREFS_KEYS } from './data/default-preferences.js';
import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './data/sections.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
@@ -127,13 +127,6 @@ export const useMainStore = defineStore('main', {
document.documentElement.classList.toggle('dark', !!value);
}
},
// set IP database enable status
updateIPDBs({ id, enabled }) {
const index = this.ipDBs.findIndex(db => db.id === id);
if (index !== -1) {
this.ipDBs[index].enabled = enabled;
}
},
// set user preferences
setPreferences(userPreferences) {
this.userPreferences = userPreferences;
@@ -185,6 +178,21 @@ export const useMainStore = defineStore('main', {
})
.then(data => {
this.configs = data;
// Configs flags are the only thing that flips ipDBs.enabled. If the
// stored preference is no longer configured, migrate it to the
// nearest available source (the one case that rewrites the user's
// setting) and say so via toast. Failed fetch → all stay enabled.
this.ipDBs = applyConfigAvailability(this.ipDBs, data);
const preferred = this.userPreferences.ipGeoSource;
const nearest = nearestEnabledId(preferred, this.ipDBs);
if (nearest !== preferred) {
const from = this.ipDBs.find(db => db.id === preferred)?.text || `#${preferred}`;
const to = this.ipDBs.find(db => db.id === nearest)?.text || `#${nearest}`;
this.updatePreference('ipGeoSource', nearest);
this.setAlert(true, 'text-warning',
t('alert.IpGeoSourceFallbackMessage', { from, to }),
t('alert.IpGeoSourceFallbackTitle'), 5000);
}
})
.catch(error => console.error('Fetching configs failed: ', error));
},
+11 -64
View File
@@ -252,76 +252,23 @@ footer {
z-index: 10001 !important;
}
/* GitBook Docs Assistant embed (loaded on demand by use-docs-assistant.js).
The embed anchors its launcher and panel bottom-right, where our FAB stack
lives — move both to the bottom-LEFT, with the launcher on the FAB dock's
baseline (bottom-9 = 2.25rem). The panel must clear the launcher by the
same amount the embed leaves by default, so it stacks on top of it rather
than overlapping: launcher height (2.5rem) + the embed's own gap (0.75rem).
The launcher doubles as the panel's toggle, which is how it gets closed. */
#gitbook-widget-button,
#gitbook-widget-window {
--jn-gb-anchor: 2.25rem;
/* Mirror of FloatingDock's inset: it hugs the 1600px content area's right
edge with an 18px gutter, so past 1600px it steps inward by half the
overflow. max() keeps that at a plain 18px on narrower viewports. */
--jn-gb-inset: max(18px, (100vw - 1600px) / 2 + 18px);
right: auto !important;
left: var(--jn-gb-inset) !important;
}
/* Launcher restyled to match the FAB dock's icon buttons: a 2.25rem circle
(size-9) with the same elevation. The embed ships it as a pill with a text
label, so the label is dropped and the padding/width constraints that came
with it are reset — otherwise the circle squashes the icon. The icon keeps
its own 1rem box (size-4, as in buttonVariants) rather than scaling with
the button. */
The stock launcher is hidden — widgets/DocsAssistant.vue renders our own
toggle in its place, styled like the FAB dock's buttons. The panel is
pinned bottom-LEFT (the FAB stack owns the bottom-right), mirroring the
dock's inset off the 1600px content area and clearing the toggle by its
height plus a gap, with a height cap so it never runs off the top on
phones. dvh tracks mobile browser chrome as it collapses; the vh line is
the fallback for browsers without it. */
#gitbook-widget-button {
bottom: var(--jn-gb-anchor) !important;
width: 2.25rem !important;
height: 2.25rem !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: none !important;
padding: 0 !important;
gap: 0 !important;
border-radius: 9999px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) !important;
}
/* The embed names these parts inconsistently across versions, so match both
an id and a class of the same name. */
#gitbook-widget-button-label,
#gitbook-widget-button .gitbook-widget-button-label {
display: none !important;
}
/* flex:none is the point — inside a 2.25rem circle a flexible child would be
squeezed. The 1rem box matches the FAB icons (size-4 in buttonVariants). */
#gitbook-widget-button-icon,
#gitbook-widget-button .gitbook-widget-button-icon,
#gitbook-widget-button > svg {
width: 1rem !important;
height: 1rem !important;
flex: none !important;
margin: 0 !important;
}
#gitbook-widget-button-icon svg,
#gitbook-widget-button .gitbook-widget-button-icon svg {
width: 100% !important;
height: 100% !important;
display: block !important;
}
#gitbook-widget-window {
--jn-gb-anchor: 2.25rem;
--jn-gb-inset: max(18px, (100vw - 1600px) / 2 + 18px);
right: auto !important;
left: var(--jn-gb-inset) !important;
bottom: calc(var(--jn-gb-anchor) + 3.25rem) !important;
/* Raising the panel above the launcher eats into its headroom, so cap the
height to what is left — otherwise a tall panel runs off the top of the
screen on phones. dvh tracks mobile browser chrome as it collapses; the
vh line is the fallback for browsers without it. */
max-height: calc(100vh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
}
+2 -6
View File
@@ -1,5 +1,6 @@
import { isValidIP } from '@/utils/valid-ip.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { getIPFromMyExternalIP_V4 } from "./myexternalip-v4";
// Get IPv4 address from Cloudflare
@@ -7,12 +8,7 @@ const getIPFromCloudflare_V4 = async () => {
try {
const response = await fetchWithTimeout("https://1.0.0.1/cdn-cgi/trace");
const data = await response.text();
const lines = data.split("\n");
const ipLine = lines.find((line) => line.startsWith("ip="));
let ip = "";
if (ipLine) {
ip = ipLine.split("=")[1];
}
const ip = parseTrace(data).ip ?? "";
const source = "Cloudflare IPv4";
if (isValidIP(ip)) {
return {
+2 -6
View File
@@ -1,5 +1,6 @@
import { isValidIP } from '@/utils/valid-ip.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { getIPFromMyExternalIP_V6 } from "./myexternalip-v6";
// Get IPv6 address from Cloudflare
@@ -7,12 +8,7 @@ const getIPFromCloudflare_V6 = async () => {
try {
const response = await fetchWithTimeout("https://[2606:4700:4700::1111]/cdn-cgi/trace");
const data = await response.text();
const lines = data.split("\n");
const ipLine = lines.find((line) => line.startsWith("ip="));
let ip = "";
if (ipLine) {
ip = ipLine.split("=")[1];
}
const ip = parseTrace(data).ip ?? "";
const source = "Cloudflare IPv6";
if (isValidIP(ip)) {
return {
+2 -7
View File
@@ -1,5 +1,6 @@
import { isValidIP } from '@/utils/valid-ip.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { getIPFromIpify_V4 } from "./ipify-v4";
// Get IPv4 address from IPCheck.ing
@@ -51,13 +52,7 @@ const getFromTrace = async () => {
try {
const response = await fetchWithTimeout("https://4.ipcheck.ing/cdn-cgi/trace");
const data = await response.text();
const lines = data.split("\n");
const ipLine = lines.find((line) => line.startsWith("ip="));
let ip = "";
if (ipLine) {
ip = ipLine.split("=")[1];
}
return ip;
return parseTrace(data).ip ?? "";
} catch (error) {
console.warn("Error fetching IP from IPCheck.ing IPv4 Trace:", error);
throw error;
+2 -7
View File
@@ -1,5 +1,6 @@
import { isValidIP } from '@/utils/valid-ip.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { getIPFromIpify_V6 } from "./ipify-v6";
// Get IPv6 address from IPCheck.ing
@@ -51,13 +52,7 @@ const getFromTrace = async () => {
try {
const response = await fetchWithTimeout("https://6.ipcheck.ing/cdn-cgi/trace");
const data = await response.text();
const lines = data.split("\n");
const ipLine = lines.find((line) => line.startsWith("ip="));
let ip = "";
if (ipLine) {
ip = ipLine.split("=")[1];
}
return ip;
return parseTrace(data).ip ?? "";
} catch (error) {
console.warn("Error fetching IP from IPCheck.ing IPv6 Trace:", error);
throw error;
+2 -7
View File
@@ -1,5 +1,6 @@
import { isValidIP } from '@/utils/valid-ip.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseTrace } from '@/utils/parse-trace.js';
// Get IPv6/4 address from IPCheck.ing
const getIPFromIPChecking64 = async (originalSite) => {
@@ -48,13 +49,7 @@ const getFromTrace = async () => {
try {
const response = await fetchWithTimeout("https://64.ipcheck.ing/cdn-cgi/trace");
const data = await response.text();
const lines = data.split("\n");
const ipLine = lines.find((line) => line.startsWith("ip="));
let ip = "";
if (ipLine) {
ip = ipLine.split("=")[1];
}
return ip;
return parseTrace(data).ip ?? "";
} catch (error) {
console.warn("Error fetching IP from IPCheck.ing IPv6/4 Trace:", error);
throw error;
+24
View File
@@ -0,0 +1,24 @@
// Shared parser for Cloudflare /cdn-cgi/trace responses (key=value lines).
//
// Every trace consumer — the getips/ IP sources, SpeedTest, RuleTest — goes
// through this instead of hand-splitting lines. Values are trimmed because a
// body that crossed a rewriting middlebox (TLS-decrypting proxy, corporate
// gateway) can arrive with CRLF line endings, and a trailing "\r" on the ip
// value would fail IP validation downstream.
// Parse a trace body into a { key: value } map. Non-string input yields {}.
// Each line splits on its first "=" only (values may themselves contain "=");
// keys and values are trimmed; lines without a key are skipped. Missing keys
// simply stay absent, so callers read them as undefined.
export const parseTrace = (text) => {
const fields = {};
if (typeof text !== 'string') return fields;
for (const line of text.split('\n')) {
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!key) continue;
fields[key] = line.slice(eq + 1).trim();
}
return fields;
};
+40
View File
@@ -29,6 +29,7 @@ import serviceStatusHandler, {
detailHandler as serviceStatusDetailHandler,
} from '../api/service-status.js';
import createReportHandler, { getReport as getReportHandler, normalizeTtlDays } from '../api/share-report.js';
import { modifyJsonForIPAPI } from '../api/ipapi-is.js';
import { REPORT_VERSION } from '../common/report-schema.js';
// -- shared test utilities ------------------------------------------------
@@ -338,6 +339,45 @@ describe('ipcheck-ing handler', () => {
});
});
// -- ipapi-is normalizer --------------------------------------------------
// Pure transform, no fetch involved.
describe('ipapi-is normalize', () => {
it('maps a fully populated upstream payload', () => {
const out = modifyJsonForIPAPI({
ip: '8.8.8.8',
location: {
city: 'Mountain View', state: 'California', country: 'United States',
country_code: 'US', latitude: 37.4, longitude: -122.07,
},
asn: { asn: 15169, org: 'Google LLC' },
is_datacenter: true,
});
assert.equal(out.city, 'Mountain View');
assert.equal(out.country_code, 'US');
assert.equal(out.asn, 'AS15169');
assert.equal(out.isHosting, true);
assert.equal(out.isProxy, false);
});
it('falls back to N/A when the upstream places no location', () => {
// Anycast / bogon addresses come back 200 with location: null.
const out = modifyJsonForIPAPI({ ip: '104.28.212.153', location: null });
assert.equal(out.ip, '104.28.212.153');
for (const key of ['city', 'region', 'country', 'country_name', 'country_code', 'latitude', 'longitude']) {
assert.equal(out[key], 'N/A', `${key} should degrade to N/A`);
}
assert.equal(out.asn, 'N/A');
assert.equal(out.isHosting, false);
assert.equal(out.isProxy, false);
});
it('flags a proxy when any of the vpn / tor / proxy bits is set', () => {
assert.equal(modifyJsonForIPAPI({ location: {}, is_vpn: true }).isProxy, true);
assert.equal(modifyJsonForIPAPI({ location: {}, is_tor: true }).isProxy, true);
});
});
// -- service-status handlers ----------------------------------------------
describe('service-status overview handler', () => {
+10 -7
View File
@@ -39,10 +39,8 @@ function makeRefs() {
const calls = {
queryOpen: 0,
helpOpen: 0,
additionalCurl: 0,
footerAbout: 0,
shareOpen: 0,
speedTest: 0,
navPrefs: 0,
advancedNavigate: [],
advancedFullScreen: 0,
ipRefresh: [],
@@ -59,12 +57,9 @@ function makeRefs() {
return {
refs: {
navBarRef: ref({ OpenPreferences: () => { calls.navPrefs += 1; } }),
preferencesRef: ref({}),
queryIPRef: ref({ openModal: () => { calls.queryOpen += 1; } }),
helpModalRef: ref({ openModal: () => { calls.helpOpen += 1; }, keyMap: null }),
additionalRef: ref({ openCurlModal: () => { calls.additionalCurl += 1; } }),
footerRef: ref({ openAbout: () => { calls.footerAbout += 1; } }),
shareReportRef: ref({ openDialog: () => { calls.shareOpen += 1; } }),
speedTestRef: ref({ speedTestController: () => { calls.speedTest += 1; } }),
advancedToolsRef,
IPCheckRef: ref({
@@ -171,6 +166,14 @@ describe('useShortcuts()', () => {
assert.equal(emitted, 1);
});
it('"e" opens the share report dialog', () => {
const { keyMap, calls } = loadAndGetKeyMap();
const entry = keyMap.findLast((e) => e.keys === 'e');
assert.ok(entry, '"e" key should be registered');
entry.action();
assert.equal(calls.shareOpen, 1);
});
it('"h" toggles info mask only when infos loaded', () => {
const { keyMap, calls } = loadAndGetKeyMap();
const entry = keyMap.findLast((e) => e.keys === 'h');
+58
View File
@@ -5,6 +5,8 @@ import {
IP_DATABASES,
createInitialIpDBs,
buildDbUrl,
applyConfigAvailability,
nearestEnabledId,
} from '../frontend/data/ip-databases.js';
describe('IP_DATABASES', () => {
@@ -59,3 +61,59 @@ describe('buildDbUrl()', () => {
assert.equal(buildDbUrl({}, '1.1.1.1', 'en'), null);
});
});
describe('applyConfigAvailability()', () => {
// Shape of a real /api/configs payload (booleans only).
const configs = { ipChecking: true, ipInfo: false, ipapiis: false, ip2location: true };
it('follows the configKey flag for keyed sources', () => {
const out = applyConfigAvailability(createInitialIpDBs(), configs);
const byId = Object.fromEntries(out.map((db) => [db.id, db.enabled]));
assert.equal(byId[0], true); // ipChecking: true
assert.equal(byId[1], false); // ipInfo: false
assert.equal(byId[3], false); // ipapiis: false
assert.equal(byId[4], true); // ip2location: true
});
it('keeps key-free sources always enabled', () => {
const allOff = applyConfigAvailability(createInitialIpDBs(),
{ ipChecking: false, ipInfo: false, ipapiis: false, ip2location: false });
for (const id of [2, 5, 6]) {
assert.equal(allOff.find((db) => db.id === id).enabled, true, `id ${id} must stay enabled`);
}
});
it('returns fresh entries without mutating the input', () => {
const input = createInitialIpDBs();
const out = applyConfigAvailability(input, configs);
assert.notEqual(out, input);
assert.notEqual(out[1], input[1]);
assert.equal(input[1].enabled, true, 'input must be untouched');
});
});
describe('nearestEnabledId()', () => {
const dbs = (enabledIds) =>
createInitialIpDBs().map((db) => ({ ...db, enabled: enabledIds.includes(db.id) }));
it('keeps the preference when it is still available', () => {
assert.equal(nearestEnabledId(3, dbs([0, 3, 6])), 3);
});
it('walks forward to the next available source', () => {
assert.equal(nearestEnabledId(1, dbs([0, 4, 5])), 4);
});
it('wraps around past the end of the list', () => {
assert.equal(nearestEnabledId(6, dbs([1])), 1);
});
it('starts from the head for unknown ids', () => {
assert.equal(nearestEnabledId(42, dbs([2, 5])), 2);
});
it('leaves the preference unchanged when nothing is enabled', () => {
assert.equal(nearestEnabledId(3, dbs([])), 3);
assert.equal(nearestEnabledId(0, []), 0);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Tests for frontend/utils/parse-trace.js — the shared /cdn-cgi/trace parser
// used by the getips/ sources, SpeedTest and RuleTest.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { parseTrace } from '../frontend/utils/parse-trace.js';
// Shape of a real trace response (LF-joined below where a test needs it).
const TRACE_LINES = [
'fl=633f90',
'h=speed.cloudflare.com',
'ip=203.0.113.7',
'ts=1785340826.000',
'colo=SIN',
'loc=SG',
];
describe('parseTrace', () => {
it('parses a plain LF trace body into a key/value map', () => {
const out = parseTrace(TRACE_LINES.join('\n'));
assert.equal(out.ip, '203.0.113.7');
assert.equal(out.colo, 'SIN');
assert.equal(out.loc, 'SG');
});
it('tolerates CRLF line endings from rewriting middleboxes', () => {
// A TLS-decrypting proxy can re-serialize the body with \r\n; the
// trailing \r must not survive into the value (it would fail
// isValidIP downstream).
const out = parseTrace(TRACE_LINES.join('\r\n'));
assert.equal(out.ip, '203.0.113.7');
assert.equal(out.loc, 'SG');
});
it('splits on the first = only, keeping later ones in the value', () => {
const out = parseTrace('uag=Mozilla/5.0 (a=b; c=d)\nkex=X25519');
assert.equal(out.uag, 'Mozilla/5.0 (a=b; c=d)');
assert.equal(out.kex, 'X25519');
});
it('keeps an empty value distinct from an absent key', () => {
const out = parseTrace('ip=\ncolo=SIN');
assert.equal(out.ip, '');
assert.equal(out.loc, undefined);
});
it('skips blank lines and lines without a key', () => {
const out = parseTrace('\nip=1.2.3.4\n\n=orphan\nnokey\n');
assert.deepEqual(out, { ip: '1.2.3.4' });
});
it('returns an empty map for non-string input', () => {
assert.deepEqual(parseTrace(undefined), {});
assert.deepEqual(parseTrace(null), {});
assert.deepEqual(parseTrace(42), {});
});
});
+26 -4
View File
@@ -1,11 +1,12 @@
// Coverage for the IP half of common/rdap.js — bootstrap endpoint
// selection (longest-prefix CIDR match) and the RDAP-JSON → WHOIS-like
// `__raw` text formatter. Network calls (rdapIp itself) are out of scope.
// selection (longest-prefix CIDR match), the RDAP-JSON → WHOIS-like
// `__raw` text formatter, and rdapIp's query-URL shape (fetch stubbed;
// real network stays out of scope).
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { describe, it, mock } from 'node:test';
import { findIpEndpoint, formatIpNetwork } from '../common/rdap.js';
import { findIpEndpoint, formatIpNetwork, rdapIp } from '../common/rdap.js';
// Shaped like IANA's ipv4.json / ipv6.json `services` arrays:
// [[cidr, …], [url, …]] per registry.
@@ -127,3 +128,24 @@ describe('formatIpNetwork', () => {
assert.equal(formatIpNetwork({}).startsWith('NetRange: N/A - N/A'), true);
});
});
describe('rdapIp — query URL shape', () => {
it('keeps IPv6 colons literal in the path (some RIR delegates 400 on %3A)', async () => {
const jsonResponse = (body) => ({ ok: true, status: 200, json: async () => body });
const fetchMock = mock.method(globalThis, 'fetch', async (url) => {
if (String(url).startsWith('https://data.iana.org/rdap/')) {
return jsonResponse({ services: [[['2400::/12'], ['https://rdap.apnic.net/']]] });
}
return jsonResponse({ startAddress: '2404::', endAddress: '2404:ffff::' });
});
try {
await rdapIp('2404:c0:2520::1');
const queried = fetchMock.mock.calls
.map((c) => String(c.arguments[0]))
.find((u) => u.includes('/ip/'));
assert.equal(queried, 'https://rdap.apnic.net/ip/2404:c0:2520::1');
} finally {
fetchMock.mock.restore();
}
});
});
+2 -15
View File
@@ -95,20 +95,6 @@ describe('store — dark mode side effect', () => {
});
describe('store — IPDBs / allIPs', () => {
it('updateIPDBs flips the enabled flag of a matching entry', () => {
const s = useMainStore();
const firstId = s.ipDBs[0].id;
assert.equal(s.ipDBs[0].enabled, true);
s.updateIPDBs({ id: firstId, enabled: false });
assert.equal(s.ipDBs[0].enabled, false);
});
it('updateIPDBs ignores unknown ids', () => {
const s = useMainStore();
const snapshot = s.ipDBs.map((d) => d.enabled);
s.updateIPDBs({ id: 9999, enabled: false });
assert.deepEqual(s.ipDBs.map((d) => d.enabled), snapshot);
});
it('updateAllIPs dedupes by ip and back-fills a missing country', () => {
const s = useMainStore();
@@ -224,7 +210,8 @@ describe('store — getters', () => {
it('activeSources returns only enabled databases', () => {
const s = useMainStore();
const firstId = s.ipDBs[0].id;
s.updateIPDBs({ id: firstId, enabled: false });
// Availability is config-derived state; flip it the way fetchConfigs does.
s.ipDBs[0].enabled = false;
const active = s.activeSources;
assert.ok(active.every((db) => db.enabled));
assert.ok(!active.find((db) => db.id === firstId));