mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
40 files changed
+516
-314
No files matched your search
+5
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -88,9 +88,6 @@ const openCurlModal = () => {
|
||||
trackEvent('Additional', 'AdditionalClick', 'Curl');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openCurlModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -455,8 +455,6 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
clearTimeout(openToolTimer);
|
||||
});
|
||||
|
||||
defineExpose({ OpenPreferences });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 = '';
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '?',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user