From a062bbcec25b02a285f8570700d4e9ff88a11d39 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Fri, 14 Aug 2026 02:10:14 +0800 Subject: [PATCH 01/68] Feat(quota): monthly quotas for advanced features, benefits & usage dialog Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + api/invisibility-test.js | 10 + frontend/components/Nav.vue | 7 - frontend/components/StandaloneTool.vue | 6 + frontend/components/User.vue | 178 +++++++++++++++--- .../advanced-tools/EnhancedDnsLeakTest.vue | 44 ++++- .../advanced-tools/InvisibilityTest.vue | 42 ++++- .../components/ip-infos/IpDetailPanel.vue | 43 ++++- frontend/data/achievement-rules.js | 2 +- frontend/locales/en.json | 54 ++++-- frontend/locales/fr.json | 54 ++++-- frontend/locales/ru.json | 54 ++++-- frontend/locales/zh.json | 52 +++-- frontend/store.js | 26 ++- frontend/utils/report-builders.js | 4 +- frontend/utils/transform-ip-data.js | 24 ++- tests/transform-ip-data.test.js | 16 ++ 17 files changed, 516 insertions(+), 101 deletions(-) diff --git a/.gitignore b/.gitignore index a98fcdd8a..b081e5437 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ common/as-org-db/*.next common/as-rel-db/*.next .learnings/ docs/ +.plan/ # Local Scripts scripts/ diff --git a/api/invisibility-test.js b/api/invisibility-test.js index 3d251b424..08b74026b 100644 --- a/api/invisibility-test.js +++ b/api/invisibility-test.js @@ -42,6 +42,16 @@ export default async (req, res) => { return res.json({ status: 'pending' }); } + // Upstream 429 = monthly quota exhausted. Pass status + code through + // so the frontend can point at the sponsor path; not an error for us. + if (apiResponse.status === 429) { + const errorData = await apiResponse.json().catch(() => ({})); + return res.status(429).json({ + error: errorData.error || 'Monthly quota exceeded', + code: 'quota_exceeded' + }); + } + // Upstream 401/403. Pass the status through so the frontend prompts // sign-in instead of retrying; keep it off the error logger. if (apiResponse.status === 401 || apiResponse.status === 403) { diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index c064479c7..0e6c479a1 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -145,13 +145,6 @@
{{ t('user.Fields.CreatedAt') }}
{{ userCreatedAt }}
-
-
{{ t('user.Fields.FunctionUses') }}
-
- {{ remoteUserInfo.functionUses?.total ?? 0 }} - {{ t('user.Fields.Fetching') }} -
-
diff --git a/frontend/components/StandaloneTool.vue b/frontend/components/StandaloneTool.vue index fddb76761..ee64ff045 100644 --- a/frontend/components/StandaloneTool.vue +++ b/frontend/components/StandaloneTool.vue @@ -4,6 +4,11 @@ component the drawer does, just inside a minimal page chrome instead of the homepage + drawer. -->
+ + + @@ -30,6 +35,7 @@ import { TOOL_BY_SLUG } from '@/data/tools.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; import Footer from '@/components/Footer.vue'; import StandalonePageHeader from '@/components/StandalonePageHeader.vue'; +import User from '@/components/User.vue'; const { t } = useI18n(); const route = useRoute(); diff --git a/frontend/components/User.vue b/frontend/components/User.vue index 724e1c2ec..900db43e5 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -1,43 +1,130 @@ diff --git a/frontend/components/report/ReportPage.vue b/frontend/components/report/ReportPage.vue index 3f6d1c5ce..9cb38088e 100644 --- a/frontend/components/report/ReportPage.vue +++ b/frontend/components/report/ReportPage.vue @@ -67,6 +67,7 @@ import { useRoute, RouterLink } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { useMainStore } from '@/store'; import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; +import { isoToDateTime } from '@/utils/time-utils.js'; import { REPORT_VERSION, REPORT_SECTION_IDS } from '@/utils/report-schema.js'; import { reportToMarkdown } from '@/utils/report-export.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; @@ -102,7 +103,7 @@ const SECTION_COMPONENTS = { enhanceddnsleak: ReportEnhanceddnsleak, }; -const { t } = useI18n(); +const { t, locale } = useI18n(); const route = useRoute(); const store = useMainStore(); @@ -115,12 +116,9 @@ const presentSectionIds = computed(() => REPORT_SECTION_IDS.filter((id) => report.value?.sections?.[id])); const generatedAtDisplay = computed(() => - report.value ? new Date(report.value.generatedAt).toLocaleString() : ''); + report.value ? isoToDateTime(report.value.generatedAt, locale.value) : ''); -const expiresAtDisplay = computed(() => { - const stamp = Date.parse(expiresAt.value); - return Number.isNaN(stamp) ? '' : new Date(stamp).toLocaleString(); -}); +const expiresAtDisplay = computed(() => isoToDateTime(expiresAt.value, locale.value)); const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1000; const isStale = computed(() => diff --git a/frontend/components/report/ShareReportDialog.vue b/frontend/components/report/ShareReportDialog.vue index 9d1c67b15..c9d78f76d 100644 --- a/frontend/components/report/ShareReportDialog.vue +++ b/frontend/components/report/ShareReportDialog.vue @@ -126,6 +126,7 @@ import { useCollectedReport } from '@/composables/use-report-collector.js'; import { useReportShare } from '@/composables/use-report-share.js'; import { REPORT_SECTION_IDS, REPORT_TTL_DAYS } from '@/utils/report-schema.js'; import { SECTION_TITLE_KEYS, buildShareReport, reportToMarkdown, downloadReportJson } from '@/utils/report-export.js'; +import { isoToDateTime } from '@/utils/time-utils.js'; import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; @@ -138,7 +139,7 @@ import { Spinner } from '@/components/ui/spinner'; import CopyButton from '@/components/widgets/CopyButton.vue'; import { Share2, Link2, Bot, FileJson, ChevronRight } from '@lucide/vue'; -const { t } = useI18n(); +const { t, locale } = useI18n(); const store = useMainStore(); const { sections, availableSectionIds } = useCollectedReport(); const { creating, shareLink, expiresAt, shareError, createShareLink, resetShareLink } = useReportShare(); @@ -207,8 +208,7 @@ const onDownloadJson = () => { downloadReportJson(assembleReport()); }; -const expiresAtDisplay = computed(() => - expiresAt.value ? new Date(expiresAt.value).toLocaleString() : ''); +const expiresAtDisplay = computed(() => isoToDateTime(expiresAt.value, locale.value)); // For the `e` keyboard shortcut (use-shortcuts.js). defineExpose({ openDialog }); diff --git a/frontend/components/report/sections/ReportSectionCard.vue b/frontend/components/report/sections/ReportSectionCard.vue index cd4ffe16b..d042ec14b 100644 --- a/frontend/components/report/sections/ReportSectionCard.vue +++ b/frontend/components/report/sections/ReportSectionCard.vue @@ -12,20 +12,18 @@ diff --git a/frontend/components/widgets/IPHistory.vue b/frontend/components/widgets/IPHistory.vue index e72f12dce..ec25cec01 100644 --- a/frontend/components/widgets/IPHistory.vue +++ b/frontend/components/widgets/IPHistory.vue @@ -129,6 +129,7 @@ import { useIpHistory } from '@/composables/use-ip-history.js'; import { createMaskGate } from '@/composables/use-info-mask.js'; import { INLINE_TIERS } from '@/composables/use-fit-text.js'; import { filterHistoryDays, countryFacets, ipVersionCounts } from '@/utils/ip-history.js'; +import { formatIsoDate } from '@/utils/time-utils.js'; import getCountryName from '@/data/country-name.js'; import FitText from '@/components/widgets/FitText.vue'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; @@ -199,14 +200,9 @@ const countryName = (code) => { return getCountryName(code, lang.value) || code; }; -// Localized day header, e.g. "Jul 8, 2026" / "2026年7月8日". -const formatDay = (dayKey) => { - const [y, m, d] = dayKey.split('-').map(Number); - const locale = lang.value === 'zh' ? 'zh-CN' : lang.value; - return new Date(y, m - 1, d).toLocaleDateString(locale, { - year: 'numeric', month: 'short', day: 'numeric', - }); -}; +// Localized day header, e.g. "Jul 8, 2026" / "2026年7月8日" — the shared +// helper takes the storage bucket's "YYYY-MM-DD" key directly. +const formatDay = (dayKey) => formatIsoDate(dayKey, lang.value); // Clear-all is destructive: first click arms, second click within the armed // window actually clears. Disarms when the panel closes. diff --git a/frontend/utils/report-export.js b/frontend/utils/report-export.js index a56ba241d..fd2f9b87a 100644 --- a/frontend/utils/report-export.js +++ b/frontend/utils/report-export.js @@ -184,6 +184,9 @@ export const reportToMarkdown = (report, t, { masked = false } = {}) => { const lines = [ `# ${t('report.ai.Heading')}`, '', + // generatedAt stays a raw ISO instant even inside localized prose: + // the reader is an AI, and ISO is unambiguous and timezone-explicit + // where a localized date+time would drop the zone. t('report.ai.Intro', { origin: report.origin, time: report.generatedAt }), ]; if (masked) lines.push('', t('report.ai.Masked')); diff --git a/frontend/utils/time-utils.js b/frontend/utils/time-utils.js index a572d9f7b..5bdfbdbd0 100644 --- a/frontend/utils/time-utils.js +++ b/frontend/utils/time-utils.js @@ -151,19 +151,43 @@ export const formatDuration = (ms, locale) => { /* Absolute dates */ /* ------------------------------------------------------------------ */ -// Localized numeric date ("1/1/2024" / "2024/1/1") from a Unix millisecond -// timestamp, in the browser's own locale and zone. -export const unixToDateTime = (timestamp) => { - const date = new Date(Number(timestamp)); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - }); +// Localized absolute date ("Jan 1, 2024" / "2024年1月1日") from a Unix +// millisecond timestamp (number or numeric string), rendered in the viewer's +// own zone. `locale` is the app UI language; omitted → browser locale. '' for +// an unusable timestamp. +export const unixToDateTime = (timestamp, locale) => { + // Number(null) / Number('') coerce to 0 — treat "no data" as unusable, not epoch. + if (timestamp == null || timestamp === '') return ''; + const ms = Number(timestamp); + if (!Number.isFinite(ms)) return ''; + try { + return new Intl.DateTimeFormat(locale || undefined, { dateStyle: 'medium' }) + .format(new Date(ms)); + } catch { + return ''; + } +}; + +// Localized date + time ("Aug 15, 2026, 3:04 PM" / "2026年8月15日 15:04") from +// an ISO 8601 instant (anything Date.parse accepts), rendered in the viewer's +// own zone — the shared report's generated / expiry / per-section stamps. +// '' for a missing or unparseable input. +export const isoToDateTime = (iso, locale) => { + const ms = Date.parse(iso ?? ''); + if (!Number.isFinite(ms)) return ''; + try { + return new Intl.DateTimeFormat(locale || undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(ms); + } catch { + return ''; + } }; // Localized absolute date ("Nov 6, 2020" / "2020年11月6日") from an ISO -// "YYYY-MM-DD" string, used by the changelog. Formatted in UTC so the calendar +// "YYYY-MM-DD" string — the changelog and every date-only ISO surface +// (OONI windows, IP-history day headers). Formatted in UTC so the calendar // date never shifts a day for west-of-UTC viewers; non-ISO strings (the // changelog's "Beta" placeholder) pass through untouched. export const formatIsoDate = (isoDate, locale) => { diff --git a/tests/time-utils.test.js b/tests/time-utils.test.js index e2861f830..1744d5fbc 100644 --- a/tests/time-utils.test.js +++ b/tests/time-utils.test.js @@ -16,6 +16,7 @@ import { relativeTimeSince, formatDuration, unixToDateTime, + isoToDateTime, formatIsoDate, } from '../frontend/utils/time-utils.js'; @@ -206,7 +207,7 @@ describe('formatDuration', () => { /* Absolute dates */ /* ------------------------------------------------------------------ */ -// The timestamp → local date string output depends on runtime locale + TZ, so +// The timestamp → local date string output depends on the host TZ, so // assertions stay flexible to avoid false failures across CI environments. describe('unixToDateTime', () => { it('accepts a numeric timestamp and returns a non-empty string', () => { @@ -226,11 +227,49 @@ describe('unixToDateTime', () => { assert.ok(/2023|2024/.test(epochYearZero), `expected 2023 or 2024 in output, got "${epochYearZero}"`); }); + it('renders in the app locale when one is passed', () => { + assert.match(unixToDateTime(1704067200000, 'zh'), /年/); + assert.notEqual( + unixToDateTime(1704067200000, 'zh'), + unixToDateTime(1704067200000, 'en'), + ); + }); + it('different timestamps produce different strings', () => { const a = unixToDateTime(1704067200000); // 2024 const b = unixToDateTime(1767225600000); // 2026 UTC assert.notEqual(a, b); }); + + it("returns '' for an unusable timestamp", () => { + assert.equal(unixToDateTime(undefined), ''); + assert.equal(unixToDateTime('not-a-number'), ''); + assert.equal(unixToDateTime(null), ''); // Number(null) is 0 — but null means "no data" + }); +}); + +// Host-TZ-dependent output → structural assertions only (same policy as +// unixToDateTime above). +describe('isoToDateTime', () => { + it('renders an ISO instant as a localized date + time', () => { + const out = isoToDateTime('2026-08-15T09:23:41.412Z', 'en'); + assert.match(out, /2026/); + assert.match(out, /\d:\d{2}/); // carries a time, unlike the date-only helpers + }); + + it('follows the app locale', () => { + assert.match(isoToDateTime('2026-08-15T09:23:41Z', 'zh'), /年/); + assert.notEqual( + isoToDateTime('2026-08-15T09:23:41Z', 'zh'), + isoToDateTime('2026-08-15T09:23:41Z', 'en'), + ); + }); + + it("returns '' for a missing or unparseable input", () => { + assert.equal(isoToDateTime('', 'en'), ''); + assert.equal(isoToDateTime(undefined, 'en'), ''); + assert.equal(isoToDateTime('not-a-date', 'en'), ''); + }); }); describe('formatIsoDate', () => { From aab3cc56a0f04d9f00342fe03330226f026246b8 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 15 Aug 2026 01:59:58 +0800 Subject: [PATCH 03/68] Chore(changelog): stamp v7.3.0 release date, start v7.4.0 entry Co-Authored-By: Claude Fable 5 --- frontend/data/changelog.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 37a55cf5b..a53e27771 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1594,5 +1594,29 @@ } } ] + }, + { + "version": "v7.4.0", + "date": "Beta", + "content": [ + { + "type": "improve", + "change": { + "en": "Optimized date display in multiple places", + "zh": "优化多处日期格式显示", + "fr": "Optimisation de l'affichage des dates dans plusieurs endroits", + "ru": "Оптимизация отображения дат в нескольких местах" + } + }, + { + "type": "fix", + "change": { + "en": "Fixed some issues", + "zh": "修复了一些问题", + "fr": "Correction de petits problèmes", + "ru": "Исправлены некоторые проблемы" + } + } + ] } ] From 7d009fb29f56fd9576e570727f13ea8287e3b595 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 15 Aug 2026 01:59:58 +0800 Subject: [PATCH 04/68] Chore(docs): document the dates & times canonical pattern All user-visible stamps go through utils/time-utils.js with the app locale; exceptions must carry a comment. Also drop the hardcoded primitives count from the ui/ description so it can't go stale. Co-Authored-By: Claude Fable 5 --- frontend/AGENTS.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 133eac2f2..911c91577 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -118,7 +118,7 @@ upload at build, gated on `SENTRY_AUTH_TOKEN`. ## UI system -**shadcn-vue first.** Check `components/ui/` (21 copied-in primitives), then +**shadcn-vue first.** Check `components/ui/` (copied-in primitives), then https://www.shadcn-vue.com/docs/components for something to copy in; hand-rolled Tailwind only when neither fits. Two local notes: `Spinner` is project-specific (lucide `Loader2` + `role="status"`); `toggle` / @@ -166,6 +166,16 @@ Copy from the named exemplar instead of re-inventing: transition (Connectivity / WebRTC / IPCard). `jn-card` = shadow / border / keyboard outline; `keyboard-shortcut-card` = J/K navigation target. - **Flag** — always ``. +- **Dates & times** — every user-visible stamp renders through + `utils/time-utils.js` with the vue-i18n locale: `formatIsoDate` (date-only + ISO — changelog, OONI windows, IP-history day headers), `isoToDateTime` + (ISO instants — report generated / expiry / per-section stamps), + `unixToDateTime` (epoch ms — account & achievement dates), + `relativeTimeFromMinutes` / `relativeTimeSince` / `formatDuration` (Pulse). + No hand-rolled `toLocaleDateString` / `Intl.DateTimeFormat` in components; + a deliberate exception carries a comment saying why (ASNHistory's + fixed-width ISO columns, report-export's AI-facing ISO intro, + ServiceStatus's seconds-bearing refresh clock). - **Fit-to-width tokens** — IP / MAC strings render inside `` (`HERO_TIERS` hero rows, `INLINE_TIERS` compact rows; `:max-lines="2"` on heroes). Never per-component length-threshold helpers (IPCard, QueryIP). From aa4524da409017908af2d29d3a91454fe9209dfc Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 15 Aug 2026 11:07:07 +0800 Subject: [PATCH 05/68] Feat(quota): surface unique-IP metering for ipinfo in copy and store docs The backend now meters the ipinfo quota by distinct target IPs per month instead of request count. Quota-exhausted copy and the usage dialog say so in all four locales, and the store's quotaExceeded getter documents why its ipinfo flag must stay display-only. Co-Authored-By: Claude Fable 5 --- frontend/locales/en.json | 6 +++--- frontend/locales/fr.json | 6 +++--- frontend/locales/ru.json | 6 +++--- frontend/locales/zh.json | 6 +++--- frontend/store.js | 8 ++++++++ 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 5abd721b7..9976f0304 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -12,8 +12,8 @@ "ViewUsage": "View usage", "Usage": { "Title": "Usage Stats", - "Note": "Advanced features come with a free monthly quota, which resets automatically at the start of each month (UTC).", - "IpinfoFeature": "IP Advanced Data", + "Note": "Advanced features come with a free monthly quota, which resets automatically at the start of each month (UTC). IP advanced data counts unique IPs only — looking up an IP you've already queried this month never consumes quota.", + "IpinfoFeature": "IP Advanced Data (unique IPs)", "SponsorNote": "Sponsoring on GitHub raises your monthly quotas." }, "MyAchievements": "My Achievements", @@ -824,7 +824,7 @@ "qualityScore": "IP Quality", "qualityScoreUnknown": "Score Unknown", "advancedUnlockCta": "To avoid abuse, sign in to view the following information", - "advancedQuotaCta": "Monthly quota used up", + "advancedQuotaCta": "Monthly quota for new IPs used up", "ASNInfo": { "trafficPercentage": "Traffic Percentage: ", "note": "Data for this AS : ", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 621655acf..f3aa4cc41 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -12,8 +12,8 @@ "ViewUsage": "Voir l'utilisation", "Usage": { "Title": "Statistiques d'utilisation", - "Note": "Les fonctionnalités avancées disposent d'un quota mensuel gratuit, réinitialisé automatiquement au début de chaque mois (UTC).", - "IpinfoFeature": "Données IP avancées", + "Note": "Les fonctionnalités avancées disposent d'un quota mensuel gratuit, réinitialisé automatiquement au début de chaque mois (UTC). Les données IP avancées ne comptent que les IP uniques — interroger à nouveau une IP déjà consultée ce mois-ci ne consomme pas de quota.", + "IpinfoFeature": "Données IP avancées (IP uniques)", "SponsorNote": "Sponsoriser le projet sur GitHub augmente vos quotas mensuels." }, "MyAchievements": "Succès", @@ -824,7 +824,7 @@ "qualityScore": "Qualité IP", "qualityScoreUnknown": "Score inconnu", "advancedUnlockCta": "Pour éviter l'abus, connectez-vous pour voir les informations suivantes", - "advancedQuotaCta": "Quota mensuel épuisé", + "advancedQuotaCta": "Quota mensuel de nouvelles IP épuisé", "ASNInfo": { "trafficPercentage": "Pourcentage de trafic : ", "note": "Données associées à cet AS : ", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 01dc1c3c1..5e00b5932 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -12,8 +12,8 @@ "ViewUsage": "Посмотреть использование", "Usage": { "Title": "Статистика использования", - "Note": "У расширенных функций есть бесплатная месячная квота, которая автоматически обновляется в начале каждого месяца (UTC).", - "IpinfoFeature": "Расширенные данные IP", + "Note": "У расширенных функций есть бесплатная месячная квота, которая автоматически обновляется в начале каждого месяца (UTC). Для расширенных данных IP учитываются только уникальные IP — повторный запрос IP, уже проверенного в этом месяце, не расходует квоту.", + "IpinfoFeature": "Расширенные данные IP (уникальные IP)", "SponsorNote": "Спонсорство проекта на GitHub увеличивает месячные квоты." }, "MyAchievements": "Достижения", @@ -824,7 +824,7 @@ "qualityScore": "Качество IP", "qualityScoreUnknown": "Оценка неизвестна", "advancedUnlockCta": "Чтобы предотвратить злоупотребления, войдите для просмотра следующих сведений", - "advancedQuotaCta": "Месячная квота исчерпана", + "advancedQuotaCta": "Месячная квота на новые IP исчерпана", "ASNInfo": { "trafficPercentage": "Доля трафика: ", "note": "Данные для этой AS: ", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index e346df3ec..4e52de78f 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -12,8 +12,8 @@ "ViewUsage": "查看用量", "Usage": { "Title": "用量统计", - "Note": "高级功能设有每月免费额度,每月(UTC)自动重置。", - "IpinfoFeature": "IP 高级数据", + "Note": "高级功能设有每月免费额度,每月(UTC)自动重置。IP 高级数据仅按独立 IP 计数——本月已查询过的 IP 再次查询不消耗额度。", + "IpinfoFeature": "IP 高级数据(按独立 IP 计)", "SponsorNote": "在 GitHub 赞助本项目可获得更高额度。" }, "MyAchievements": "我的成就", @@ -824,7 +824,7 @@ "qualityScore": "IP 质量分", "qualityScoreUnknown": "未知分数", "advancedUnlockCta": "为避免滥用,登录后可查看以下信息", - "advancedQuotaCta": "本月高级数据额度已用完", + "advancedQuotaCta": "本月新 IP 查询额度已用完", "ASNInfo": { "trafficPercentage": "流量比例:", "note": "此 AS 的相关数据:", diff --git a/frontend/store.js b/frontend/store.js index 0d4f1ec13..99bdac53e 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -72,6 +72,14 @@ export const useMainStore = defineStore('main', { // /api/getuserinfo quota snapshot in remoteUserInfo. Frontend first line // only — the backend enforces the same limits authoritatively; absent // data (signed out, old backend, fetch pending) reads as not exceeded. + // + // Metering differs per feature: invisibility_test / dns_leak_test count + // requests, so exhausted means every further run is blocked and their + // components use this as a pre-flight gate. ipinfo counts UNIQUE target + // IPs per month — exhausted only means "no NEW IPs"; already-queried IPs + // still pass, only the backend can tell which is which. Never use the + // ipinfo flag to preemptively block a lookup (no component does today); + // it is display-only. quotaExceeded: (state) => { const features = state.remoteUserInfo?.quota?.features || {}; const exceeded = (key) => { From cc8e9a0a52db9ec5a05f02be3b96e256f903878a Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 15 Aug 2026 20:39:53 +0800 Subject: [PATCH 06/68] Improvements --- frontend/components/Nav.vue | 18 ++++++- frontend/locales/en.json | 7 ++- frontend/locales/fr.json | 7 ++- frontend/locales/ru.json | 7 ++- frontend/locales/zh.json | 7 ++- frontend/store.js | 98 ++++++++++++++++++++++++++++--------- tests/store.test.js | 83 +++++++++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 34 deletions(-) diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index f8d117ca7..7a576fa33 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -103,8 +103,8 @@ - @@ -145,6 +145,18 @@
{{ t('user.Fields.CreatedAt') }}
{{ userCreatedAt }}
+ +
+
{{ t('user.Fields.SignInMethods') }}
+
+ + + {{ provider.label }} + +
+
@@ -341,6 +353,8 @@ const userPhotoURL = computed(() => store.user?.photoURL); const userCreatedAt = computed(() => unixToDateTime(store.user?.metadata.createdAt, locale.value)); const remoteUserInfo = computed(() => store.remoteUserInfo); const remoteUserInfoFetched = computed(() => store.remoteUserInfoFetched); +// Sign-in methods attached to this account. +const linkedProviders = computed(() => store.linkedProviders); // Level Badge Color: mapped to semantic token, keep each level color distinction const levelBadgeClass = computed(() => { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 9976f0304..88047d832 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -22,7 +22,8 @@ "CreatedAt": "Created At", "LastLogin": "Last Login", "Level": "User Level", - "Fetching": "Fetching..." + "Fetching": "Fetching...", + "SignInMethods": "Sign-in" }, "Level": { "Standard": "Standard User", @@ -1105,7 +1106,9 @@ "SignInFailed": "Sign In Failed", "SignInFailedReason": "Reason", "IpGeoSourceFallbackTitle": "IP Geolocation Source Switched", - "IpGeoSourceFallbackMessage": "{from} is temporarily unavailable — this result comes from {to} for now." + "IpGeoSourceFallbackMessage": "{from} is temporarily unavailable — this result comes from {to} for now.", + "SignInEmailTakenTitle": "This email is already in use", + "SignInEmailTakenMessage": "An account already exists for this email address. Please sign in with {other} instead." }, "shortcutKeys": { "GoToTop": "Go to Top", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index f3aa4cc41..1669198c4 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -22,7 +22,8 @@ "CreatedAt": "Créé Le", "LastLogin": "Dernière Connexion", "Level": "Niveau d'Utilisateur", - "Fetching": "Recherche..." + "Fetching": "Recherche...", + "SignInMethods": "Connexion" }, "Level": { "Standard": "Utilisateur Standard", @@ -1105,7 +1106,9 @@ "SignInFailed": "Echec de connexion", "SignInFailedReason": "Raison", "IpGeoSourceFallbackTitle": "Source de géolocalisation IP changée", - "IpGeoSourceFallbackMessage": "{from} est momentanément indisponible — ce résultat provient temporairement de {to}." + "IpGeoSourceFallbackMessage": "{from} est momentanément indisponible — ce résultat provient temporairement de {to}.", + "SignInEmailTakenTitle": "Cette adresse e-mail est déjà utilisée", + "SignInEmailTakenMessage": "Un compte existe déjà pour cette adresse e-mail. Veuillez vous connecter avec {other}." }, "shortcutKeys": { "GoToTop": "Aller en haut", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 5e00b5932..1607688f4 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -22,7 +22,8 @@ "CreatedAt": "Дата создания", "LastLogin": "Последний вход", "Level": "Уровень пользователя", - "Fetching": "Загрузка..." + "Fetching": "Загрузка...", + "SignInMethods": "Вход" }, "Level": { "Standard": "Обычный пользователь", @@ -1105,7 +1106,9 @@ "SignInFailed": "Не удалось войти", "SignInFailedReason": "Причина", "IpGeoSourceFallbackTitle": "Источник IP-геоданных переключён", - "IpGeoSourceFallbackMessage": "{from} временно недоступен — этот результат временно получен из {to}." + "IpGeoSourceFallbackMessage": "{from} временно недоступен — этот результат временно получен из {to}.", + "SignInEmailTakenTitle": "Этот адрес уже используется", + "SignInEmailTakenMessage": "Аккаунт с этим адресом электронной почты уже существует. Войдите через {other}." }, "shortcutKeys": { "GoToTop": "Перейти наверх", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 4e52de78f..73cdc913d 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -22,7 +22,8 @@ "CreatedAt": "创建于", "LastLogin": "最后登录", "Level": "用户等级", - "Fetching": "获取中..." + "Fetching": "获取中...", + "SignInMethods": "登录方式" }, "Level": { "Standard": "标准用户", @@ -1105,7 +1106,9 @@ "SignInFailed": "登录失败", "SignInFailedReason": "原因", "IpGeoSourceFallbackTitle": "IP 解析数据源已切换", - "IpGeoSourceFallbackMessage": "解析源 {from} 暂时不可用,本次结果临时改由 {to} 提供。" + "IpGeoSourceFallbackMessage": "解析源 {from} 暂时不可用,本次结果临时改由 {to} 提供。", + "SignInEmailTakenTitle": "该邮箱已被使用", + "SignInEmailTakenMessage": "该邮箱已经注册过账号,请改用 {other} 登录。" }, "shortcutKeys": { "GoToTop": "回到顶部", diff --git a/frontend/store.js b/frontend/store.js index 99bdac53e..680390518 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -10,6 +10,33 @@ import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './da import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; const { t } = i18n.global; +// The two sign-in buttons. `other` is the provider to point a visitor at when +// their email already owns an account through the other one; +const SIGN_IN_PROVIDERS = { + google: { + label: 'Google', + other: 'GitHub', + providerId: 'google.com', + icon: 'ri:google-line', + build: ({ GoogleAuthProvider }) => { + const provider = new GoogleAuthProvider(); + provider.addScope('email'); + return provider; + }, + }, + github: { + label: 'GitHub', + other: 'Google', + providerId: 'github.com', + icon: 'ri:github-line', + build: ({ GithubAuthProvider }) => { + const provider = new GithubAuthProvider(); + provider.addScope('user:email'); + return provider; + }, + }, +}; + export const useMainStore = defineStore('main', { state: () => ({ @@ -68,6 +95,19 @@ export const useMainStore = defineStore('main', { curlDomainsHadSet: (state) => { return state.curl.ipv4Domain && state.curl.ipv6Domain && state.curl.ipv64Domain; }, + // How this account signs in, for display in the user menu. A provider the + // app no longer offers still shows, under its raw id rather than hidden. + linkedProviders: (state) => { + const known = Object.values(SIGN_IN_PROVIDERS); + return (state.user?.providerData || []).map((entry) => { + const descriptor = known.find((item) => item.providerId === entry.providerId); + return { + providerId: entry.providerId, + label: descriptor?.label || entry.providerId, + icon: descriptor?.icon || null, + }; + }); + }, // Per-feature "monthly quota exhausted" booleans, derived from the // /api/getuserinfo quota snapshot in remoteUserInfo. Frontend first line // only — the backend enforces the same limits authoritatively; absent @@ -75,11 +115,7 @@ export const useMainStore = defineStore('main', { // // Metering differs per feature: invisibility_test / dns_leak_test count // requests, so exhausted means every further run is blocked and their - // components use this as a pre-flight gate. ipinfo counts UNIQUE target - // IPs per month — exhausted only means "no NEW IPs"; already-queried IPs - // still pass, only the backend can tell which is which. Never use the - // ipinfo flag to preemptively block a lookup (no component does today); - // it is display-only. + // components use this as a pre-flight gate. quotaExceeded: (state) => { const features = state.remoteUserInfo?.quota?.features || {}; const exceeded = (key) => { @@ -221,34 +257,48 @@ export const useMainStore = defineStore('main', { }, // sign in with Google async signInWithGoogle() { - try { - const { auth, GoogleAuthProvider, signInWithPopup } = await loadFirebaseAuth(); - const provider = new GoogleAuthProvider(); - provider.addScope('email'); - const result = await signInWithPopup(auth, provider); - this.user = result.user; - writeAuthHint(true); - // refresh browser after successful login - window.location.reload(); - } catch (error) { - this.setAlert(true, "text-danger", t('alert.SignInFailedReason') + ' : ' + error, t('alert.SignInFailed')); - console.error("Google sign-in failed:", error); - } + await this.signInWithProvider('google'); }, // sign in with GitHub async signInWithGithub() { + await this.signInWithProvider('github'); + }, + // Shared sign-in path for both buttons. + async signInWithProvider(providerKey) { + const descriptor = SIGN_IN_PROVIDERS[providerKey]; try { - const { auth, GithubAuthProvider, signInWithPopup } = await loadFirebaseAuth(); - const provider = new GithubAuthProvider(); - provider.addScope('user:email'); - const result = await signInWithPopup(auth, provider); + const fb = await loadFirebaseAuth(); + const result = await fb.signInWithPopup(fb.auth, descriptor.build(fb)); this.user = result.user; writeAuthHint(true); // refresh browser after successful login window.location.reload(); } catch (error) { - this.setAlert(true, "text-danger", t('alert.SignInFailedReason') + ' : ' + error, t('alert.SignInFailed')); - console.error("GitHub sign-in failed:", error); + this.handleSignInError(error, descriptor); + } + }, + // Turns Firebase auth error codes into something a visitor can act on. + handleSignInError(error, descriptor) { + console.error(`${descriptor.label} sign-in failed:`, error); + + switch (error?.code) { + // Closing the popup is normal, not a failure worth a red toast. + case 'auth/popup-closed-by-user': + case 'auth/cancelled-popup-request': + case 'auth/user-cancelled': + return; + case 'auth/account-exists-with-different-credential': + case 'auth/email-already-in-use': + case 'auth/credential-already-in-use': + this.setAlert(true, 'text-warning', + t('alert.SignInEmailTakenMessage', { other: descriptor.other }), + t('alert.SignInEmailTakenTitle'), 8000); + return; + + default: + this.setAlert(true, 'text-danger', + t('alert.SignInFailedReason') + ' : ' + error, + t('alert.SignInFailed')); } }, // sign out diff --git a/tests/store.test.js b/tests/store.test.js index ae4220729..9faf04011 100644 --- a/tests/store.test.js +++ b/tests/store.test.js @@ -238,3 +238,86 @@ describe('store — getDbUrl delegates to buildDbUrl', () => { assert.ok(!result, `expected falsy result for unknown id, got ${result}`); }); }); + +describe('store — linkedProviders', () => { + const signedInWith = (store, ...providerIds) => { + store.isSignedIn = true; + store.user = { providerData: providerIds.map((providerId) => ({ providerId })) }; + }; + + it('is empty while signed out', () => { + const s = useMainStore(); + s.user = null; + assert.deepEqual(s.linkedProviders, []); + }); + + it('names each provider and the icon the menu renders', () => { + const s = useMainStore(); + signedInWith(s, 'google.com'); + assert.deepEqual(s.linkedProviders, [ + { providerId: 'google.com', label: 'Google', icon: 'ri:google-line' }, + ]); + }); + + it('keeps Firebase order when several are present', () => { + const s = useMainStore(); + signedInWith(s, 'github.com', 'google.com'); + assert.deepEqual(s.linkedProviders.map((entry) => entry.label), ['GitHub', 'Google']); + }); + + it('falls back to the raw id for a provider the app does not offer', () => { + const s = useMainStore(); + signedInWith(s, 'password'); + // Shown rather than hidden: an account signing in some other way should + // not read as having no sign-in method at all. + assert.deepEqual(s.linkedProviders, [ + { providerId: 'password', label: 'password', icon: null }, + ]); + }); +}); + +describe('store — handleSignInError', () => { + const google = { label: 'Google', other: 'GitHub' }; + const github = { label: 'GitHub', other: 'Google' }; + // i18n has no messages loaded here, so t() returns the key itself — which + // makes these tests about WHICH message each code picks. + const fail = (store, code, descriptor) => { + store.setAlert(false, '', '', '', 0); + store.handleSignInError({ code }, descriptor); + return store.alert; + }; + + it('stays silent when the visitor just closes the popup', () => { + const s = useMainStore(); + for (const code of ['auth/popup-closed-by-user', 'auth/cancelled-popup-request', 'auth/user-cancelled']) { + assert.equal(fail(s, code, github).alertToShow, false, code); + } + }); + + it('warns and points at the other provider when the email is taken', () => { + const s = useMainStore(); + for (const code of [ + 'auth/account-exists-with-different-credential', + 'auth/email-already-in-use', + 'auth/credential-already-in-use', + ]) { + const alert = fail(s, code, github); + assert.equal(alert.alertMessage, 'alert.SignInEmailTakenMessage', code); + assert.equal(alert.alertStyle, 'text-warning', code); + } + }); + + it('still surfaces unexpected failures as errors', () => { + const s = useMainStore(); + const unknown = fail(s, 'auth/network-request-failed', google); + assert.equal(unknown.alertStyle, 'text-danger'); + assert.ok(unknown.alertToShow); + }); + + it('survives an error object with no code at all', () => { + const s = useMainStore(); + s.setAlert(false, '', '', '', 0); + s.handleSignInError(new Error('boom'), google); + assert.equal(s.alert.alertStyle, 'text-danger'); + }); +}); From 26ab6ace3bcda497217958bc4280012218648727 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 15 Aug 2026 20:50:54 +0800 Subject: [PATCH 07/68] Improvements --- frontend/components/ip-infos/IpDetailPanel.vue | 11 ++++++++++- frontend/components/widgets/QueryIP.vue | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 41f05a4f9..71c88fca9 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -86,7 +86,7 @@ users) — the sponsor path lives there, not a direct jump. --> @@ -315,6 +315,15 @@ const props = defineProps({ enableMap: { type: Boolean, default: false }, }); +// Consumers rendering this panel inside a dialog listen to close themselves +// first — the Benefits & Usage dialog would otherwise stack on top of them. +const emit = defineEmits(['view-usage']); + +const openUsageDialog = () => { + emit('view-usage'); + store.setTriggerUserBenefits(true); +}; + // Single-select panel content for the ASN block: 'info' | 'history' | null. // Kept separate from the open state so close animations retain their content // until Collapsible finishes measuring and animating the closing height. diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index 633029ac1..f2e1d7142 100644 --- a/frontend/components/widgets/QueryIP.vue +++ b/frontend/components/widgets/QueryIP.vue @@ -45,7 +45,8 @@ + :configs="configs" :is-dark-mode="isDarkMode" :enable-map="false" + @view-usage="onOpenChange(false)" /> @@ -57,6 +58,7 @@ // Differences from IPCard: // - No Copy button (the IP was typed by the user — copying it is pointless). // - No Map button (Dialog-in-Dialog stacking is avoided; enableMap=false). +// - The panel's "view usage" link closes this dialog first, for the same reason. // - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard). import { ref, computed, watch, nextTick } from 'vue'; import { useMainStore } from '@/store'; From 5f7dfe552fe01d600e66c7a89e39ee41d6ee4e52 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sun, 16 Aug 2026 15:29:00 +0800 Subject: [PATCH 08/68] Improvements --- frontend/App.vue | 7 ++-- frontend/components/widgets/PWA.vue | 6 +++ frontend/locales/en.json | 4 ++ frontend/locales/fr.json | 4 ++ frontend/locales/ru.json | 4 ++ frontend/locales/zh.json | 4 ++ frontend/utils/pwa.js | 32 ++++++++++++--- tests/pwa.test.js | 60 +++++++++++++++++++++++++---- 8 files changed, 105 insertions(+), 16 deletions(-) diff --git a/frontend/App.vue b/frontend/App.vue index 4b81ea3af..38bc14768 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -20,9 +20,10 @@ import { shouldOfferPwaInstall } from '@/utils/pwa.js'; import { sendVisitBeacon } from '@/utils/pulse-beacon.js'; import { useTheme } from '@/composables/use-theme.js'; -// PWA install prompt — async and eligibility-gated: ineligible visits (first -// visit, prompt cap reached, already installed) never load pwa-install or -// trigger its manifest fetch; eligible ones load it at the prompt's 30s mark. +// PWA install prompt — async and eligibility-gated: ineligible visits (too +// few 12h-deduped uses, prompt cap reached, already installed) never load +// pwa-install or trigger its manifest fetch; eligible ones load it at the +// prompt's 30s mark. const PWA = defineAsyncComponent(() => import('@/components/widgets/PWA.vue')); const offerPwaInstall = ref(false); onMounted(() => { diff --git a/frontend/components/widgets/PWA.vue b/frontend/components/widgets/PWA.vue index 8617b91ec..56a222e39 100644 --- a/frontend/components/widgets/PWA.vue +++ b/frontend/components/widgets/PWA.vue @@ -5,10 +5,14 @@ diff --git a/frontend/components/User.vue b/frontend/components/User.vue index 900db43e5..d50823f42 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -165,6 +165,7 @@ const USAGE_LABEL_KEYS = { ipinfo: 'user.Usage.IpinfoFeature', invisibility_test: 'invisibilitytest.Title', dns_leak_test: 'enhanceddnsleaktest.Title', + persona_check: 'personacheck.Title', }; // Rows for the usage dialog. Empty until the (re)fetched user info lands or diff --git a/frontend/components/advanced-tools/PersonaCheck.vue b/frontend/components/advanced-tools/PersonaCheck.vue new file mode 100644 index 000000000..7abc6d8ff --- /dev/null +++ b/frontend/components/advanced-tools/PersonaCheck.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/frontend/components/advanced-tools/PersonaReport.vue b/frontend/components/advanced-tools/PersonaReport.vue new file mode 100644 index 000000000..e963f0c4c --- /dev/null +++ b/frontend/components/advanced-tools/PersonaReport.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/composables/use-persona-collector.js b/frontend/composables/use-persona-collector.js new file mode 100644 index 000000000..654bed0ff --- /dev/null +++ b/frontend/composables/use-persona-collector.js @@ -0,0 +1,128 @@ +// Persona collector — keeps the latest snapshot of every homepage test the +// Persona Check reads, normalized into the observation the API consumes. +// Same pattern as the report collector: components emit domain events, this +// subscribes; nothing is ever re-run here — the homepage tests keep their +// single owner. Call useAppPersonaCollector() once from App.vue setup. + +import { reactive, computed, onScopeDispose } from 'vue'; +import { onAppEvent } from '../utils/app-events.js'; +import { isValidIP, isIPv6 } from '../utils/valid-ip.js'; +import { observeBrowser } from '../utils/persona/observe-browser.js'; +import { probeFonts } from '../utils/persona/probe-fonts.js'; +import { probeVoices, probeKeyboard } from '../utils/persona/probe-locale.js'; +import { probeTrace } from '../utils/persona/probe-server.js'; + +// Module-level so the tool sees whatever App.vue's subscriber collected while +// the visitor was on the homepage. +const snapshots = reactive({ + ipinfo: null, + webrtc: null, + dnsleak: null, +}); + +// --- normalizers: event payload → observation slice -------------------------- + +const normalizeIpinfo = (payload) => { + const cards = (payload?.cards ?? []) + .filter((card) => isValidIP(card?.ip)) + .map((card) => ({ + source: card.source || '', + ip: card.ip, + countryCode: (card.country_code || '').toUpperCase() || undefined, + timezone: card.timezone || undefined, + asn: card.asn || undefined, + isp: card.isp || undefined, + ipType: card.ipTypeCode || undefined, + isProxy: card.proxyCode || undefined, + version: isIPv6(card.ip) ? 6 : 4, + })); + return cards.length ? { cards } : null; +}; + +const normalizeWebrtc = (payload) => { + const servers = (payload?.servers ?? []) + .filter((server) => isValidIP(server?.ip)) + .map((server) => ({ + ip: server.ip, + natType: server.natTypeCode || undefined, + countryCode: (server.country_code || '').toUpperCase() || undefined, + org: server.org || undefined, + })); + return servers.length ? { servers } : null; +}; + +const normalizeDnsleak = (payload) => { + const providers = (payload?.providers ?? []) + .filter((provider) => isValidIP(provider?.ip)) + .map((provider) => ({ + name: provider.name || '', + ip: provider.ip, + countryCode: (provider.country_code || '').toUpperCase() || undefined, + org: provider.org || undefined, + })); + return providers.length ? { providers } : null; +}; + +const NORMALIZERS = { + 'ipinfo:finished': { key: 'ipinfo', normalize: normalizeIpinfo }, + 'webrtc:finished': { key: 'webrtc', normalize: normalizeWebrtc }, + 'dnsleak:finished': { key: 'dnsleak', normalize: normalizeDnsleak }, +}; + +// Which observation slices each source feeds, for the "what's missing" list. +export const PERSONA_SOURCES = ['ipinfo', 'webrtc', 'dnsleak']; + +/** Subscribe once, app-wide. */ +export const useAppPersonaCollector = () => { + const unsubscribes = Object.entries(NORMALIZERS).map(([event, { key, normalize }]) => + onAppEvent(event, (payload) => { + const normalized = normalize(payload); + // Latest-wins, but a run that produced nothing usable must not + // erase a good earlier snapshot. + if (normalized !== null) snapshots[key] = normalized; + })); + + onScopeDispose(() => unsubscribes.forEach((unsubscribe) => unsubscribe())); +}; + +/** + * Assemble the observation to score: the collected snapshots plus the active + * probes, run concurrently. GPS and the card prefix are opt-in and passed in + * by the caller — neither happens without an explicit visitor action. + */ +export const buildObservation = async ({ geolocation, cardBin } = {}) => { + const [trace, fonts, voices, keyboard] = await Promise.all([ + probeTrace(), + probeFonts(), + probeVoices(), + probeKeyboard(), + ]); + const browser = observeBrowser(); + return { + ip: snapshots.ipinfo || undefined, + webrtc: snapshots.webrtc || undefined, + dns: snapshots.dnsleak || undefined, + browser, + intl: browser.intl || undefined, + trace: trace.available ? trace : undefined, + fonts, + voices: voices || undefined, + keyboard: keyboard || undefined, + geolocation: geolocation || undefined, + card: cardBin ? { bin: cardBin } : undefined, + }; +}; + +/** Read-only view for the tool: raw snapshots plus what hasn't been run yet. */ +export const usePersonaSnapshots = () => ({ + snapshots, + missingSources: computed(() => PERSONA_SOURCES.filter((source) => !snapshots[source])), + hasAnySource: computed(() => PERSONA_SOURCES.some((source) => snapshots[source])), +}); + +/** Drop every snapshot — module state is shared, so tests need this. */ +export const resetPersonaSnapshots = () => { + for (const key of PERSONA_SOURCES) snapshots[key] = null; +}; + +export { normalizeIpinfo, normalizeWebrtc, normalizeDnsleak }; diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index a53e27771..219329cee 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1599,6 +1599,15 @@ "version": "v7.4.0", "date": "Beta", "content": [ + { + "type": "add", + "change": { + "en": "New tool: Persona Check — see how much of what sites read agrees with the country you want to be taken for", + "zh": "新增工具:身份画像检测——看看网站能读到的信息,有多少和你想被当成的国家对得上", + "fr": "Nouvel outil : Vérification de persona — voyez dans quelle mesure ce que lisent les sites correspond au pays pour lequel vous voulez passer", + "ru": "Новый инструмент: проверка цифрового портрета — насколько то, что видят сайты, совпадает со страной, за жителя которой вы хотите себя выдавать" + } + }, { "type": "improve", "change": { diff --git a/frontend/data/persona-tables.js b/frontend/data/persona-tables.js new file mode 100644 index 000000000..8d6de4c56 --- /dev/null +++ b/frontend/data/persona-tables.js @@ -0,0 +1,68 @@ +// The two reference tables the tool needs before any request is made: which +// languages a country's locals plausibly run their machine in (the picker), +// and which fonts mark a writing system (the font probe). Everything else +// about a country comes from Intl at runtime (utils/persona/local-profile.js). +// This file is the single owner of both tables — an edit here is the whole +// edit. + +// --------------------------------------------------------------------------- +// Additional languages a country's residents plausibly run their OS in, +// beyond the one Intl.Locale#maximize() reports. Order is significance; an +// entry may safely repeat the primary (deduped at merge). Not a census — an +// entry earns its place only when the language realistically appears in +// `navigator.languages` on a local machine. +// --------------------------------------------------------------------------- +export const EXTRA_LANGUAGES = { + AE: ['ar', 'en'], AF: ['fa', 'ps'], AM: ['hy', 'ru'], AT: ['de'], + AZ: ['az', 'ru'], BA: ['bs', 'hr', 'sr'], BE: ['nl', 'fr', 'de'], + BN: ['ms', 'en'], BO: ['es', 'qu', 'ay'], BY: ['be', 'ru'], CA: ['en', 'fr'], + CH: ['de', 'fr', 'it'], CM: ['fr', 'en'], CY: ['el', 'tr'], DJ: ['fr', 'ar'], + DZ: ['ar', 'fr'], EE: ['et', 'ru'], ER: ['ti', 'ar', 'en'], + ES: ['es', 'ca', 'eu', 'gl'], ET: ['am', 'om'], FI: ['fi', 'sv'], + FJ: ['en', 'fj'], GE: ['ka', 'ru'], HK: ['zh', 'en'], IE: ['en', 'ga'], + IL: ['he', 'ar', 'ru'], IN: ['hi', 'en'], IQ: ['ar', 'ku'], KE: ['sw', 'en'], + KG: ['ky', 'ru'], KZ: ['kk', 'ru'], LB: ['ar', 'fr'], + LK: ['si', 'ta', 'en'], LU: ['lb', 'fr', 'de'], LV: ['lv', 'ru'], + MA: ['ar', 'fr'], MD: ['ro', 'ru'], MG: ['mg', 'fr'], MK: ['mk', 'sq'], + ML: ['fr', 'bm'], MO: ['zh', 'pt'], MT: ['mt', 'en'], MU: ['en', 'fr'], + MY: ['ms', 'en', 'zh'], NG: ['en', 'ha', 'yo', 'ig'], NO: ['nb', 'nn'], + NZ: ['en', 'mi'], PE: ['es', 'qu'], PH: ['fil', 'en'], PK: ['ur', 'en'], + PY: ['es', 'gn'], RW: ['rw', 'fr', 'en'], SC: ['fr', 'en'], + SG: ['en', 'zh', 'ms', 'ta'], SN: ['fr', 'wo'], SO: ['so', 'ar'], + TJ: ['tg', 'ru'], TL: ['pt', 'tet'], TN: ['ar', 'fr'], TZ: ['sw', 'en'], + UA: ['uk', 'ru'], US: ['en', 'es'], UZ: ['uz', 'ru'], + VU: ['bi', 'en', 'fr'], ZA: ['en', 'af', 'zu', 'xh'], +}; + +// --------------------------------------------------------------------------- +// Writing system → marker fonts, keyed by ISO 15924 script code. Latin and +// Cyrillic are absent (every OS ships them). Fonts that every install of an +// OS carries regardless of language are excluded wherever a language-gated +// alternative exists — they would match for everyone — and kept only for +// scripts that have no such alternative, where dropping them would penalize +// genuine locals instead. +// --------------------------------------------------------------------------- +export const FONTS_BY_SCRIPT = { + Jpan: ['Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Mincho', 'Yu Mincho', 'Noto Sans JP', 'Noto Sans CJK JP', 'Source Han Sans JP'], + Hans: ['PingFang SC', 'Songti SC', 'DengXian', 'SimHei', 'KaiTi', 'Noto Sans SC', 'Noto Sans CJK SC', 'Source Han Sans SC', 'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei'], + Hant: ['PingFang TC', 'Heiti TC', 'PMingLiU', 'MingLiU', 'DFKai-SB', 'Noto Sans TC', 'Noto Sans CJK TC', 'Source Han Sans TC'], + Kore: ['Apple SD Gothic Neo', 'Gulim', 'Batang', 'Dotum', 'Noto Sans KR', 'Noto Sans CJK KR', 'Source Han Sans K', 'NanumGothic'], + Arab: ['Geeza Pro', 'Al Bayan', 'Traditional Arabic', 'Arabic Typesetting', 'Sakkal Majalla', 'Noto Sans Arabic'], + Hebr: ['Arial Hebrew', 'David', 'Gisha', 'Noto Sans Hebrew'], + Thai: ['Thonburi', 'Angsana New', 'Cordia New', 'Browallia New', 'Noto Sans Thai'], + Deva: ['Kohinoor Devanagari', 'Mangal', 'Aparajita', 'Noto Sans Devanagari'], + Beng: ['Bangla Sangam MN', 'Vrinda', 'Shonar Bangla', 'Noto Sans Bengali'], + Taml: ['Tamil Sangam MN', 'Latha', 'Vijaya', 'Noto Sans Tamil'], + Telu: ['Telugu Sangam MN', 'Gautami', 'Vani', 'Noto Sans Telugu'], + Knda: ['Kannada Sangam MN', 'Tunga', 'Noto Sans Kannada'], + Mlym: ['Malayalam Sangam MN', 'Kartika', 'Noto Sans Malayalam'], + Guru: ['Gurmukhi MN', 'Raavi', 'Noto Sans Gurmukhi'], + Gujr: ['Gujarati Sangam MN', 'Shruti', 'Noto Sans Gujarati'], + Sinh: ['Sinhala Sangam MN', 'Iskoola Pota', 'Noto Sans Sinhala'], + Mymr: ['Myanmar Sangam MN', 'Myanmar Text', 'Noto Sans Myanmar'], + Khmr: ['Khmer Sangam MN', 'Khmer UI', 'DaunPenh', 'Noto Sans Khmer'], + Laoo: ['Lao Sangam MN', 'Lao UI', 'DokChampa', 'Noto Sans Lao'], + Ethi: ['Kefa', 'Nyala', 'Noto Sans Ethiopic'], + Geor: ['Sylfaen', 'Noto Sans Georgian'], + Armn: ['Mshtakan', 'Sylfaen', 'Noto Sans Armenian'], +}; diff --git a/frontend/data/tools.js b/frontend/data/tools.js index 84da6b942..6b4bf928e 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -32,6 +32,9 @@ export const ADVANCED_TOOLS = [ { slug: 'servicestatus', emoji: '📡', titleKey: 'serviceStatus.Title', noteKey: 'advancedtools.ServiceStatus', component: () => import('@/components/advanced-tools/ServiceStatus.vue') }, { slug: 'invisibilitytest', emoji: '🫣', titleKey: 'invisibilitytest.Title', noteKey: 'advancedtools.InvisibilityTest', component: () => import('@/components/advanced-tools/InvisibilityTest.vue'), requiresOriginalSite: true }, { slug: 'enhanceddnsleaktest', emoji: '🌀', titleKey: 'enhanceddnsleaktest.Title', noteKey: 'advancedtools.EnhancedDnsLeakTest', component: () => import('@/components/advanced-tools/EnhancedDnsLeakTest.vue'), requiresOriginalSite: true }, + // noStandalone: the check requires the homepage tests' results, and running + // them navigates home — a /tools/ page for it would immediately bounce away. + { slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true, noStandalone: true }, ]; // Fast slug → entry lookup (drawer + standalone page resolve a tool by slug). diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e39f6a6aa..c962ff81e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -580,6 +580,270 @@ "negative": "No evidence of you using a proxy or VPN was found in high latency analysis." } }, + "personacheck": { + "Title": "Persona Check", + "Note": "There is an old test: if it looks like a duck, swims like a duck and quacks like a duck, it probably is a duck. Sites read you the same way — your IP may claim you are in country A, but your time zone, languages, installed fonts, keyboard and a dozen other signals each tell their own story, and together they are the persona a site actually sees. This tool measures that persona, compares it against the local resident you want to be read as, and shows exactly where the two diverge.", + "NoteVs": "It asks a different question than the Invisibility Test. That one checks whether your proxy itself can be detected; this one checks who sites think they are looking at — and how far that picture is from the one you expect to present.", + "Note2": "Start by choosing the country you want to be read as a local of.", + "signInFirst": "Sign in first to use this tool — head to the home page, sign in, then come back; every run spends one unit of your monthly allowance.", + "axis": { + "match": "Match with the target", + "coherence": "Internal consistency", + "leak": "Identity leak" + }, + "zone": { + "expected": "Expected identity", + "optional": "Sharper signals (optional)", + "run": "Run the check" + }, + "selectCountry": "Search for a country", + "noCountryMatch": "No matching country", + "noProfile": "This territory has no local profile to compare against.", + "languageSingle": "This country has a single primary language.", + "languageMulti": "This country has several languages — pick the one you want to present.", + "timezoneMulti": "This country spans several time zones — pick the one you want to present.", + "hourCycle": { + "h11": "12-hour clock (0–11)", + "h12": "12-hour clock", + "h23": "24-hour clock", + "h24": "24-hour clock (1–24)" + }, + "profile": { + "timezone": "Time zone" + }, + "runCompare": "Run the check", + "runError": "The check could not complete.", + "source": { + "ipinfo": "IP lookup", + "webrtc": "WebRTC", + "dnsleak": "DNS leak" + }, + "report": { + "grade": { + "A": "🎉 You look every bit like a local of {country}", + "B": "😊 You look somewhat like a local of {country}", + "C": "🤔 You don't quite look like a local of {country}", + "D": "😅 You don't look like a local of {country}", + "unknown": "🤷 Not enough signal to grade" + }, + "gradeNote": { + "A": "Every signal we can read agrees with the persona you chose.", + "B": "The persona mostly holds, but a few signals point elsewhere.", + "C": "Several signals disagree with the persona, or with each other — the results below show which.", + "D": "Most signals point somewhere other than this persona.", + "unknown": "Run the underlying tests first — there is too little to go on." + }, + "counts": { + "leak": "{n} exposing", + "warning": "{n} inconsistent", + "match": "{n} passing", + "unknown": "{n} unmeasured", + "notApplicable": "{n} not applicable" + }, + "nothingActionable": "Nothing to fix — every measurable signal fits the persona.", + "howToFix": "How to fix it", + "verdict": { + "match": "Fits the persona.", + "mismatch": "Contradicts the persona.", + "leak": "Exposes your real environment.", + "unnatural": "Internally inconsistent — two signals that should agree tell different stories, which draws more attention than a plain mismatch.", + "unknown": "Not measurable right now.", + "not-applicable": "No conclusion is possible here." + }, + "visibility": { + "public": "Any site sees this", + "probed": "Sites that probe for it", + "risk-engine": "Anti-fraud systems" + }, + "profileTitle": "Measurement details", + "state": { + "match": "Passed", + "mismatch": "Not local", + "unnatural": "Inconsistent", + "leak": "Exposed", + "unknown": "Not measured", + "not-applicable": "N/A" + } + }, + "detail": { + "expected": "Expected", + "actual": "Actual", + "disputed": "Sources disagree", + "v4": "IPv4", + "v6": "IPv6", + "candidateCount": "Candidates", + "matching": "Matches expected", + "ipType": "Address type", + "expectedOffset": "Expected offset", + "actualOffset": "Actual offset", + "sameOffset": "Same offset", + "primary": "Primary language", + "primaryExpected": "Expected primary", + "demoted": "Listed but not first", + "timeZone": "Time zone", + "reportedOffset": "Offset from Date", + "zoneOffset": "Offset from zone", + "samples": "Samples", + "expectedScripts": "Expected scripts", + "installedScripts": "Installed scripts", + "expectedLanguages": "Expected languages", + "voiceLanguages": "Voice languages", + "voiceCount": "Voices", + "expectedLayout": "Expected layout", + "actualLayout": "Actual layout", + "headerPrimary": "Header primary", + "scriptPrimary": "Script primary", + "headerLanguages": "Header languages", + "ipZone": "IP time zone", + "browserZone": "Browser time zone", + "ipOffset": "IP offset", + "browserOffset": "Browser offset", + "colo": "Cloudflare PoP", + "gpsTimezone": "Time zone at your position", + "accuracyMetres": "Accuracy (m)", + "cardIssuer": "Issuing bank", + "cardNetwork": "Card network", + "cardTier": "Card tier", + "cardType": "Card type" + }, + "checks": { + "ip-country": { + "title": "IP country", + "fix": "Part of your exits land outside the target country — with several proxy exits, different cards genuinely show different countries, and any one exit is what some site will see. The fractions above say how many exits agree. Route everything through nodes in the target country; if a single source disagrees about one IP, that node may just be mislabelled in its database, which is not fixable from your side.", + "what": "Which country the IP lookup cards on the home page place your exit address in — every source is read, and every exit has to sit in the target country." + }, + "webrtc-leak": { + "title": "WebRTC exposure", + "fix": "Block WebRTC, or use a setup where it can only ever see the proxy address. The cost is real: video calls and voice conferencing stop working. A browser extension that filters WebRTC is gentler than disabling it outright, but the extension itself leaves traces of its own.", + "what": "Whether WebRTC hands out an address from a different country than your persona." + }, + "dns-resolver-country": { + "title": "DNS resolver country", + "fix": "Use a resolver in the target country, or let the proxy handle DNS resolution. Note that a public resolver like 1.1.1.1 will not expose your location, but it will not support your persona either — it is equally neutral for every country.", + "what": "Which country the DNS resolvers answering for you sit in." + }, + "asn-type": { + "title": "Address type", + "fix": "A datacenter range is the single loudest \"this is a proxy\" signal an address carries. A residential exit fixes it outright, but costs considerably more and varies a lot in quality.", + "what": "Whether your address belongs to a residential ISP or a datacenter range." + }, + "timezone-vs-persona": { + "title": "Time zone vs persona", + "fix": "Change the time zone at the operating-system level — that way every browser API stays consistent with itself. Extensions that override the time zone often change only part of it, creating the contradictions flagged under the coherence checks, which is worse than not changing it at all.", + "what": "Whether your browser's time zone is the one a local would have." + }, + "language-match": { + "title": "Browser language", + "fix": "Change the language in your system or browser settings rather than rewriting a single HTTP header. The order matters too: sites read the whole list, and a local would rarely lead with a foreign language.", + "what": "Whether the language your browser leads with is one a local would use." + }, + "script-fonts": { + "title": "Writing-system fonts", + "fix": "A machine set up for this locale ships that writing system's fonts. Installing them by hand is possible but rarely worth it: it changes one signal while the rest of the system stays in its original language, which usually creates more contradictions than it resolves. If this needs to hold up, the system language itself is the thing to change.", + "what": "Whether fonts for this country's writing system are installed on the machine." + }, + "speech-voices": { + "title": "Speech synthesis voices", + "fix": "Text-to-speech packs follow the language your OS was installed with, and essentially no proxy or spoofing extension touches them. Adding a voice pack for the target language is a real fix, with the same caveat as fonts: one changed signal inside an otherwise unchanged system reads as deliberate.", + "what": "Which languages the text-to-speech packs on your system cover." + }, + "keyboard-layout": { + "title": "Keyboard layout", + "fix": "Your physical keyboard layout is not what is typical for the target country. Only Chromium browsers expose this, so it is a narrower signal than the others — but wherever it is readable, it is hard to fake without changing the actual keyboard.", + "what": "Which physical keyboard layout your machine reports." + }, + "edge-geo-consensus": { + "title": "Edge geolocation", + "fix": "Cloudflare's own reading of your exit address disagrees with the target country. When this and the IP lookup contradict each other, the address is more likely mislabelled in one database than actually moving — but every site behind Cloudflare sees this verdict, not the other one.", + "what": "Cloudflare's own reading of your exit address, formed independently of the databases above." + }, + "accept-language-header": { + "title": "Language header vs script", + "fix": "The Accept-Language header your browser sends on the wire does not match what navigator.languages reports to scripts. Almost nothing produces this except an extension that rewrote one and left the other. Change the language in your browser or system settings instead — that moves both at once.", + "what": "Whether the language header sent on the wire matches what scripts are told. Deliberately independent of the target country: a pass only says your two language channels agree — whether the language itself fits is the Browser language check's job. Half-applied language spoofs fail exactly here." + }, + "ip-timezone-vs-browser": { + "title": "IP time zone vs browser", + "fix": "The time zone of your exit IP and the one your browser reports are different places. This holds whichever country you are aiming for: a real machine sits somewhere, and both should say the same somewhere. Usually it means the proxy moved your address but not your system clock.", + "what": "Whether the time zone of your IP and the one your browser reports agree." + }, + "gps-location": { + "title": "Device location (GPS)", + "fix": "Your device reports being physically in a different country than the one you are aiming for. No proxy touches this: any site that asks for your location gets the real answer, and browser extensions that fake it typically leave the accuracy and timing patterns intact. The realistic options are to deny location permission for sites that ask, or to accept that this one will not hold up.", + "what": "Which country your device reports being physically in." + }, + "payment-region": { + "title": "Card issuer country", + "fix": "Your card was issued in a different country than the one you are presenting as. Merchants see the issuing country before almost anything else, and payment risk systems weigh it heavily against the address and IP. There is no browser-side fix for this — it is a property of the card itself.", + "what": "Which country issued the card behind the prefix you entered." + }, + "intl-number-format": { + "title": "Number format", + "what": "How your browser groups digits and marks decimals.", + "fix": "Your browser groups numbers the way its own region does, not the way the target country does. This is separate from the language: a machine can carry the right language tag while its region still says elsewhere. Change the region — not just the language — in your system settings: Region on Windows, Language & Region on macOS." + }, + "intl-date-format": { + "title": "Date format", + "what": "The order and separators your browser uses when writing a date.", + "fix": "Your browser writes dates in a different order or with different separators than a local would. Like the number format, this follows the system region rather than the language, and changing the region is what moves it." + }, + "intl-hour-cycle": { + "title": "Clock format", + "what": "Whether your browser presents a 12-hour or 24-hour clock — the convention sites see when they format times.", + "fix": "The clock format follows the browser's language, not the system's 24-hour toggle — most browsers ignore that switch entirely (Safari is the main exception). To present the target country's convention, switch the browser's language or region to a locale of that country." + }, + "timezone-authenticity": { + "title": "Time zone authenticity", + "what": "Whether the time zone your browser reports is the machine's real one.", + "fix": "The zone your browser reports is not the one the machine actually runs on: Intl and Date disagree, or past dates do not follow that zone's daylight-saving rules. This matters because it means the timezone check above passed on a value that is not real — a site reading the clock directly sees through it. Set the zone in your operating system rather than with an extension, and both the current offset and the historical rules move together." + } + }, + "reason": { + "no-marker-script": "No marker writing system", + "bin-lookup-failed": "The card lookup service could not be reached — the digits may well be fine; try again later.", + "bin-not-recognized": "The card database did not recognize this prefix — a typo is the most common cause; double-check the digits and run again.", + "font-probe-unavailable": "Canvas unavailable", + "no-voice-packs": "No voice packs installed", + "keyboard-api-unavailable": "Not exposed by this browser", + "no-layout-expectation": "No layout expectation for this country", + "no-persona-languages": "Persona has no language set", + "geolocation-unsupported": "Geolocation not supported" + }, + "optional": { + "gpsNote": "GPS position is one of the dimensions the check can measure — share it if you want it compared too.", + "gpsButton": "Share location", + "gpsGranted": "Location received.", + "gpsFailed": { + "denied": "Permission denied — this check will report as not measured.", + "timeout": "The location request timed out.", + "unavailable": "Your device could not determine a position.", + "unsupported": "This browser has no geolocation support." + }, + "cardNote": "Sometimes you also want to pay as a local. Enter your card's first 6-8 digits and the check tells you whether the card reads as issued in that country.", + "cardError": "6 to 8 digits needed — enter at least 6 to look up the issuer.", + "cardReady": "Prefix ready — the issuing country is looked up when the check runs." + }, + "dependencies": { + "note": "The check needs the results of all of these base tests — run any that are missing before starting:", + "runButton": "Run the missing tests", + "measured": "{n} of {total} measured", + "allReady": "All tests have run" + }, + "step": { + "country": "Target country", + "language": "Main language", + "location": "Location", + "card": "Card prefix", + "awaitingCountry": "Pick a country first", + "otherTests": "Base tests" + }, + "emptyHint": "Pick the country you want to look local to, then run the check.", + "readyHint": "Ready. Run the check to see how local you look.", + "value": { + "yes": "Yes", + "no": "No" + } + }, "advancedtools": { "OpenInNewTab": "Open in new tab", "BackToHome": "Back to Home", @@ -596,7 +860,8 @@ "InvisibilityTest": "Check if you are using a proxy or VPN", "MacChecker": "Query information of a physical address", "BrowserInfo": "Check browser information and fingerprint", - "SecurityChecklist": "Guide to securing your digital life" + "SecurityChecklist": "Guide to securing your digital life", + "PersonaCheck": "Compare what sites see against the country you want to look local to" }, "macchecker": { "Title": "MAC Lookup", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index e3b96558c..a0d2fd6a5 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -544,6 +544,270 @@ "negative": "Aucune preuve de votre utilisation d'un proxy ou d'un VPN n'a été trouvée lors de l'analyse de haute latence." } }, + "personacheck": { + "Title": "Vérification de persona", + "Note": "Un vieux test dit : si ça ressemble à un canard, nage comme un canard et cancane comme un canard, c'est probablement un canard. Les sites vous lisent de la même façon — votre IP peut prétendre que vous êtes dans le pays A, mais votre fuseau horaire, vos langues, vos polices installées, votre clavier et une dizaine d'autres signaux racontent chacun leur propre histoire, et c'est leur ensemble qui forme le persona qu'un site voit réellement. Cet outil mesure ce persona, le compare au résident local que vous voulez incarner et montre précisément où les deux divergent.", + "NoteVs": "Il répond à une autre question que le test d'invisibilité : celui-ci vérifie si votre proxy peut être détecté ; ici, il s'agit de savoir qui les sites croient voir — et à quelle distance ce portrait se trouve de celui que vous pensez présenter.", + "Note2": "Commencez par choisir le pays dont vous voulez avoir l'air d'être un habitant.", + "signInFirst": "Connectez-vous d'abord pour utiliser cet outil — passez par la page d'accueil, connectez-vous, puis revenez ; chaque exécution consomme une unité de votre quota mensuel.", + "axis": { + "match": "Concordance avec la cible", + "coherence": "Cohérence interne", + "leak": "Fuite d'identité" + }, + "zone": { + "expected": "Identité attendue", + "optional": "Signaux plus précis (facultatif)", + "run": "Lancer la vérification" + }, + "selectCountry": "Rechercher un pays", + "noCountryMatch": "Aucun pays correspondant", + "noProfile": "Ce territoire n'a aucun profil local auquel se comparer.", + "languageSingle": "Ce pays n'a qu'une seule langue principale.", + "languageMulti": "Ce pays a plusieurs langues — choisissez celle que vous voulez présenter.", + "timezoneMulti": "Ce pays s'étend sur plusieurs fuseaux horaires — choisissez celui que vous voulez présenter.", + "hourCycle": { + "h11": "cycle de 12 heures (0–11)", + "h12": "cycle de 12 heures", + "h23": "cycle de 24 heures", + "h24": "cycle de 24 heures (1–24)" + }, + "runCompare": "Lancer la vérification", + "runError": "La vérification n'a pas pu aboutir.", + "profile": { + "timezone": "Fuseau horaire" + }, + "source": { + "ipinfo": "Recherche IP", + "webrtc": "WebRTC", + "dnsleak": "Fuite DNS" + }, + "report": { + "grade": { + "A": "🎉 Vous avez tout d'un habitant du pays ({country})", + "B": "😊 Vous ressemblez un peu à un habitant du pays ({country})", + "C": "🤔 Vous ne ressemblez pas vraiment à un habitant du pays ({country})", + "D": "😅 Vous ne ressemblez pas à un habitant du pays ({country})", + "unknown": "🤷 Signal insuffisant pour noter" + }, + "gradeNote": { + "A": "Tous les signaux lisibles concordent avec le persona choisi.", + "B": "Le persona tient globalement, mais quelques signaux pointent ailleurs.", + "C": "Plusieurs signaux contredisent le persona, ou se contredisent entre eux — les résultats ci-dessous montrent lesquels.", + "D": "La plupart des signaux pointent ailleurs que vers ce persona.", + "unknown": "Lancez d'abord les tests sous-jacents — il y a trop peu d'éléments." + }, + "counts": { + "leak": "{n} exposition(s)", + "warning": "{n} incohérence(s)", + "match": "{n} conforme(s)", + "unknown": "{n} non mesurée(s)", + "notApplicable": "{n} non applicable(s)" + }, + "nothingActionable": "Rien à corriger — tous les signaux mesurables correspondent au persona.", + "howToFix": "Comment corriger", + "verdict": { + "match": "Conforme au persona.", + "mismatch": "Contredit le persona.", + "leak": "Expose votre environnement réel.", + "unnatural": "Incohérent en interne — deux signaux censés concorder racontent des histoires différentes, ce qui attire plus l'attention qu'un simple écart.", + "unknown": "Non mesurable pour le moment.", + "not-applicable": "Aucune conclusion possible ici." + }, + "visibility": { + "public": "N'importe quel site le voit", + "probed": "Les sites qui le sondent", + "risk-engine": "Systèmes anti-fraude" + }, + "profileTitle": "Détails des mesures", + "state": { + "match": "Conforme", + "mismatch": "Pas local", + "unnatural": "Incohérent", + "leak": "Exposé", + "unknown": "Non mesuré", + "not-applicable": "N/A" + } + }, + "reason": { + "no-marker-script": "Pas de système d'écriture distinctif", + "bin-lookup-failed": "Le service de recherche de cartes était injoignable — les chiffres sont peut-être corrects ; réessayez plus tard.", + "bin-not-recognized": "La base de cartes n'a pas reconnu ce préfixe — une faute de frappe en est la cause la plus fréquente ; vérifiez les chiffres et relancez.", + "font-probe-unavailable": "Canvas indisponible", + "no-voice-packs": "Aucun pack vocal installé", + "keyboard-api-unavailable": "Non exposé par ce navigateur", + "no-layout-expectation": "Aucune disposition attendue pour ce pays", + "no-persona-languages": "Aucune langue définie pour le persona", + "geolocation-unsupported": "Géolocalisation non prise en charge" + }, + "detail": { + "expected": "Attendu", + "actual": "Observé", + "disputed": "Sources en désaccord", + "v4": "IPv4", + "v6": "IPv6", + "candidateCount": "Candidats", + "matching": "Conformes à l'attendu", + "ipType": "Type d'adresse", + "expectedOffset": "Décalage attendu", + "actualOffset": "Décalage observé", + "sameOffset": "Même décalage", + "primary": "Langue principale", + "primaryExpected": "Principale attendue", + "demoted": "Présente mais pas en tête", + "timeZone": "Fuseau horaire", + "reportedOffset": "Décalage selon Date", + "zoneOffset": "Décalage selon le fuseau", + "samples": "Échantillons", + "expectedScripts": "Systèmes attendus", + "installedScripts": "Systèmes installés", + "expectedLanguages": "Langues attendues", + "voiceLanguages": "Langues des voix", + "voiceCount": "Voix", + "expectedLayout": "Disposition attendue", + "actualLayout": "Disposition observée", + "headerPrimary": "Langue principale de l'en-tête", + "scriptPrimary": "Langue principale du script", + "headerLanguages": "Langues de l'en-tête", + "ipZone": "Fuseau de l'IP", + "browserZone": "Fuseau du navigateur", + "ipOffset": "Décalage de l'IP", + "browserOffset": "Décalage du navigateur", + "colo": "PoP Cloudflare", + "gpsTimezone": "Fuseau de votre position", + "accuracyMetres": "Précision (m)", + "cardIssuer": "Banque émettrice", + "cardNetwork": "Réseau de carte", + "cardTier": "Gamme de carte", + "cardType": "Type de carte" + }, + "checks": { + "ip-country": { + "title": "Pays de l’IP", + "fix": "Une partie de vos sorties se trouve hors du pays visé — avec plusieurs sorties de proxy, des cartes différentes peuvent réellement afficher des pays différents, et chaque sortie est ce que verra tel ou tel site. Les fractions ci-dessus indiquent combien de sorties concordent. Faites tout passer par des nœuds du pays visé ; si une seule source diverge sur une IP, le nœud est peut-être simplement mal étiqueté dans sa base, ce qui n'est pas corrigeable de votre côté.", + "what": "Dans quel pays les cartes IP de la page d'accueil situent votre adresse de sortie — chaque source est lue, et chaque sortie doit se trouver dans le pays visé." + }, + "webrtc-leak": { + "title": "Exposition WebRTC", + "fix": "Bloquez WebRTC, ou utilisez une configuration où il ne peut voir que l'adresse du proxy. Le coût est réel : les appels vidéo et la visioconférence cessent de fonctionner. Une extension qui filtre WebRTC est plus douce qu'une désactivation totale, mais l'extension elle-même laisse ses propres traces.", + "what": "Si WebRTC divulgue une adresse située dans un autre pays que votre persona." + }, + "dns-resolver-country": { + "title": "Pays du résolveur DNS", + "fix": "Utilisez un résolveur situé dans le pays visé, ou laissez le proxy gérer la résolution DNS. Notez qu'un résolveur public comme 1.1.1.1 n'exposera pas votre position, mais ne soutiendra pas non plus votre persona : il est neutre pour tous les pays.", + "what": "Dans quel pays se trouvent les résolveurs DNS qui répondent pour vous." + }, + "asn-type": { + "title": "Type d'adresse", + "fix": "Une plage de centre de données est le signal « ceci est un proxy » le plus bruyant qu'une adresse puisse porter. Une sortie résidentielle corrige le problème, mais coûte nettement plus cher et sa qualité varie beaucoup.", + "what": "Si votre adresse relève d'un FAI résidentiel ou d'une plage de centre de données." + }, + "timezone-vs-persona": { + "title": "Fuseau horaire et persona", + "fix": "Changez le fuseau horaire au niveau du système d'exploitation : toutes les API du navigateur restent alors cohérentes entre elles. Les extensions qui forcent le fuseau n'en modifient souvent qu'une partie, créant les contradictions signalées par les vérifications de cohérence — ce qui est pire que de ne rien changer.", + "what": "Si le fuseau horaire de votre navigateur est celui qu'aurait un habitant." + }, + "language-match": { + "title": "Langue du navigateur", + "fix": "Changez la langue dans les réglages du système ou du navigateur plutôt que de réécrire un seul en-tête HTTP. L'ordre compte aussi : les sites lisent toute la liste, et un habitant place rarement une langue étrangère en tête.", + "what": "Si la langue que votre navigateur place en tête est celle d'un habitant." + }, + "script-fonts": { + "title": "Polices du système d'écriture", + "fix": "Une machine configurée pour cette région embarque les polices de ce système d'écriture. Les installer à la main est possible mais rarement utile : cela change un signal alors que le reste du système demeure dans sa langue d'origine, ce qui crée généralement plus de contradictions que cela n'en résout. Si ce point doit tenir, c'est la langue du système qu'il faut changer.", + "what": "Si les polices du système d'écriture de ce pays sont installées sur la machine." + }, + "speech-voices": { + "title": "Voix de synthèse vocale", + "fix": "Les packs de synthèse vocale suivent la langue d'installation du système, et pratiquement aucun proxy ni extension de falsification n'y touche. Ajouter un pack vocal pour la langue visée est une vraie correction, avec la même réserve que pour les polices : un seul signal modifié dans un système par ailleurs inchangé paraît délibéré.", + "what": "Quelles langues couvrent les packs de synthèse vocale de votre système." + }, + "keyboard-layout": { + "title": "Disposition du clavier", + "fix": "Votre disposition physique de clavier ne correspond pas à celle du pays visé. Seuls les navigateurs Chromium l'exposent, le signal est donc plus étroit que les autres — mais là où il est lisible, il est difficile à falsifier sans changer le clavier lui-même.", + "what": "Quelle disposition physique de clavier votre machine déclare." + }, + "edge-geo-consensus": { + "title": "Géolocalisation en périphérie", + "fix": "La lecture que Cloudflare fait de votre adresse de sortie diffère du pays visé. Quand cette vérification et la recherche IP se contredisent, l'adresse est plus probablement mal étiquetée dans une base que réellement déplacée — mais tout site derrière Cloudflare voit ce verdict-ci, pas l'autre.", + "what": "La lecture propre à Cloudflare de votre adresse de sortie, indépendante des bases ci-dessus." + }, + "accept-language-header": { + "title": "En-tête de langue vs script", + "fix": "L'en-tête Accept-Language envoyé par votre navigateur ne correspond pas à ce que navigator.languages rapporte aux scripts. Presque rien ne produit cela, hormis une extension qui a réécrit l'un en laissant l'autre. Changez plutôt la langue dans les réglages du navigateur ou du système : les deux suivront ensemble.", + "what": "Si l'en-tête de langue envoyé sur le réseau correspond à ce qui est dit aux scripts. Volontairement indépendant du pays visé : réussir dit seulement que vos deux canaux de langue concordent — savoir si la langue elle-même convient relève de la vérification « Langue du navigateur ». Les falsifications de langue à moitié appliquées échouent précisément ici." + }, + "ip-timezone-vs-browser": { + "title": "Fuseau de l’IP vs navigateur", + "fix": "Le fuseau horaire de votre IP de sortie et celui que rapporte votre navigateur désignent deux endroits différents. Cela vaut quel que soit le pays visé : une vraie machine se trouve quelque part, et les deux devraient désigner ce même endroit. En général, le proxy a déplacé votre adresse mais pas l'horloge de votre système.", + "what": "Si le fuseau de votre IP et celui rapporté par le navigateur concordent." + }, + "gps-location": { + "title": "Position de l'appareil (GPS)", + "fix": "Votre appareil se déclare physiquement dans un autre pays que celui visé. Aucun proxy n'y touche : tout site qui demande votre position obtient la vraie réponse, et les extensions qui la falsifient laissent généralement intactes la précision et la temporisation. Les options réalistes : refuser l'autorisation de localisation aux sites qui la demandent, ou accepter que ce point ne tienne pas.", + "what": "Dans quel pays votre appareil se déclare physiquement." + }, + "payment-region": { + "title": "Pays émetteur de la carte", + "fix": "Votre carte a été émise dans un autre pays que celui dont vous vous réclamez. Les marchands voient le pays émetteur avant presque tout le reste, et les systèmes de risque le comparent étroitement à l'adresse et à l'IP. Il n'existe pas de correctif côté navigateur — c'est une propriété de la carte elle-même.", + "what": "Quel pays a émis la carte correspondant au préfixe saisi." + }, + "intl-number-format": { + "title": "Format numérique", + "what": "Comment votre navigateur groupe les chiffres et marque les décimales.", + "fix": "Votre navigateur groupe les nombres selon sa propre région, pas selon celle du pays visé. C'est distinct de la langue : une machine peut porter la bonne balise de langue alors que sa région désigne encore ailleurs. Changez la région — et pas seulement la langue — dans les réglages système : « Région » sous Windows, « Langue et région » sous macOS." + }, + "intl-date-format": { + "title": "Format de date", + "what": "L'ordre et les séparateurs employés par votre navigateur pour écrire une date.", + "fix": "Votre navigateur écrit les dates dans un ordre ou avec des séparateurs différents de ceux d'un habitant. Comme le format numérique, cela suit la région du système et non la langue : c'est la région qu'il faut changer." + }, + "intl-hour-cycle": { + "title": "Format d'heure", + "what": "Si votre navigateur présente une horloge de 12 ou de 24 heures — la convention que voient les sites quand ils formatent l'heure.", + "fix": "Le format d'heure suit la langue du navigateur, pas l'interrupteur « 24 heures » du système — la plupart des navigateurs ignorent complètement ce réglage (Safari est la principale exception). Pour présenter la convention du pays visé, passez la langue ou la région du navigateur sur une locale de ce pays." + }, + "timezone-authenticity": { + "title": "Authenticité du fuseau horaire", + "what": "Si le fuseau horaire annoncé par votre navigateur est bien celui de la machine.", + "fix": "Le fuseau annoncé n'est pas celui sur lequel tourne réellement la machine : Intl et Date se contredisent, ou les dates passées ne suivent pas les règles d'heure d'été de ce fuseau. C'est important parce que la vérification de fuseau ci-dessus a donc validé une valeur qui n'est pas réelle — un site qui lit l'horloge directement s'en aperçoit. Réglez le fuseau dans votre système d'exploitation plutôt qu'avec une extension : le décalage courant et les règles historiques suivront ensemble." + } + }, + "optional": { + "gpsNote": "La position GPS est l'une des dimensions que la vérification peut mesurer — partagez-la si vous voulez qu'elle soit comparée aussi.", + "gpsButton": "Partager ma position", + "gpsGranted": "Position reçue.", + "gpsFailed": { + "denied": "Autorisation refusée — cette vérification sera non mesurée.", + "timeout": "La demande de position a expiré.", + "unavailable": "Votre appareil n'a pas pu déterminer de position.", + "unsupported": "Ce navigateur ne prend pas en charge la géolocalisation." + }, + "cardNote": "Parfois, vous voulez aussi payer comme un local. Saisissez les 6 à 8 premiers chiffres de votre carte : la vérification dira si la carte passe pour émise dans ce pays.", + "cardError": "6 à 8 chiffres requis — saisissez-en au moins 6 pour identifier la banque émettrice.", + "cardReady": "Préfixe prêt — le pays émetteur est recherché au moment de la vérification." + }, + "dependencies": { + "note": "La vérification a besoin des résultats de tous ces tests de base — lancez d'abord ceux qui manquent :", + "runButton": "Lancer les tests manquants", + "measured": "{n} sur {total} mesurées", + "allReady": "Tous les tests ont été lancés" + }, + "step": { + "country": "Pays visé", + "language": "Langue principale", + "location": "Position", + "card": "Préfixe de carte", + "awaitingCountry": "Choisissez d'abord un pays", + "otherTests": "Tests de base" + }, + "emptyHint": "Choisissez le pays dont vous voulez avoir l'air d'être un habitant, puis lancez la vérification.", + "readyHint": "Prêt. Lancez la vérification pour voir à quel point vous passez pour un habitant.", + "value": { + "yes": "Oui", + "no": "Non" + } + }, "censorshipcheck": { "Title": "Test de censure", "Note": "Consultez, à partir des données ouvertes du réseau mondial de volontaires OONI, dans quels pays/régions un site a été bloqué ou perturbé au cours des 30 derniers jours — et par quels moyens. Vous pouvez ensuite vérifier les régions suspectes avec un test en temps réel Globalping. Dans certains pays, la censure est clairement définie par la loi, tandis que dans d'autres elle est plus floue.", @@ -596,7 +860,8 @@ "InvisibilityTest": "Vérifiez si vous utilisez un proxy ou un VPN", "MacChecker": "Requête d'informations d'une adresse physique", "BrowserInfo": "Vérifier les informations du navigateur et l'empreinte digitale", - "SecurityChecklist": "Guide pour sécuriser votre vie numérique" + "SecurityChecklist": "Guide pour sécuriser votre vie numérique", + "PersonaCheck": "Comparez ce que voient les sites avec le pays dont vous voulez avoir l'air d'être un habitant" }, "macchecker": { "Title": "Recherche MAC", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index ebe90bc14..8cb69663e 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -580,6 +580,270 @@ "negative": "Анализ высокой задержки не выявил признаков использования прокси или VPN." } }, + "personacheck": { + "Title": "Проверка цифрового портрета", + "Note": "Есть старый тест: если что-то выглядит как утка, плавает как утка и крякает как утка — скорее всего, это утка. Сайты читают вас так же: IP может заявлять, что вы в стране A, но часовой пояс, языки, установленные шрифты, клавиатура и десяток других сигналов рассказывают каждый свою историю, и вместе они образуют портрет, который сайт видит на самом деле. Этот инструмент измеряет этот портрет, сравнивает его с местным жителем, за которого вы хотите сойти, и показывает, где именно они расходятся.", + "NoteVs": "Он отвечает на другой вопрос, чем тест на невидимость: тот проверяет, можно ли обнаружить сам прокси; здесь же вопрос в том, каким человеком вы выглядите для сайтов — и насколько это далеко от того образа, который вы рассчитываете показывать.", + "Note2": "Начните с выбора страны, местным жителем которой вы хотите выглядеть.", + "signInFirst": "Для этого инструмента нужен вход — вернитесь на главную страницу, войдите и возвращайтесь; каждый запуск расходует одну единицу месячного лимита.", + "axis": { + "match": "Соответствие цели", + "coherence": "Внутренняя согласованность", + "leak": "Утечка личности" + }, + "zone": { + "expected": "Ожидаемый образ", + "optional": "Дополнительная точность (необязательно)", + "run": "Запуск проверки" + }, + "selectCountry": "Поиск страны", + "noCountryMatch": "Совпадений не найдено", + "noProfile": "Для этой территории нет локального профиля для сравнения.", + "languageSingle": "В этой стране один основной язык.", + "languageMulti": "В этой стране несколько языков — выберите тот, который вы хотите представлять.", + "timezoneMulti": "Эта страна охватывает несколько часовых поясов — выберите тот, который вы хотите представлять.", + "hourCycle": { + "h11": "12-часовой формат (0–11)", + "h12": "12-часовой формат", + "h23": "24-часовой формат", + "h24": "24-часовой формат (1–24)" + }, + "profile": { + "timezone": "Часовой пояс" + }, + "runCompare": "Запустить проверку", + "runError": "Проверку не удалось завершить.", + "source": { + "ipinfo": "Поиск по IP", + "webrtc": "WebRTC", + "dnsleak": "Утечка DNS" + }, + "report": { + "grade": { + "A": "🎉 Вы выглядите в точности как местный житель ({country})", + "B": "😊 Вы отчасти похожи на местного жителя ({country})", + "C": "🤔 Вы не очень похожи на местного жителя ({country})", + "D": "😅 Вы не похожи на местного жителя ({country})", + "unknown": "🤷 Недостаточно данных для оценки" + }, + "gradeNote": { + "A": "Все доступные признаки согласуются с выбранным образом.", + "B": "Образ в целом держится, но отдельные признаки указывают в другую сторону.", + "C": "Несколько признаков противоречат образу или друг другу — результаты ниже показывают, какие именно.", + "D": "Большинство признаков указывает не на этот образ.", + "unknown": "Сначала запустите базовые тесты — данных пока слишком мало." + }, + "counts": { + "leak": "раскрытий: {n}", + "warning": "несоответствий: {n}", + "match": "пройдено: {n}", + "unknown": "не измерено: {n}", + "notApplicable": "неприменимо: {n}" + }, + "nothingActionable": "Исправлять нечего — все измеримые признаки соответствуют персоне.", + "howToFix": "Как исправить", + "verdict": { + "match": "Соответствует персоне.", + "mismatch": "Противоречит персоне.", + "leak": "Раскрывает вашу настоящую среду.", + "unnatural": "Внутренне противоречиво — два признака, которые должны совпадать, говорят разное, и это привлекает больше внимания, чем простое несовпадение.", + "unknown": "Сейчас измерить невозможно.", + "not-applicable": "Здесь вывод невозможен." + }, + "visibility": { + "public": "Видит любой сайт", + "probed": "Сайты, которые это проверяют", + "risk-engine": "Антифрод-системы" + }, + "profileTitle": "Подробнее о мерах", + "state": { + "match": "Пройдено", + "mismatch": "Не местный", + "unnatural": "Противоречие", + "leak": "Раскрыто", + "unknown": "Не измерено", + "not-applicable": "Н/П" + } + }, + "detail": { + "expected": "Ожидалось", + "actual": "Фактически", + "disputed": "Источники расходятся", + "v4": "IPv4", + "v6": "IPv6", + "candidateCount": "Кандидатов", + "matching": "Совпадает с ожидаемым", + "ipType": "Тип адреса", + "expectedOffset": "Ожидаемое смещение", + "actualOffset": "Фактическое смещение", + "sameOffset": "Смещение совпадает", + "primary": "Основной язык", + "primaryExpected": "Ожидаемый основной", + "demoted": "Есть в списке, но не первый", + "timeZone": "Часовой пояс", + "reportedOffset": "Смещение по Date", + "zoneOffset": "Смещение по поясу", + "samples": "Выборок", + "expectedScripts": "Ожидаемые письменности", + "installedScripts": "Установленные письменности", + "expectedLanguages": "Ожидаемые языки", + "voiceLanguages": "Языки голосов", + "voiceCount": "Голосов", + "expectedLayout": "Ожидаемая раскладка", + "actualLayout": "Фактическая раскладка", + "headerPrimary": "Основной язык заголовка", + "scriptPrimary": "Основной язык скрипта", + "headerLanguages": "Языки заголовка", + "ipZone": "Часовой пояс IP", + "browserZone": "Часовой пояс браузера", + "ipOffset": "Смещение IP", + "browserOffset": "Смещение браузера", + "colo": "Узел Cloudflare", + "gpsTimezone": "Часовой пояс вашей позиции", + "accuracyMetres": "Точность (м)", + "cardIssuer": "Банк-эмитент", + "cardNetwork": "Платёжная система", + "cardTier": "Уровень карты", + "cardType": "Тип карты" + }, + "checks": { + "ip-country": { + "title": "Страна IP-адреса", + "fix": "Часть ваших выходов находится вне целевой страны — при нескольких прокси-выходах разные карточки действительно показывают разные страны, и каждый выход увидит какой-то из сайтов. Дроби выше показывают, сколько выходов совпадает. Направьте весь трафик через узлы целевой страны; если по одному IP расходится лишь один источник, узел может быть просто неверно помечен в его базе — это не исправляется с вашей стороны.", + "what": "В какую страну карточки IP на главной странице помещают ваш выходной адрес — читается каждый источник, и каждый выход должен находиться в целевой стране." + }, + "webrtc-leak": { + "title": "Раскрытие через WebRTC", + "fix": "Заблокируйте WebRTC или используйте схему, где он видит только адрес прокси. Цена вполне реальна: видеозвонки и голосовые конференции перестанут работать. Расширение, фильтрующее WebRTC, мягче полного отключения, но само расширение оставляет собственные следы.", + "what": "Раскрывает ли WebRTC адрес из страны, отличной от вашей персоны." + }, + "dns-resolver-country": { + "title": "Страна DNS-резолвера", + "fix": "Используйте резолвер в целевой стране или отдайте разрешение имён прокси. Учтите: публичный резолвер вроде 1.1.1.1 не выдаст вашего местоположения, но и не поддержит вашу персону — он одинаково нейтрален для любой страны.", + "what": "В какой стране находятся DNS-резолверы, отвечающие для вас." + }, + "asn-type": { + "title": "Тип адреса", + "fix": "Диапазон дата-центра — самый громкий сигнал «это прокси», который может нести адрес. Резидентный выход решает проблему полностью, но стоит заметно дороже и сильно различается по качеству.", + "what": "Принадлежит ли ваш адрес домашнему провайдеру или диапазону дата-центра." + }, + "timezone-vs-persona": { + "title": "Часовой пояс и персона", + "fix": "Меняйте часовой пояс на уровне операционной системы — тогда все API браузера останутся согласованными между собой. Расширения, подменяющие пояс, часто меняют лишь его часть и создают противоречия, отмеченные в проверках согласованности, а это хуже, чем не менять вовсе.", + "what": "Совпадает ли часовой пояс браузера с тем, что был бы у местного жителя." + }, + "language-match": { + "title": "Язык браузера", + "fix": "Меняйте язык в настройках системы или браузера, а не переписывайте один HTTP-заголовок. Порядок тоже важен: сайты читают весь список, а местный житель редко ставит иностранный язык первым.", + "what": "Является ли первый язык вашего браузера тем, которым пользуется местный." + }, + "script-fonts": { + "title": "Шрифты письменности", + "fix": "Машина, настроенная под этот регион, поставляется со шрифтами соответствующей письменности. Установить их вручную можно, но обычно не стоит: вы меняете один признак, тогда как остальная система остаётся на прежнем языке, и это создаёт больше противоречий, чем снимает. Если этот пункт должен выдержать проверку, менять нужно язык самой системы.", + "what": "Установлены ли на машине шрифты письменности этой страны." + }, + "speech-voices": { + "title": "Голоса синтеза речи", + "fix": "Пакеты синтеза речи следуют языку установки системы, и практически ни один прокси или расширение-подменщик их не трогает. Установка голосового пакета для нужного языка — настоящее исправление, с той же оговоркой, что и со шрифтами: один изменённый признак в остальном неизменной системе выглядит нарочитым.", + "what": "Какие языки покрывают пакеты синтеза речи в вашей системе." + }, + "keyboard-layout": { + "title": "Раскладка клавиатуры", + "fix": "Ваша физическая раскладка не соответствует типичной для целевой страны. Её раскрывают только браузеры на Chromium, поэтому сигнал уже остальных — но там, где он читается, подделать его без смены самой клавиатуры трудно.", + "what": "Какую физическую раскладку сообщает ваша машина." + }, + "edge-geo-consensus": { + "title": "Геолокация на периферии", + "fix": "Собственное определение Cloudflare по вашему выходному адресу расходится с целевой страной. Когда эта проверка и поиск по IP противоречат друг другу, адрес скорее неверно помечен в одной из баз, чем действительно переместился, — но любой сайт за Cloudflare видит именно этот вердикт, а не тот.", + "what": "Собственное определение Cloudflare по вашему выходному адресу, независимое от баз выше." + }, + "accept-language-header": { + "title": "Заголовок языка и скрипт", + "fix": "Заголовок Accept-Language, который браузер отправляет по сети, не совпадает с тем, что navigator.languages сообщает скриптам. Такое почти ничем не объясняется, кроме расширения, переписавшего одно и оставившего другое. Меняйте язык в настройках браузера или системы — тогда оба изменятся сразу.", + "what": "Совпадает ли языковой заголовок в сети с тем, что сообщается скриптам. Намеренно не зависит от целевой страны: успех говорит лишь о том, что два языковых канала согласуются между собой — подходит ли сам язык, решает проверка «Язык браузера». Наполовину применённая подмена языка проваливается именно здесь." + }, + "ip-timezone-vs-browser": { + "title": "Часовой пояс IP и браузера", + "fix": "Часовой пояс вашего выходного IP и пояс, о котором сообщает браузер, — это разные места. Это верно независимо от того, за жителя какой страны вы себя выдаёте: реальная машина находится где-то одном, и оба должны указывать на это место. Обычно это значит, что прокси сменил адрес, но не часы системы.", + "what": "Согласуются ли часовой пояс вашего IP и пояс, о котором сообщает браузер." + }, + "gps-location": { + "title": "Местоположение устройства (GPS)", + "fix": "Ваше устройство сообщает, что физически находится не в той стране, за которую вы себя выдаёте. Прокси этого не касается: любой сайт, запросивший местоположение, получит настоящий ответ, а расширения, подменяющие его, обычно оставляют нетронутыми точность и тайминги. Реалистичных вариантов два: отказывать в доступе к геолокации всем сайтам, которые её просят, либо смириться, что этот пункт не выдержит.", + "what": "В какой стране устройство сообщает о своём физическом нахождении." + }, + "payment-region": { + "title": "Страна выпуска карты", + "fix": "Ваша карта выпущена не в той стране, за жителя которой вы себя выдаёте. Продавцы видят страну эмитента раньше почти всего остального, а системы платёжного риска сопоставляют её с адресом и IP. Решения на стороне браузера здесь нет — это свойство самой карты.", + "what": "Какая страна выпустила карту, соответствующую введённому префиксу." + }, + "intl-number-format": { + "title": "Формат чисел", + "what": "Как браузер группирует цифры и обозначает десятичный разделитель.", + "fix": "Ваш браузер группирует числа по собственному региону, а не по обычаям целевой страны. Это не то же самое, что язык: машина может нести правильный языковой тег, а регион по-прежнему указывать в другое место. Меняйте именно регион, а не только язык: в Windows это «Регион», в macOS «Язык и регион»." + }, + "intl-date-format": { + "title": "Формат даты", + "what": "Порядок и разделители, которыми браузер записывает дату.", + "fix": "Ваш браузер записывает даты в другом порядке или с другими разделителями, чем местный житель. Как и формат чисел, это следует за регионом системы, а не за языком — менять нужно регион." + }, + "intl-hour-cycle": { + "title": "Формат времени", + "what": "Показывает ли браузер 12-часовой или 24-часовой формат — именно эту конвенцию видят сайты при форматировании времени.", + "fix": "Формат времени следует за языком браузера, а не за системным переключателем 24-часового формата — большинство браузеров полностью игнорируют этот переключатель (главное исключение — Safari). Чтобы показывать конвенцию целевой страны, переключите язык или регион браузера на локаль этой страны." + }, + "timezone-authenticity": { + "title": "Достоверность часового пояса", + "what": "Является ли часовой пояс, о котором сообщает браузер, настоящим поясом машины.", + "fix": "Пояс, о котором сообщает браузер, — не тот, в котором машина работает на самом деле: либо Intl и Date противоречат друг другу, либо прошлые даты не следуют правилам летнего времени этого пояса. Это важно потому, что проверка пояса выше прошла на недостоверном значении — сайт, читающий часы напрямую, это увидит. Задавайте пояс в операционной системе, а не расширением: тогда текущее смещение и исторические правила изменятся вместе." + } + }, + "reason": { + "no-marker-script": "Нет характерной письменности", + "bin-lookup-failed": "Сервис проверки карт был недоступен — цифры могут быть верными; повторите попытку позже.", + "bin-not-recognized": "База карт не распознала этот префикс — чаще всего это опечатка; проверьте цифры и запустите проверку снова.", + "font-probe-unavailable": "Canvas недоступен", + "no-voice-packs": "Голосовые пакеты не установлены", + "keyboard-api-unavailable": "Браузер это не раскрывает", + "no-layout-expectation": "Для этой страны нет ожидаемой раскладки", + "no-persona-languages": "У персоны не задан язык", + "geolocation-unsupported": "Геолокация не поддерживается" + }, + "optional": { + "gpsNote": "GPS-позиция — одно из измерений проверки; поделитесь ею, если хотите, чтобы она тоже участвовала в сравнении.", + "gpsButton": "Поделиться местоположением", + "gpsGranted": "Местоположение получено.", + "gpsFailed": { + "denied": "В доступе отказано — проверка останется неизмеренной.", + "timeout": "Время запроса местоположения истекло.", + "unavailable": "Устройству не удалось определить позицию.", + "unsupported": "Этот браузер не поддерживает геолокацию." + }, + "cardNote": "Иногда вы хотите и платить как местный. Введите первые 6-8 цифр карты — проверка скажет, читается ли карта как выпущенная в этой стране.", + "cardError": "Нужно 6-8 цифр — введите не менее 6, чтобы определить банк-эмитент.", + "cardReady": "Префикс введён — страну-эмитента определим во время проверки." + }, + "dependencies": { + "note": "Проверке нужны результаты всех этих базовых тестов — сначала запустите недостающие:", + "runButton": "Запустить недостающие тесты", + "measured": "измерено {n} из {total}", + "allReady": "Все тесты выполнены" + }, + "step": { + "country": "Целевая страна", + "language": "Основной язык", + "location": "Местоположение", + "card": "Префикс карты", + "awaitingCountry": "Сначала выберите страну", + "otherTests": "Базовые тесты" + }, + "emptyHint": "Выберите страну, местным жителем которой хотите выглядеть, затем запустите проверку.", + "readyHint": "Готово. Запустите проверку и посмотрите, насколько вы похожи на местного.", + "value": { + "yes": "Да", + "no": "Нет" + } + }, "advancedtools": { "OpenInNewTab": "Открыть в новой вкладке", "BackToHome": "На главную", @@ -596,7 +860,8 @@ "InvisibilityTest": "Проверка использования прокси или VPN", "MacChecker": "Поиск сведений о физическом адресе", "BrowserInfo": "Проверка сведений и отпечатка браузера", - "SecurityChecklist": "Руководство по защите цифровой жизни" + "SecurityChecklist": "Руководство по защите цифровой жизни", + "PersonaCheck": "Сравните то, что видят сайты, со страной, местным жителем которой вы хотите выглядеть" }, "macchecker": { "Title": "Поиск MAC-адреса", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 8afb20b3d..9102c2806 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -544,6 +544,270 @@ "negative": "高延迟分析未发现你使用代理或 VPN 的证据。" } }, + "personacheck": { + "Title": "身份画像检测", + "Note": "有一个经典的判断法:如果它长得像鸭子、游起来像鸭子、叫起来也像鸭子,那它多半就是鸭子。网站也是这样看你的——IP 可以声称你在 A 国,但时区、语言、已安装字体、键盘布局等十几个信号各自都在讲述自己的故事,它们合在一起才是网站真正看到的那个「你」。本工具测量这个真实呈现的身份画像,与你希望被看作的那个本地人做对照,并告诉你两者的差距具体在哪里。", + "NoteVs": "它和隐身测试(Invisibility Test)回答的是不同的问题:隐身测试判断你的代理本身是否会被识别出来;本工具判断的是——在网站眼里你是一个什么样的人,以及这个画像和你预期呈现的差了多少。", + "Note2": "先选择你希望被看作哪个国家的本地人。", + "signInFirst": "该工具需登录后使用——请先回到首页登录,再回来运行;每次检测会消耗一次每月额度。", + "axis": { + "match": "与目标匹配", + "coherence": "自洽性", + "leak": "身份泄露" + }, + "zone": { + "expected": "预期身份", + "optional": "更精准的定位(可选)", + "run": "开始检测" + }, + "selectCountry": "搜索国家", + "noCountryMatch": "没有匹配的国家", + "noProfile": "该地区没有可用于比较的本地画像。", + "languageSingle": "该国只有一种主要语言。", + "languageMulti": "该国有多种语言,请选择你想呈现的那一种。", + "timezoneMulti": "该国横跨多个时区,请选择你想呈现的那一个。", + "hourCycle": { + "h11": "12 小时制(0–11 时)", + "h12": "12 小时制", + "h23": "24 小时制", + "h24": "24 小时制(1–24 时)" + }, + "profile": { + "timezone": "时区" + }, + "runCompare": "开始检测", + "runError": "检测未能完成。", + "source": { + "ipinfo": "IP 查询", + "webrtc": "WebRTC", + "dnsleak": "DNS 泄露" + }, + "report": { + "grade": { + "A": "🎉 你看起来就是一个{country}本地人", + "B": "😊 你看起来有点像{country}本地人", + "C": "🤔 你看起来不太像{country}本地人", + "D": "😅 你看起来不像{country}本地人", + "unknown": "🤷 信号不足,无法评级" + }, + "gradeNote": { + "A": "所有能读到的特征都与你选择的画像一致。", + "B": "画像基本成立,但有少数特征指向别处。", + "C": "有几项特征与画像不符,或彼此矛盾——下面的结果会告诉你是哪些。", + "D": "多数特征指向的并不是这个画像。", + "unknown": "请先运行底层检测——目前可用的信息太少。" + }, + "counts": { + "leak": "{n} 项暴露", + "warning": "{n} 项不一致", + "match": "{n} 项通过", + "unknown": "{n} 项未测量", + "notApplicable": "{n} 项不适用" + }, + "nothingActionable": "没有需要修复的——所有可测量的特征都符合该画像。", + "howToFix": "怎么修", + "verdict": { + "match": "符合角色设定。", + "mismatch": "与角色设定矛盾。", + "leak": "暴露了你的真实环境。", + "unnatural": "内部自相矛盾——本该一致的两个信号各说各话,比单纯的不一致更引人注目。", + "unknown": "当前无法测量。", + "not-applicable": "此项无法给出结论。" + }, + "visibility": { + "public": "任何网站都看得到", + "probed": "会主动探测的网站", + "risk-engine": "反欺诈系统" + }, + "profileTitle": "特征详解", + "state": { + "match": "通过", + "mismatch": "不像本地", + "unnatural": "自相矛盾", + "leak": "已暴露", + "unknown": "未测量", + "not-applicable": "不适用" + } + }, + "detail": { + "expected": "预期", + "actual": "实际", + "disputed": "数据源有分歧", + "v4": "IPv4", + "v6": "IPv6", + "candidateCount": "候选地址数", + "matching": "与预期相符", + "ipType": "地址类型", + "expectedOffset": "预期偏移", + "actualOffset": "实际偏移", + "sameOffset": "偏移相同", + "primary": "首选语言", + "primaryExpected": "预期首选", + "demoted": "在列表中但非首位", + "timeZone": "时区", + "reportedOffset": "Date 报告的偏移", + "zoneOffset": "时区应有的偏移", + "samples": "采样数", + "expectedScripts": "预期书写系统", + "installedScripts": "已安装书写系统", + "expectedLanguages": "预期语言", + "voiceLanguages": "语音包语言", + "voiceCount": "语音包数量", + "expectedLayout": "预期布局", + "actualLayout": "实际布局", + "headerPrimary": "请求头首选语言", + "scriptPrimary": "脚本首选语言", + "headerLanguages": "请求头语言列表", + "ipZone": "IP 时区", + "browserZone": "浏览器时区", + "ipOffset": "IP 偏移", + "browserOffset": "浏览器偏移", + "colo": "Cloudflare 接入点", + "gpsTimezone": "你所在位置的时区", + "accuracyMetres": "定位精度(米)", + "cardIssuer": "发卡行", + "cardNetwork": "卡组织", + "cardTier": "卡片等级", + "cardType": "卡类型" + }, + "checks": { + "ip-country": { + "title": "IP 归属国家", + "fix": "你有部分出口落在目标国家之外——有多个代理出口时,不同卡片显示不同国家是真实情况,而任何一个出口都会被某些网站看到。上面的分数标明了有多少出口相符。把所有流量都走目标国家的节点;如果只是单个数据源对某个 IP 有异议,那可能只是该节点在它的库里被误标,这不是你这边能修的。", + "what": "首页各张 IP 检测卡片把你的出口地址判定在哪个国家——每个数据源都会读取,且每个出口都必须落在目标国家。" + }, + "webrtc-leak": { + "title": "WebRTC 暴露", + "fix": "屏蔽 WebRTC,或者改用只能看到代理地址的方案。代价是实打实的:视频通话和语音会议会失效。用插件过滤 WebRTC 比彻底禁用温和,但插件本身也会留下自己的痕迹。", + "what": "WebRTC 是否暴露了与目标国家不符的地址。" + }, + "dns-resolver-country": { + "title": "DNS 解析器国家", + "fix": "改用目标国家的解析器,或者让代理接管 DNS 解析。注意:1.1.1.1 这类公共解析器不会暴露你的位置,但也不会支撑你的角色——它对每个国家都同样中性。", + "what": "为你解析域名的 DNS 服务器位于哪个国家。" + }, + "asn-type": { + "title": "地址类型", + "fix": "数据中心地址段是一个地址能携带的最响亮的「这是代理」信号。换住宅出口能直接解决,但成本高得多,而且质量参差不齐。", + "what": "你的地址属于住宅宽带还是数据中心网段。" + }, + "timezone-vs-persona": { + "title": "时区与角色", + "fix": "在操作系统层面改时区——这样所有浏览器 API 才会自洽。用插件覆盖时区往往只改了一部分,会制造出「内部一致性」那几项标记的矛盾,比不改更糟。", + "what": "你浏览器的时区是不是当地人应有的那个。" + }, + "language-match": { + "title": "浏览器语言", + "fix": "改系统或浏览器的语言设置,而不是只改写一个 HTTP 头。顺序同样重要:网站读的是整个列表,而当地人很少把外语排在第一位。", + "what": "你浏览器排在首位的语言是不是当地人会用的。" + }, + "script-fonts": { + "title": "书写系统字体", + "fix": "为该地区配置的机器会自带对应书写系统的字体。手动安装字体可行,但通常不值得:它只改了一个信号,系统其余部分仍是原来的语言,往往制造出比解决掉的更多的矛盾。如果真要让这一项站得住,该改的是系统语言本身。", + "what": "这台机器有没有安装该国书写系统的字体。" + }, + "speech-voices": { + "title": "语音合成语音包", + "fix": "语音合成包跟随系统安装时的语言,基本没有任何代理或换区插件会去动它。为目标语言装一个语音包是真正有效的修复,但和字体一样有个前提:在其余部分都没变的系统里只改一个信号,反而显得刻意。", + "what": "你系统里的语音合成包覆盖了哪些语言。" + }, + "keyboard-layout": { + "title": "键盘布局", + "fix": "你的物理键盘布局和目标国家的常见布局不符。只有 Chromium 系浏览器会暴露这一项,所以它的覆盖面比其他项窄——但凡是读得到的地方,不换真实键盘就很难伪造。", + "what": "你的机器报告的物理键盘布局是哪一种。" + }, + "edge-geo-consensus": { + "title": "边缘地理判定", + "fix": "Cloudflare 对你出口地址的判定和目标国家不符。当这一项和 IP 查询互相矛盾时,多半是某个数据库标错了,而不是地址真的换了位置——但所有走 Cloudflare 的网站看到的是这个判定,不是那个。", + "what": "Cloudflare 对你出口地址的独立判定,与上面那些数据库彼此无关。" + }, + "accept-language-header": { + "title": "语言请求头与脚本", + "fix": "你的浏览器在网络请求里发出的 Accept-Language 头,和 navigator.languages 向脚本报告的不一致。除了「插件只改了其中一个」,几乎没有别的原因会造成这种情况。改用浏览器或系统的语言设置——那样两者会一起变。", + "what": "网络请求里发出的语言头,和告诉脚本的是否一致。这一项刻意与目标国家无关:通过只说明你的两个语言通道没有互相矛盾,语言本身合不合适由「浏览器语言」那一项判定——只改了一半的语言伪装工具正是在这里露馅。" + }, + "ip-timezone-vs-browser": { + "title": "IP 时区与浏览器时区", + "fix": "你出口 IP 所在的时区,和浏览器报告的时区是两个地方。这一项和你想扮演哪个国家无关:真实机器总在某一个地方,两者本就该说同一个地方。通常意味着代理换了你的地址,却没换系统时区。", + "what": "你 IP 所在的时区和浏览器报告的时区是否一致。" + }, + "gps-location": { + "title": "设备定位(GPS)", + "fix": "你的设备报告的物理位置和你想扮演的国家不是一个地方。代理完全碰不到这一项:任何向你索取定位的网站拿到的都是真实答案,而伪造定位的浏览器插件通常会留下精度和时序上的破绽。现实的选择只有两个:对索取定位的网站一律拒绝授权,或者接受这一项撑不住。", + "what": "你的设备报告自己实际位于哪个国家。" + }, + "payment-region": { + "title": "信用卡发卡国", + "fix": "你的卡是在另一个国家发行的,和你所呈现的国家不符。商家几乎在了解你的其他一切之前就先看到了发卡国,而支付风控系统会把它和账单地址、IP 放在一起重点比对。这一项没有浏览器侧的解法——它是卡本身的属性。", + "what": "你输入的卡号段对应的发卡国是哪里。" + }, + "intl-number-format": { + "title": "数字格式", + "what": "你的浏览器如何分组数字、如何标记小数点。", + "fix": "你的浏览器按自己的区域习惯给数字分组,而不是按目标国家的习惯。这和语言是两回事:一台机器完全可能带着正确的语言标签,区域却仍指向别处。请在系统设置里改**区域**而不只是语言——Windows 在「区域」,macOS 在「语言与地区」。" + }, + "intl-date-format": { + "title": "日期格式", + "what": "你的浏览器书写日期时用的顺序和分隔符。", + "fix": "你的浏览器书写日期的顺序或分隔符与当地人不同。和数字格式一样,它跟随的是系统区域而非语言,改区域才会跟着变。" + }, + "intl-hour-cycle": { + "title": "时间制", + "what": "你的浏览器呈现 12 小时制还是 24 小时制——网站格式化时间时看到的就是这个惯例。", + "fix": "时间制跟随浏览器语言,而不是系统的「24 小时制」开关——多数浏览器完全忽略那个开关(Safari 是主要例外)。想呈现目标国家的习惯,需要把浏览器的语言或区域切换成该国的。" + }, + "timezone-authenticity": { + "title": "时区可信度", + "what": "浏览器报告的时区是不是这台机器真实的时区。", + "fix": "浏览器报告的时区不是机器真正运行的那个:要么 Intl 和 Date 说法不一致,要么历史日期不符合该时区的夏令时规则。这一点之所以重要,是因为它意味着上面那项时区检测是在一个不真实的值上通过的——网站只要直接读时钟就能看穿。请在操作系统里设置时区而不是靠插件,这样当前偏移和历史规则会一起变。" + } + }, + "reason": { + "no-marker-script": "无特征书写系统", + "bin-lookup-failed": "号段查询服务暂时不可用——号码本身可能没有问题,请稍后重试。", + "bin-not-recognized": "卡片数据库没有识别出这个号段——最常见的原因是输错了,请核对后重新检测。", + "font-probe-unavailable": "Canvas 不可用", + "no-voice-packs": "未安装语音包", + "keyboard-api-unavailable": "本浏览器不暴露", + "no-layout-expectation": "该国无对应布局预期", + "no-persona-languages": "角色未设定语言", + "geolocation-unsupported": "不支持地理定位" + }, + "optional": { + "gpsNote": "GPS 位置是检测的维度之一——愿意的话可以共享,让它一并参与对照。", + "gpsButton": "共享定位", + "gpsGranted": "已获取定位。", + "gpsFailed": { + "denied": "已拒绝授权——该项将显示为未测量。", + "timeout": "定位请求超时。", + "unavailable": "你的设备无法确定位置。", + "unsupported": "此浏览器不支持地理定位。" + }, + "cardNote": "有时你也希望以该国身份完成支付——填入卡号前 6-8 位,检测会判断这张卡是否会被视为该国发行。", + "cardError": "需要 6-8 位数字——至少输入 6 位才能查询发卡行。", + "cardReady": "号段已填好——发卡国会在检测时查询。" + }, + "dependencies": { + "note": "检测需要以下所有基础测试的结果——开始前请先补跑缺失的:", + "runButton": "一键补跑缺失的测试", + "measured": "已测量 {n} / {total}", + "allReady": "所有测试已运行" + }, + "step": { + "country": "目标国家", + "language": "主要语言", + "location": "定位", + "card": "卡号段", + "awaitingCountry": "请先选择国家", + "otherTests": "基础测试" + }, + "emptyHint": "先选择你希望看起来像哪国本地人,然后开始检测。", + "readyHint": "已就绪。开始检测,看看你有多像本地人。", + "value": { + "yes": "是", + "no": "否" + } + }, "censorshipcheck": { "Title": "封锁测试", "Note": "基于 OONI(开放网络干预观测站)全球志愿者的开放数据,查询一个网站过去 30 天在哪些国家/地区被屏蔽或干扰、以及采用的屏蔽手段。对可疑地区,还可以用 Globalping 全球探针进行实时连通性验证。在一些地区,互联网审查有较为明确的法律条文,有些则相对含糊。", @@ -596,7 +860,8 @@ "InvisibilityTest": "猜猜看我是否知道你挂了代理", "MacChecker": "查询物理地址的归属信息", "BrowserInfo": "检阅浏览器信息和指纹", - "SecurityChecklist": "全面的数字生活安全检查清单" + "SecurityChecklist": "全面的数字生活安全检查清单", + "PersonaCheck": "把网站看到的你,和你想扮成的本地人做对照" }, "macchecker": { "Title": "MAC 地址查询", diff --git a/frontend/store.js b/frontend/store.js index 680390518..d89a346fd 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -113,9 +113,9 @@ export const useMainStore = defineStore('main', { // only — the backend enforces the same limits authoritatively; absent // data (signed out, old backend, fetch pending) reads as not exceeded. // - // Metering differs per feature: invisibility_test / dns_leak_test count - // requests, so exhausted means every further run is blocked and their - // components use this as a pre-flight gate. + // Metering differs per feature: invisibility_test / dns_leak_test / + // persona_check count requests, so exhausted means every further run is + // blocked and their components use this as a pre-flight gate. quotaExceeded: (state) => { const features = state.remoteUserInfo?.quota?.features || {}; const exceeded = (key) => { @@ -126,6 +126,7 @@ export const useMainStore = defineStore('main', { ipinfo: exceeded('ipinfo'), invisibility_test: exceeded('invisibility_test'), dns_leak_test: exceeded('dns_leak_test'), + persona_check: exceeded('persona_check'), }; }, }, diff --git a/frontend/utils/persona/card-bin.js b/frontend/utils/persona/card-bin.js new file mode 100644 index 000000000..700967b46 --- /dev/null +++ b/frontend/utils/persona/card-bin.js @@ -0,0 +1,10 @@ +// Input rules for the card issuer prefix. Only the first 6-8 digits are ever +// handled: they identify the issuing bank, not the account, and the input +// renders exactly BIN_MAX_LENGTH digit boxes so the cap is structural. + +export const BIN_MIN_LENGTH = 6; +export const BIN_MAX_LENGTH = 8; + +const BIN_RE = new RegExp(`^\\d{${BIN_MIN_LENGTH},${BIN_MAX_LENGTH}}$`); + +export const isValidBin = (value) => typeof value === 'string' && BIN_RE.test(value); diff --git a/frontend/utils/persona/check-ids.js b/frontend/utils/persona/check-ids.js new file mode 100644 index 000000000..cf153d225 --- /dev/null +++ b/frontend/utils/persona/check-ids.js @@ -0,0 +1,123 @@ +// The vocabulary the Persona Check report is rendered from — the contract +// between this front end and the API that evaluates the observation. What +// comes back is ids, enums and detail fields; every one needs copy here, and +// `tests/persona-i18n.test.js` checks the four locales against these lists. +// A new id or field upstream lands here, translated, in the same change. + +// Every check the report can carry a row for, in registry order. +export const PERSONA_CHECK_IDS = [ + 'ip-country', + 'webrtc-leak', + 'dns-resolver-country', + 'asn-type', + 'timezone-vs-persona', + 'language-match', + 'script-fonts', + 'speech-voices', + 'keyboard-layout', + 'edge-geo-consensus', + 'intl-number-format', + 'intl-date-format', + 'intl-hour-cycle', + 'gps-location', + 'payment-region', + 'accept-language-header', + 'ip-timezone-vs-browser', + 'timezone-authenticity', +]; + +// What a check concluded. Rendered as the row's state label and colour. +export const VERDICT = { + MATCH: 'match', // observed value fits the declared persona + MISMATCH: 'mismatch', // observed value contradicts the persona + LEAK: 'leak', // a channel exposed the real identity outright + UNNATURAL: 'unnatural', // internally inconsistent — reads as "being spoofed" + UNKNOWN: 'unknown', // the data is missing but obtainable — run the test + NOT_APPLICABLE: 'not-applicable', // no conclusion is possible or needed here +}; + +// Which of the three questions a check answers. +export const AXIS = { + MATCH: 'match', // do I look like the target country? + COHERENCE: 'coherence', // does my browser contradict itself? + LEAK: 'leak', // is my real identity escaping? +}; + +// Who actually gets to see the signal — what a finding's priority hangs on. +export const VISIBILITY = { + PUBLIC: 'public', // any site sees it without trying + PROBED: 'probed', // needs an active probe (WebRTC, font enumeration) + RISK_ENGINE: 'risk-engine', // only anti-fraud stacks look this deep +}; + +export const GRADE = { A: 'A', B: 'B', C: 'C', D: 'D', UNKNOWN: 'unknown' }; + +// Scalar fields a result's `detail` can carry. The report renders each one as +// a labelled badge, so each needs a `personacheck.detail.` entry. +// Two fields are deliberately absent: `reason` rides on no-answer verdicts +// and is rendered from the `personacheck.reason.*` namespace; `agreement` +// annotates the `actual` value itself ("US · 4/6") rather than earning its +// own badge. +export const PERSONA_DETAIL_KEYS = [ + 'accuracyMetres', + 'actual', + 'actualLayout', + 'actualOffset', + 'browserOffset', + 'browserZone', + 'candidateCount', + 'cardIssuer', + 'cardNetwork', + 'cardTier', + 'cardType', + 'colo', + 'demoted', + 'disputed', + 'expected', + 'expectedLanguages', + 'expectedLayout', + 'expectedOffset', + 'expectedScripts', + 'gpsTimezone', + 'headerLanguages', + 'headerPrimary', + 'installedScripts', + 'ipOffset', + 'ipType', + 'ipZone', + 'matching', + 'primary', + 'primaryExpected', + 'reportedOffset', + 'sameOffset', + 'samples', + 'scriptPrimary', + 'timeZone', + 'v4', + 'v6', + 'voiceCount', + 'voiceLanguages', + 'zoneOffset', +]; + +// Why a check concluded that nothing can be measured. Every not-applicable +// result carries its reason into the report; unknown results keep theirs +// internal ("run the missing test" is the same advice either way) — except +// the ones below, where the visitor's next move differs per reason. +export const PERSONA_NOT_APPLICABLE_REASONS = [ + 'font-probe-unavailable', + 'geolocation-unsupported', + 'keyboard-api-unavailable', + 'no-layout-expectation', + 'no-marker-script', + 'no-persona-languages', + 'no-voice-packs', +]; + +// Unknown-verdict reasons that DO render copy: a card prefix the upstream did +// not know is most often a typo (check the digits), while a failed lookup is +// our service's fault (try later) — generic "not measured" would hide which. +export const PERSONA_UNKNOWN_REASONS = [ + 'bin-lookup-failed', + 'bin-not-recognized', +]; diff --git a/frontend/utils/persona/local-profile.js b/frontend/utils/persona/local-profile.js new file mode 100644 index 000000000..088432f7e --- /dev/null +++ b/frontend/utils/persona/local-profile.js @@ -0,0 +1,58 @@ +// What a local of country X plausibly speaks and which clock they read — the +// two lists the picker offers. Derived from Intl at call time, so choosing a +// country costs no request; the one gap CLDR has no Intl surface for comes +// from data/persona-tables.js. This is the picker's view only — the scoring +// baseline is built where the check runs. + +import { EXTRA_LANGUAGES } from '../../data/persona-tables.js'; + +// Resolve a language + country pair to its CLDR-likely script (ISO 15924). +// The country matters: zh-CN maximizes to Hans, zh-HK to Hant. +const scriptOf = (language, country) => { + try { + return new Intl.Locale(`${language}-${country}`).maximize().script || ''; + } catch { + return ''; + } +}; + +// The single most likely language of a country, per CLDR likelySubtags. +const primaryLanguageOf = (country) => { + try { + return new Intl.Locale(`und-${country}`).maximize().language || ''; + } catch { + return ''; + } +}; + +// Uninhabited territories (BV, HM) legitimately have no zone — an empty list +// is data, not an error, and the tool renders it as "nothing to pick". +const timeZonesOf = (country) => { + try { + return (new Intl.Locale(`und-${country}`).getTimeZones() || []).slice().sort(); + } catch { + return []; + } +}; + +// Primary language first, then the multilingual-country additions, deduped. +// EXTRA_LANGUAGES entries may repeat the primary; the Set drops it. +const languagesOf = (country) => { + const primary = primaryLanguageOf(country); + const ordered = [primary, ...(EXTRA_LANGUAGES[country] || [])].filter(Boolean); + return [...new Set(ordered)].map((language) => ({ + language, + tag: `${language}-${country}`, + script: scriptOf(language, country), + })); +}; + +/** + * Languages and timezones to offer for an ISO 3166-1 alpha-2 code. + * An unknown code yields empty lists rather than throwing. + */ +export const localProfile = (rawCountry) => { + const country = String(rawCountry || '').toUpperCase(); + if (!/^[A-Z]{2}$/.test(country)) return { country: '', languages: [], timeZones: [] }; + return { country, languages: languagesOf(country), timeZones: timeZonesOf(country) }; +}; diff --git a/frontend/utils/persona/observe-browser.js b/frontend/utils/persona/observe-browser.js new file mode 100644 index 000000000..ce3dc684f --- /dev/null +++ b/frontend/utils/persona/observe-browser.js @@ -0,0 +1,67 @@ +// Collects the browser-local signals no other test on the site reports: +// timezone and UTC offsets, the language list, and the browser's own +// rendering of a fixed number and date. + +// The fixed values both halves format. Changing either here means changing +// it in the evaluating API too. +const SAMPLE_INSTANT = Date.UTC(2026, 0, 23, 15, 4, 5); +const SAMPLE_NUMBER = 1234567.89; + +// Mid-winter and mid-summer of this year and last — enough instants to +// exercise a zone's DST rule. +const historicalSampleInstants = () => { + const year = new Date().getUTCFullYear(); + return [ + Date.UTC(year, 0, 15), + Date.UTC(year, 6, 15), + Date.UTC(year - 1, 0, 15), + Date.UTC(year - 1, 6, 15), + ]; +}; + +// JS reports minutes *behind* UTC (Tokyo → -540); everything downstream speaks +// minutes east of UTC, so the sign is flipped once, here. +const offsetEastOf = (instant) => -new Date(instant).getTimezoneOffset(); + +// Rendered output rather than resolvedOptions(): the string is what a site +// actually sees. All three samples follow the browser's locale, not the OS +// preferences (Chromium and Firefox ignore the system 24-hour toggle; Safari +// honors it). The date is pinned to UTC so both halves format the same day. +const readIntlSamples = () => { + try { + return { + number: new Intl.NumberFormat().format(SAMPLE_NUMBER), + date: new Intl.DateTimeFormat(undefined, { timeZone: 'UTC' }).format(SAMPLE_INSTANT), + hourCycle: new Intl.DateTimeFormat(undefined, { hour: 'numeric' }) + .resolvedOptions().hourCycle || '', + }; + } catch { + return null; + } +}; + +/** + * Measure everything observable synchronously from this browser. + */ +export const observeBrowser = () => { + const resolved = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions(); + } catch { + return {}; + } + })(); + + return { + timeZone: resolved.timeZone || '', + // No locale argument on purpose: the browser's own regional setting. + intl: readIntlSamples(), + offsetMinutes: offsetEastOf(Date.now()), + historicalOffsets: historicalSampleInstants() + .map((ts) => ({ ts, offsetMinutes: offsetEastOf(ts) })), + languages: Array.isArray(navigator.languages) && navigator.languages.length + ? [...navigator.languages] + : [navigator.language].filter(Boolean), + }; +}; + diff --git a/frontend/utils/persona/probe-fonts.js b/frontend/utils/persona/probe-fonts.js new file mode 100644 index 000000000..f21ec215a --- /dev/null +++ b/frontend/utils/persona/probe-fonts.js @@ -0,0 +1,92 @@ +// Font probing — which writing systems does this machine have fonts for? +// Only the script-marker fonts in data/persona-tables.js are measured, every +// script each time; the table loads on first probe rather than with the +// module, so a visitor who never opens the tool never downloads it. + +// Fallback faces the target is measured against. A font that exists renders +// the samples with different metrics than the generic family it falls back to. +const BASE_FONTS = ['monospace', 'sans-serif', 'serif']; + +// Two samples per candidate: the script sample shows whether the script +// renders at all, the Latin sample separates fonts whose script glyphs share +// metrics (CJK advances are uniform em-widths, and on a CJK system the +// generic fallbacks are CJK fonts themselves). Ink bounds ride along with +// the width for fonts that differ only in shape. +const SCRIPT_SAMPLES = { + Jpan: 'あアン亜', + Hans: '中文简体', + Hant: '中文繁體', + Kore: '한국어글', + Arab: 'العربية', + Hebr: 'עברית', + Thai: 'ภาษาไทย', + Deva: 'देवनागरी', + Beng: 'বাংলা', + Taml: 'தமிழ்', + Telu: 'తెలుగు', + Knda: 'ಕನ್ನಡ', + Mlym: 'മലയാളം', + Guru: 'ਗੁਰਮੁਖੀ', + Gujr: 'ગુજરાતી', + Sinh: 'සිංහල', + Mymr: 'မြန်မာ', + Khmr: 'ខ្មែរ', + Laoo: 'ລາວ', + Ethi: 'ግዕዝ', + Geor: 'ქართული', + Armn: 'հայերեն', +}; + +const LATIN_SAMPLE = 'mmmmmmmmmmlli'; +const PROBE_SIZE = 72; + +// measureText on a detached canvas: no DOM insertion, no reflow — a few +// hundred measurements complete well inside one frame. Engines without +// actualBoundingBox* degrade to width-only. +const makeMeasurer = () => { + if (typeof document === 'undefined') return null; + const context = document.createElement('canvas').getContext('2d'); + if (!context) return null; + return (font, sample) => { + context.font = `${PROBE_SIZE}px ${font}`; + const metrics = context.measureText(sample); + return [ + metrics.width, + metrics.actualBoundingBoxAscent ?? 0, + metrics.actualBoundingBoxDescent ?? 0, + ].join('|'); + }; +}; + +/** + * Probe the font markers of every writing system in the table. + * Returns { scripts: { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c962ff81e..bacbcf6a3 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1029,7 +1029,8 @@ "ruletest": "Egress IPs observed when fetching 8 test endpoints — more than one unique IP means traffic is split across different routes (e.g. proxy rules).", "browserinfo": "Browser, OS and display environment of the tested device.", "invisibility": "Proxy/VPN detection scores (0-100, higher = more likely detected) with the individual detection signals that flagged.", - "enhanceddnsleak": "Resolver-level DNS capture: every resolver IP that queried the test domain, with transport, ECS exposure and DNSSEC flags (do/cd)." + "enhanceddnsleak": "Resolver-level DNS capture: every resolver IP that queried the test domain, with transport, ECS exposure and DNSSEC flags (do/cd).", + "persona": "How closely the browser matches a local resident of the chosen country: an overall grade plus each check's verdict. Verdicts only — the underlying values are deliberately not stored in a shared report." } } }, diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index a0d2fd6a5..4e9ef9d17 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1029,7 +1029,8 @@ "ruletest": "IP de sortie observées en accédant à 8 points de test — plusieurs IP distinctes signifient que le trafic est réparti sur différentes routes (règles de proxy, par ex.).", "browserinfo": "Navigateur, système et environnement d'affichage de l'appareil testé.", "invisibility": "Scores de détection proxy/VPN (0-100, plus haut = plus probablement détecté) avec les signaux de détection déclenchés.", - "enhanceddnsleak": "Capture DNS au niveau résolveur : chaque IP de résolveur ayant interrogé le domaine de test, avec transport, exposition ECS et indicateurs DNSSEC (do/cd)." + "enhanceddnsleak": "Capture DNS au niveau résolveur : chaque IP de résolveur ayant interrogé le domaine de test, avec transport, exposition ECS et indicateurs DNSSEC (do/cd).", + "persona": "À quel point le navigateur correspond à un habitant du pays choisi : une note globale et le verdict de chaque vérification. Verdicts uniquement — les valeurs sous-jacentes ne sont volontairement pas stockées dans un rapport partagé." } } }, diff --git a/frontend/locales/privacy/en.json b/frontend/locales/privacy/en.json index 3f515fe12..f40fc0809 100644 --- a/frontend/locales/privacy/en.json +++ b/frontend/locales/privacy/en.json @@ -29,7 +29,7 @@ "personaCheck": { "title": "Persona Check", "paragraphs": [ - "Persona Check is the one tool whose measurements leave your browser. The others compute in your browser or look something up and show it only to you; this one sends what it collected to our own API, which scores it against the country you picked and returns the graded report to you. It requires signing in, and each run counts against your monthly allowance. Neither what you send nor the report you get back is stored.", + "Persona Check is the one tool whose measurements leave your browser. The others compute in your browser or look something up and show it only to you; this one sends what it collected to our own API, which scores it against the country you picked and returns the graded report to you. It requires signing in, and each run counts against your monthly allowance. Neither what you send nor the report you get back is stored — unless you explicitly bundle the result into a shareable report link, and even then only the grade and each check's verdict travel into it, never the values behind them.", "What travels with a run:" ], "bullets": [ diff --git a/frontend/locales/privacy/fr.json b/frontend/locales/privacy/fr.json index 54b9ebb83..e67266b51 100644 --- a/frontend/locales/privacy/fr.json +++ b/frontend/locales/privacy/fr.json @@ -29,7 +29,7 @@ "personaCheck": { "title": "Vérification de persona", "paragraphs": [ - "La Vérification de persona est le seul outil dont les mesures quittent votre navigateur. Les autres calculent dans votre navigateur ou effectuent une recherche affichée à vous seul ; celui-ci envoie ce qu'il a collecté à notre propre API, qui l'évalue par rapport au pays que vous avez choisi et vous renvoie le rapport noté. Il nécessite une connexion, et chaque exécution consomme une unité de votre quota mensuel. Ni ce que vous envoyez ni le rapport reçu ne sont conservés.", + "La Vérification de persona est le seul outil dont les mesures quittent votre navigateur. Les autres calculent dans votre navigateur ou effectuent une recherche affichée à vous seul ; celui-ci envoie ce qu'il a collecté à notre propre API, qui l'évalue par rapport au pays que vous avez choisi et vous renvoie le rapport noté. Il nécessite une connexion, et chaque exécution consomme une unité de votre quota mensuel. Ni ce que vous envoyez ni le rapport reçu ne sont conservés — sauf si vous choisissez explicitement de regrouper le résultat dans un lien de rapport partageable ; et même alors, seuls la note et le verdict de chaque vérification y figurent, jamais les valeurs qui les sous-tendent.", "Ce qui accompagne une exécution :" ], "bullets": [ diff --git a/frontend/locales/privacy/ru.json b/frontend/locales/privacy/ru.json index 06370dbd2..baf6755ea 100644 --- a/frontend/locales/privacy/ru.json +++ b/frontend/locales/privacy/ru.json @@ -29,7 +29,7 @@ "personaCheck": { "title": "Проверка цифрового портрета", "paragraphs": [ - "Проверка цифрового портрета — единственный инструмент, чьи измерения покидают ваш браузер. Остальные считают прямо в браузере или запрашивают данные, которые показываются только вам; этот отправляет собранное в наш собственный API, где оно оценивается относительно выбранной вами страны, а готовый отчёт возвращается вам. Инструмент требует входа в аккаунт, и каждый запуск расходует единицу месячного лимита. Ни отправленные данные, ни полученный отчёт не сохраняются.", + "Проверка цифрового портрета — единственный инструмент, чьи измерения покидают ваш браузер. Остальные считают прямо в браузере или запрашивают данные, которые показываются только вам; этот отправляет собранное в наш собственный API, где оно оценивается относительно выбранной вами страны, а готовый отчёт возвращается вам. Инструмент требует входа в аккаунт, и каждый запуск расходует единицу месячного лимита. Ни отправленные данные, ни полученный отчёт не сохраняются — если только вы сами не соберёте результат в общедоступную ссылку на отчёт; но и тогда в неё попадают лишь оценка и вердикт каждой проверки, но не стоящие за ними значения.", "Что уходит вместе с запуском:" ], "bullets": [ diff --git a/frontend/locales/privacy/zh.json b/frontend/locales/privacy/zh.json index 67aab09bc..eb462c80c 100644 --- a/frontend/locales/privacy/zh.json +++ b/frontend/locales/privacy/zh.json @@ -29,7 +29,7 @@ "personaCheck": { "title": "身份画像检测", "paragraphs": [ - "身份画像检测是本站唯一会把测量结果发出浏览器的工具。其他工具都在你的浏览器里完成计算,或查询后只展示给你;这一项会把它收集到的信息发送到我们自己的 API,由其对照你选择的国家进行评分,并把评级报告返回给你。该工具需要登录后使用,每次检测会消耗一次每月额度。你发送的内容和收到的报告都不会被保存。", + "身份画像检测是本站唯一会把测量结果发出浏览器的工具。其他工具都在你的浏览器里完成计算,或查询后只展示给你;这一项会把它收集到的信息发送到我们自己的 API,由其对照你选择的国家进行评分,并把评级报告返回给你。该工具需要登录后使用,每次检测会消耗一次每月额度。你发送的内容和收到的报告都不会被保存——除非你主动把结果打包成可分享的报告链接;即便如此,进入链接的也只有评级和每项检查的结论,而不包含它们背后的具体数值。", "一次检测会随请求发送的内容:" ], "bullets": [ diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 8cb69663e..cbc63b09d 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1029,7 +1029,8 @@ "ruletest": "Выходные IP, обнаруженные при обращении к 8 тестовым адресам. Несколько уникальных IP означают, что трафик разделён между маршрутами, например правилами прокси.", "browserinfo": "Браузер, ОС и параметры экрана проверяемого устройства.", "invisibility": "Оценки обнаружения прокси/VPN (0–100; чем выше, тем вероятнее обнаружение) и отдельные сработавшие признаки.", - "enhanceddnsleak": "Перехват DNS на уровне резолверов: каждый IP резолвера, запросившего тестовый домен, с данными о транспорте, раскрытии ECS и флагах DNSSEC (do/cd)." + "enhanceddnsleak": "Перехват DNS на уровне резолверов: каждый IP резолвера, запросившего тестовый домен, с данными о транспорте, раскрытии ECS и флагах DNSSEC (do/cd).", + "persona": "Насколько браузер похож на местного жителя выбранной страны: общая оценка и вердикт каждой проверки. Только вердикты — стоящие за ними значения намеренно не сохраняются в общем отчёте." } } }, diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 9102c2806..d32759e98 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1029,7 +1029,8 @@ "ruletest": "访问 8 个测试端点时观测到的出口 IP——出现多个不同 IP 说明流量被分流到不同线路(如代理规则)。", "browserinfo": "被测设备的浏览器、操作系统与显示环境。", "invisibility": "代理/VPN 检测得分(0-100,越高越可能被识别)及各项命中的检测信号。", - "enhanceddnsleak": "解析器级 DNS 捕获:向测试域名发起查询的每个解析器 IP,含传输协议、ECS 暴露与 DNSSEC 标志(do/cd)。" + "enhanceddnsleak": "解析器级 DNS 捕获:向测试域名发起查询的每个解析器 IP,含传输协议、ECS 暴露与 DNSSEC 标志(do/cd)。", + "persona": "浏览器与所选国家本地居民的相符程度:一个总体评级,加上每项检查的结论。只有结论——各项检查依据的具体数值刻意不写入可分享的报告。" } } }, diff --git a/frontend/utils/report-builders.js b/frontend/utils/report-builders.js index 4388a1917..999f1fb22 100644 --- a/frontend/utils/report-builders.js +++ b/frontend/utils/report-builders.js @@ -319,6 +319,49 @@ const buildEnhanceddnsleak = (payload) => { }; }; +// persona:finished — { country, grade, score, counts, results } from the +// Persona Check. Only each check's conclusion travels: the live results carry +// a `detail` object per check (issuing bank, the country a shared position +// resolved to, the zone and languages), which has no place behind a link +// anyone can open, so it is dropped here rather than at render time. +const PERSONA_GRADES = new Set(['A', 'B', 'C', 'D', 'unknown']); +const PERSONA_AXES = new Set(['match', 'coherence', 'leak']); +const PERSONA_VERDICTS = new Set([ + 'match', 'mismatch', 'leak', 'unnatural', 'unknown', 'not-applicable', +]); +const personaCount = (value) => clampInt(value, 0, 64) ?? 0; + +const buildPersona = (payload) => { + if (!PERSONA_GRADES.has(payload?.grade)) return null; + const results = (payload?.results ?? []) + .filter((result) => typeof result?.id === 'string' + && PERSONA_AXES.has(result?.axis) && PERSONA_VERDICTS.has(result?.verdict)) + .slice(0, 32) + .map((result) => ({ id: clip(result.id, 48), axis: result.axis, verdict: result.verdict })); + if (!results.length) return null; + + const counts = payload?.counts ?? {}; + return { + country: countryCode(payload?.country), + grade: payload.grade, + // Stored the way the tool displays it; null when the run was too thin + // to grade, which the schema allows and the renderer shows as a dash. + score: finiteNum(payload?.score, 0, 1) === undefined + ? null : Math.round(payload.score * 100), + counts: { + total: personaCount(counts.total), + scored: personaCount(counts.scored), + match: personaCount(counts.match), + mismatch: personaCount(counts.mismatch), + unnatural: personaCount(counts.unnatural), + leak: personaCount(counts.leak), + unknown: personaCount(counts.unknown), + notApplicable: personaCount(counts.notApplicable), + }, + results, + }; +}; + // --- event → builder registry (consumed by use-report-collector) ------------ export const REPORT_EVENT_BUILDERS = { @@ -333,4 +376,5 @@ export const REPORT_EVENT_BUILDERS = { 'browserinfo:finished': { section: 'browserinfo', build: buildBrowserinfo }, 'invisibility:result': { section: 'invisibility', build: buildInvisibility }, 'enhanceddnsleak:finished': { section: 'enhanceddnsleak', build: buildEnhanceddnsleak }, + 'persona:finished': { section: 'persona', build: buildPersona }, }; diff --git a/frontend/utils/report-export.js b/frontend/utils/report-export.js index fd2f9b87a..645bf9e91 100644 --- a/frontend/utils/report-export.js +++ b/frontend/utils/report-export.js @@ -21,6 +21,7 @@ export const SECTION_TITLE_KEYS = { browserinfo: 'browserinfo.Title', invisibility: 'invisibilitytest.Title', enhanceddnsleak: 'enhanceddnsleaktest.Title', + persona: 'personacheck.Title', }; // Assemble the envelope from collected snapshots. Sections keep homepage @@ -164,6 +165,20 @@ const SECTION_RENDERERS = { ]), mdTable(['signal', 'flagged'], section.flags.map((f) => [f.key, f.flagged])), ].join('\n'), + persona: (section) => [ + kvLines([ + ['targetCountry', section.country], + ['grade', section.grade], + ['score', section.score === null ? '—' : `${section.score}/100`], + ['scored', `${section.counts.scored}/${section.counts.total}`], + ['match', section.counts.match], + ['mismatch', section.counts.mismatch], + ['unnatural', section.counts.unnatural], + ['leak', section.counts.leak], + ]), + mdTable(['check', 'axis', 'verdict'], + section.results.map((r) => [r.id, r.axis, r.verdict])), + ].join('\n'), enhanceddnsleak: (section) => [ kvLines([ ['rawCount', section.rawCount], diff --git a/tests/report-builders.test.js b/tests/report-builders.test.js index 059a30fb7..849215c82 100644 --- a/tests/report-builders.test.js +++ b/tests/report-builders.test.js @@ -103,6 +103,28 @@ const PAYLOADS = { proxyScore: 12, vpnScore: 88, ip: '1.2.3.4', flags: [{ key: 'timezone', flagged: true }, { key: 'blocklist.vpn', flagged: false }], }, + 'persona:finished': { + country: 'JP', + grade: 'C', + score: 0.7234, + counts: { + total: 18, scored: 12, match: 9, mismatch: 2, + unnatural: 0, leak: 1, unknown: 3, notApplicable: 3, + }, + results: [ + // Detail objects ride along on the live result and must not reach + // the report — the visitor's bank and resolved position are in there. + { + id: 'payment-region', axis: 'match', visibility: 'risk-engine', verdict: 'mismatch', + detail: { expected: 'JP', actual: 'SG', cardIssuer: 'EXAMPLE BANK', cardTier: 'PLATINUM' }, + }, + { + id: 'gps-location', axis: 'leak', visibility: 'probed', verdict: 'leak', + detail: { expected: 'JP', actual: 'IT', accuracyMetres: 30 }, + }, + { id: 'nonsense', axis: 'bogus', verdict: 'match' }, + ], + }, 'enhanceddnsleak:finished': { rawCount: 12, resolverCount: 3, queries: [ @@ -269,6 +291,41 @@ describe('cleaning rules', () => { assert.equal(allDo.dnssec, 'ok'); }); + it('persona keeps verdicts only — never the detail behind them', () => { + const { build } = REPORT_EVENT_BUILDERS['persona:finished']; + const section = build(PAYLOADS['persona:finished']); + // Rows with an axis the schema doesn't know are dropped outright. + assert.deepEqual(section.results, [ + { id: 'payment-region', axis: 'match', verdict: 'mismatch' }, + { id: 'gps-location', axis: 'leak', verdict: 'leak' }, + ]); + // No row carries anything beyond those three keys. + for (const row of section.results) { + assert.deepEqual(Object.keys(row).sort(), ['axis', 'id', 'verdict']); + } + // The serialized section mentions neither the bank nor the position. + const serialized = JSON.stringify(section); + assert.ok(!serialized.includes('EXAMPLE BANK')); + assert.ok(!serialized.includes('accuracyMetres')); + assert.equal(section.country, 'JP'); + assert.equal(section.score, 72, 'the 0..1 score is stored the way the tool shows it'); + }); + + it('persona reports an ungraded run with a null score', () => { + const { build } = REPORT_EVENT_BUILDERS['persona:finished']; + const section = build({ + ...PAYLOADS['persona:finished'], grade: 'unknown', score: null, + }); + assert.equal(section.grade, 'unknown'); + assert.equal(section.score, null); + }); + + it('persona rejects a payload with no usable verdicts', () => { + const { build } = REPORT_EVENT_BUILDERS['persona:finished']; + assert.equal(build({ grade: 'A', results: [] }), null); + assert.equal(build({ grade: 'nope', results: PAYLOADS['persona:finished'].results }), null); + }); + it('invisibility requires both scores', () => { const { build } = REPORT_EVENT_BUILDERS['invisibility:result']; assert.equal(build({ proxyScore: 10 }), null); diff --git a/tests/report-export.test.js b/tests/report-export.test.js index 7636ceeb7..36764d284 100644 --- a/tests/report-export.test.js +++ b/tests/report-export.test.js @@ -18,6 +18,20 @@ import { REPORT_SECTION_IDS, REPORT_VERSION, validateReport } from '../common/re const t = (key, params) => (params ? `${key}[${Object.values(params).join(',')}]` : key); const makeSections = () => ({ + persona: { + testedAt: '2026-07-14T08:20:00.000Z', + country: 'JP', + grade: 'C', + score: 72, + counts: { + total: 18, scored: 12, match: 9, mismatch: 2, + unnatural: 0, leak: 1, unknown: 3, notApplicable: 3, + }, + results: [ + { id: 'ip-country', axis: 'match', verdict: 'match' }, + { id: 'gps-location', axis: 'leak', verdict: 'leak' }, + ], + }, ipinfo: { testedAt: '2026-07-14T08:00:00.000Z', cards: [{ source: 'IPCheck.ing IPv4', ip: '1.2.3.4', countryCode: 'US', city: 'LA', timezone: 'America/Los_Angeles', asn: 'AS15169', isp: 'Google' }], @@ -115,6 +129,21 @@ describe('reportToMarkdown', () => { origin: 'ipcheck.ing', }); + it('renders the persona section as verdicts, carrying no detail values', () => { + const report = buildShareReport({ + sections: makeSections(), + selectedIds: ['persona'], + maskTail: false, + locale: 'en', + origin: 'ipcheck.ing', + }); + const md = reportToMarkdown(report, t); + assert.ok(md.includes('| ip-country | match | match |')); + assert.ok(md.includes('| gps-location | leak | leak |')); + assert.ok(md.includes('grade: C')); + assert.ok(md.includes('score: 72/100')); + }); + it('renders heading, intro, per-section blocks and the closing instruction', () => { const md = reportToMarkdown(makeReport(), t); assert.ok(md.startsWith('# report.ai.Heading')); diff --git a/tests/report-schema.test.js b/tests/report-schema.test.js index 2255fefb0..0b9b2aa99 100644 --- a/tests/report-schema.test.js +++ b/tests/report-schema.test.js @@ -108,6 +108,21 @@ const makeValidReport = () => ({ dnssec: 'partial', queries: [{ ip: '8.8.8.8', countryCode: 'US', asn: 'AS15169', org: 'Google', transport: 'udp', ecs: '1.2.3.0/24', do: true, cd: false }], }, + persona: { + testedAt: '2026-07-14T08:00:00.000Z', + country: 'JP', + grade: 'C', + score: 72, + counts: { + total: 18, scored: 12, match: 9, mismatch: 2, + unnatural: 0, leak: 1, unknown: 3, notApplicable: 3, + }, + results: [ + { id: 'ip-country', axis: 'match', verdict: 'match' }, + { id: 'gps-location', axis: 'leak', verdict: 'leak' }, + { id: 'accept-language-header', axis: 'coherence', verdict: 'not-applicable' }, + ], + }, }, }); @@ -263,7 +278,7 @@ describe('constants', () => { assert.deepEqual(REPORT_TTL_DAYS, [1, 3, 7]); assert.ok(REPORT_SECTION_IDS.includes('ipinfo')); assert.ok(REPORT_SECTION_IDS.includes('enhanceddnsleak')); - assert.equal(REPORT_SECTION_IDS.length, 11); + assert.equal(REPORT_SECTION_IDS.length, 12); }); }); From 8d55118a78a932c79b0202def8b30002808dac3b Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 19 Aug 2026 10:37:18 +0800 Subject: [PATCH 25/68] Add shortcut to persona check --- frontend/composables/use-shortcuts.js | 6 ++++++ frontend/locales/en.json | 3 ++- frontend/locales/fr.json | 3 ++- frontend/locales/ru.json | 3 ++- frontend/locales/zh.json | 3 ++- tests/composable-shortcuts.test.js | 8 +++++++- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index beaa4c7cb..dd322c324 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -219,6 +219,12 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => { action: () => goToAdvancedTool('enhanceddnsleaktest', 'EnhancedDnsLeakTest'), description: t('shortcutKeys.EnhancedDnsLeakTest'), }); + // Uppercase P: lowercase `p` belongs to Earth Online. + config.push({ + keys: 'P', + action: () => goToAdvancedTool('personacheck', 'PersonaCheck'), + description: t('shortcutKeys.PersonaCheck'), + }); } if (isPulseEnabled) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index bacbcf6a3..111e98ee3 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1409,7 +1409,8 @@ "MacChecker": "Open MAC lookup panel", "BrowserInfo": "Open Browser Info panel", "fullScreenAdvancedTools": "Full Screen Advanced Tools", - "SecurityChecklist": "Open Security Checklist panel" + "SecurityChecklist": "Open Security Checklist panel", + "PersonaCheck": "Open Persona Check panel" }, "page": { "title": "IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 4e9ef9d17..5d4adc937 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1409,7 +1409,8 @@ "MacChecker": "Ouvrir le Recherche de MAC", "BrowserInfo": "Ouvrir l'Info du navigateur", "fullScreenAdvancedTools": "Outils avancés en plein écran", - "SecurityChecklist": "Ouvrir la Liste de sécurité" + "SecurityChecklist": "Ouvrir la Liste de sécurité", + "PersonaCheck": "Ouvrir le panneau Vérification de persona" }, "page": { "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Test de fuite IP - Test de fuite DNS - Vérification de la qualité IP - Test de vitesse réseau - Jason Ng Open Source", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index cbc63b09d..a6c96f157 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1409,7 +1409,8 @@ "MacChecker": "Открыть панель поиска MAC-адреса", "BrowserInfo": "Открыть панель сведений о браузере", "fullScreenAdvancedTools": "Расширенные инструменты во весь экран", - "SecurityChecklist": "Открыть панель контрольного списка безопасности" + "SecurityChecklist": "Открыть панель контрольного списка безопасности", + "PersonaCheck": "Открыть панель проверки цифрового портрета" }, "page": { "title": "IPCheck.ing — проверка IP-адреса и геолокации — тест утечки IP и DNS — качество IP — скорость сети — проект Jason Ng с открытым кодом", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index d32759e98..e66db786a 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1409,7 +1409,8 @@ "MacChecker": "打开物理地址查询面板", "BrowserInfo": "打开浏览器信息面板", "fullScreenAdvancedTools": "全屏展开高级工具面板", - "SecurityChecklist": "打开安全检查清单" + "SecurityChecklist": "打开安全检查清单", + "PersonaCheck": "打开身份画像检测面板" }, "page": { "title": "IPCheck.ing - 查看我的 IP 地址及归属地 - IP 泄露检测 - DNS 泄露检测 - IP 质量检查 - 网速测试 - Jason Ng 阿禅开源作品", diff --git a/tests/composable-shortcuts.test.js b/tests/composable-shortcuts.test.js index 807961e21..66a9b10c8 100644 --- a/tests/composable-shortcuts.test.js +++ b/tests/composable-shortcuts.test.js @@ -148,9 +148,10 @@ describe('useShortcuts()', () => { assert.ok(distinctKeys.has('o')); assert.ok(distinctKeys.has('H')); assert.equal(distinctKeys.has('p'), false, 'pulse shortcut stays off when isPulseEnabled is false'); + assert.equal(distinctKeys.has('P'), false, 'persona shortcut stays off a self-hosted instance'); }); - it('originalSite=true adds invisibility ("i") and enhanced DNS-leak ("D") shortcuts', () => { + it('originalSite=true adds invisibility ("i"), enhanced DNS-leak ("D") and persona ("P") shortcuts', () => { const { keyMap, calls } = loadAndGetKeyMap({ originalSite: true }); const hasInvisibility = keyMap.some((e) => e.keys === 'i'); assert.ok(hasInvisibility, 'key "i" should be present on originalSite'); @@ -158,6 +159,11 @@ describe('useShortcuts()', () => { assert.ok(D, 'key "D" should be present on originalSite'); D.action(); assert.deepEqual(calls.advancedNavigate, ['enhanceddnsleaktest']); + // Uppercase P is its own key — lowercase p is Earth Online's. + const P = keyMap.findLast((e) => e.keys === 'P'); + assert.ok(P, 'key "P" should be present on originalSite'); + P.action(); + assert.deepEqual(calls.advancedNavigate, ['enhanceddnsleaktest', 'personacheck']); }); it('"R" action triggers store.setRefreshEveryThing(true)', () => { From 337d03fb8a1331b9eb91c0b2170b1ca537dccbd6 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 19 Aug 2026 11:09:48 +0800 Subject: [PATCH 26/68] Improvements --- frontend/data/changelog.json | 8 ++++---- frontend/locales/en.json | 4 ++-- frontend/locales/fr.json | 4 ++-- frontend/locales/privacy/en.json | 4 ++-- frontend/locales/privacy/fr.json | 4 ++-- frontend/locales/privacy/ru.json | 4 ++-- frontend/locales/privacy/zh.json | 4 ++-- frontend/locales/ru.json | 4 ++-- frontend/locales/zh.json | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 219329cee..0f9d6cf60 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1602,10 +1602,10 @@ { "type": "add", "change": { - "en": "New tool: Persona Check — see how much of what sites read agrees with the country you want to be taken for", - "zh": "新增工具:身份画像检测——看看网站能读到的信息,有多少和你想被当成的国家对得上", - "fr": "Nouvel outil : Vérification de persona — voyez dans quelle mesure ce que lisent les sites correspond au pays pour lequel vous voulez passer", - "ru": "Новый инструмент: проверка цифрового портрета — насколько то, что видят сайты, совпадает со страной, за жителя которой вы хотите себя выдавать" + "en": "New tool: In-depth Persona Check — see how much of what sites read agrees with the country you want to be taken for", + "zh": "新增工具:深度画像检测——看看网站能读到的信息,有多少和你想被当成的国家对得上", + "fr": "Nouvel outil : Vérification de persona approfondie — voyez dans quelle mesure ce que lisent les sites correspond au pays pour lequel vous voulez passer", + "ru": "Новый инструмент: углублённая проверка портрета — насколько то, что видят сайты, совпадает со страной, за жителя которой вы хотите себя выдавать" } }, { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 111e98ee3..e032b9f80 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -581,7 +581,7 @@ } }, "personacheck": { - "Title": "Persona Check", + "Title": "In-depth Persona Check", "Note": "There is an old test: if it looks like a duck, swims like a duck and quacks like a duck, it probably is a duck. Sites read you the same way — your IP may claim you are in country A, but your time zone, languages, installed fonts, keyboard and a dozen other signals each tell their own story, and together they are the persona a site actually sees. This tool measures that persona, compares it against the local resident you want to be read as, and shows exactly where the two diverge.", "NoteVs": "It asks a different question than the Invisibility Test. That one checks whether your proxy itself can be detected; this one checks who sites think they are looking at — and how far that picture is from the one you expect to present.", "Note2": "Start by choosing the country you want to be read as a local of.", @@ -1410,7 +1410,7 @@ "BrowserInfo": "Open Browser Info panel", "fullScreenAdvancedTools": "Full Screen Advanced Tools", "SecurityChecklist": "Open Security Checklist panel", - "PersonaCheck": "Open Persona Check panel" + "PersonaCheck": "Open In-depth Persona Check panel" }, "page": { "title": "IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 5d4adc937..e2e3454d5 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -545,7 +545,7 @@ } }, "personacheck": { - "Title": "Vérification de persona", + "Title": "Vérification de persona approfondie", "Note": "Un vieux test dit : si ça ressemble à un canard, nage comme un canard et cancane comme un canard, c'est probablement un canard. Les sites vous lisent de la même façon — votre IP peut prétendre que vous êtes dans le pays A, mais votre fuseau horaire, vos langues, vos polices installées, votre clavier et une dizaine d'autres signaux racontent chacun leur propre histoire, et c'est leur ensemble qui forme le persona qu'un site voit réellement. Cet outil mesure ce persona, le compare au résident local que vous voulez incarner et montre précisément où les deux divergent.", "NoteVs": "Il répond à une autre question que le test d'invisibilité : celui-ci vérifie si votre proxy peut être détecté ; ici, il s'agit de savoir qui les sites croient voir — et à quelle distance ce portrait se trouve de celui que vous pensez présenter.", "Note2": "Commencez par choisir le pays dont vous voulez avoir l'air d'être un habitant.", @@ -1410,7 +1410,7 @@ "BrowserInfo": "Ouvrir l'Info du navigateur", "fullScreenAdvancedTools": "Outils avancés en plein écran", "SecurityChecklist": "Ouvrir la Liste de sécurité", - "PersonaCheck": "Ouvrir le panneau Vérification de persona" + "PersonaCheck": "Ouvrir le panneau Vérification de persona approfondie" }, "page": { "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Test de fuite IP - Test de fuite DNS - Vérification de la qualité IP - Test de vitesse réseau - Jason Ng Open Source", diff --git a/frontend/locales/privacy/en.json b/frontend/locales/privacy/en.json index f40fc0809..ddbdb8a2c 100644 --- a/frontend/locales/privacy/en.json +++ b/frontend/locales/privacy/en.json @@ -27,9 +27,9 @@ ] }, "personaCheck": { - "title": "Persona Check", + "title": "In-depth Persona Check", "paragraphs": [ - "Persona Check is the one tool whose measurements leave your browser. The others compute in your browser or look something up and show it only to you; this one sends what it collected to our own API, which scores it against the country you picked and returns the graded report to you. It requires signing in, and each run counts against your monthly allowance. Neither what you send nor the report you get back is stored — unless you explicitly bundle the result into a shareable report link, and even then only the grade and each check's verdict travel into it, never the values behind them.", + "In-depth Persona Check is the one tool whose measurements leave your browser. The others compute in your browser or look something up and show it only to you; this one sends what it collected to our own API, which scores it against the country you picked and returns the graded report to you. It requires signing in, and each run counts against your monthly allowance. Neither what you send nor the report you get back is stored — unless you explicitly bundle the result into a shareable report link, and even then only the grade and each check's verdict travel into it, never the values behind them.", "What travels with a run:" ], "bullets": [ diff --git a/frontend/locales/privacy/fr.json b/frontend/locales/privacy/fr.json index e67266b51..18b8b5552 100644 --- a/frontend/locales/privacy/fr.json +++ b/frontend/locales/privacy/fr.json @@ -27,9 +27,9 @@ ] }, "personaCheck": { - "title": "Vérification de persona", + "title": "Vérification de persona approfondie", "paragraphs": [ - "La Vérification de persona est le seul outil dont les mesures quittent votre navigateur. Les autres calculent dans votre navigateur ou effectuent une recherche affichée à vous seul ; celui-ci envoie ce qu'il a collecté à notre propre API, qui l'évalue par rapport au pays que vous avez choisi et vous renvoie le rapport noté. Il nécessite une connexion, et chaque exécution consomme une unité de votre quota mensuel. Ni ce que vous envoyez ni le rapport reçu ne sont conservés — sauf si vous choisissez explicitement de regrouper le résultat dans un lien de rapport partageable ; et même alors, seuls la note et le verdict de chaque vérification y figurent, jamais les valeurs qui les sous-tendent.", + "La Vérification de persona approfondie est le seul outil dont les mesures quittent votre navigateur. Les autres calculent dans votre navigateur ou effectuent une recherche affichée à vous seul ; celui-ci envoie ce qu'il a collecté à notre propre API, qui l'évalue par rapport au pays que vous avez choisi et vous renvoie le rapport noté. Il nécessite une connexion, et chaque exécution consomme une unité de votre quota mensuel. Ni ce que vous envoyez ni le rapport reçu ne sont conservés — sauf si vous choisissez explicitement de regrouper le résultat dans un lien de rapport partageable ; et même alors, seuls la note et le verdict de chaque vérification y figurent, jamais les valeurs qui les sous-tendent.", "Ce qui accompagne une exécution :" ], "bullets": [ diff --git a/frontend/locales/privacy/ru.json b/frontend/locales/privacy/ru.json index baf6755ea..15682b2fc 100644 --- a/frontend/locales/privacy/ru.json +++ b/frontend/locales/privacy/ru.json @@ -27,9 +27,9 @@ ] }, "personaCheck": { - "title": "Проверка цифрового портрета", + "title": "Углублённая проверка портрета", "paragraphs": [ - "Проверка цифрового портрета — единственный инструмент, чьи измерения покидают ваш браузер. Остальные считают прямо в браузере или запрашивают данные, которые показываются только вам; этот отправляет собранное в наш собственный API, где оно оценивается относительно выбранной вами страны, а готовый отчёт возвращается вам. Инструмент требует входа в аккаунт, и каждый запуск расходует единицу месячного лимита. Ни отправленные данные, ни полученный отчёт не сохраняются — если только вы сами не соберёте результат в общедоступную ссылку на отчёт; но и тогда в неё попадают лишь оценка и вердикт каждой проверки, но не стоящие за ними значения.", + "Углублённая проверка портрета — единственный инструмент, чьи измерения покидают ваш браузер. Остальные считают прямо в браузере или запрашивают данные, которые показываются только вам; этот отправляет собранное в наш собственный API, где оно оценивается относительно выбранной вами страны, а готовый отчёт возвращается вам. Инструмент требует входа в аккаунт, и каждый запуск расходует единицу месячного лимита. Ни отправленные данные, ни полученный отчёт не сохраняются — если только вы сами не соберёте результат в общедоступную ссылку на отчёт; но и тогда в неё попадают лишь оценка и вердикт каждой проверки, но не стоящие за ними значения.", "Что уходит вместе с запуском:" ], "bullets": [ diff --git a/frontend/locales/privacy/zh.json b/frontend/locales/privacy/zh.json index eb462c80c..b06bd52a5 100644 --- a/frontend/locales/privacy/zh.json +++ b/frontend/locales/privacy/zh.json @@ -27,9 +27,9 @@ ] }, "personaCheck": { - "title": "身份画像检测", + "title": "深度画像检测", "paragraphs": [ - "身份画像检测是本站唯一会把测量结果发出浏览器的工具。其他工具都在你的浏览器里完成计算,或查询后只展示给你;这一项会把它收集到的信息发送到我们自己的 API,由其对照你选择的国家进行评分,并把评级报告返回给你。该工具需要登录后使用,每次检测会消耗一次每月额度。你发送的内容和收到的报告都不会被保存——除非你主动把结果打包成可分享的报告链接;即便如此,进入链接的也只有评级和每项检查的结论,而不包含它们背后的具体数值。", + "深度画像检测是本站唯一会把测量结果发出浏览器的工具。其他工具都在你的浏览器里完成计算,或查询后只展示给你;这一项会把它收集到的信息发送到我们自己的 API,由其对照你选择的国家进行评分,并把评级报告返回给你。该工具需要登录后使用,每次检测会消耗一次每月额度。你发送的内容和收到的报告都不会被保存——除非你主动把结果打包成可分享的报告链接;即便如此,进入链接的也只有评级和每项检查的结论,而不包含它们背后的具体数值。", "一次检测会随请求发送的内容:" ], "bullets": [ diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index a6c96f157..d02468097 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -581,7 +581,7 @@ } }, "personacheck": { - "Title": "Проверка цифрового портрета", + "Title": "Углублённая проверка портрета", "Note": "Есть старый тест: если что-то выглядит как утка, плавает как утка и крякает как утка — скорее всего, это утка. Сайты читают вас так же: IP может заявлять, что вы в стране A, но часовой пояс, языки, установленные шрифты, клавиатура и десяток других сигналов рассказывают каждый свою историю, и вместе они образуют портрет, который сайт видит на самом деле. Этот инструмент измеряет этот портрет, сравнивает его с местным жителем, за которого вы хотите сойти, и показывает, где именно они расходятся.", "NoteVs": "Он отвечает на другой вопрос, чем тест на невидимость: тот проверяет, можно ли обнаружить сам прокси; здесь же вопрос в том, каким человеком вы выглядите для сайтов — и насколько это далеко от того образа, который вы рассчитываете показывать.", "Note2": "Начните с выбора страны, местным жителем которой вы хотите выглядеть.", @@ -1410,7 +1410,7 @@ "BrowserInfo": "Открыть панель сведений о браузере", "fullScreenAdvancedTools": "Расширенные инструменты во весь экран", "SecurityChecklist": "Открыть панель контрольного списка безопасности", - "PersonaCheck": "Открыть панель проверки цифрового портрета" + "PersonaCheck": "Открыть панель углублённой проверки портрета" }, "page": { "title": "IPCheck.ing — проверка IP-адреса и геолокации — тест утечки IP и DNS — качество IP — скорость сети — проект Jason Ng с открытым кодом", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index e66db786a..1cce8584a 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -545,7 +545,7 @@ } }, "personacheck": { - "Title": "身份画像检测", + "Title": "深度画像检测", "Note": "有一个经典的判断法:如果它长得像鸭子、游起来像鸭子、叫起来也像鸭子,那它多半就是鸭子。网站也是这样看你的——IP 可以声称你在 A 国,但时区、语言、已安装字体、键盘布局等十几个信号各自都在讲述自己的故事,它们合在一起才是网站真正看到的那个「你」。本工具测量这个真实呈现的身份画像,与你希望被看作的那个本地人做对照,并告诉你两者的差距具体在哪里。", "NoteVs": "它和隐身测试(Invisibility Test)回答的是不同的问题:隐身测试判断你的代理本身是否会被识别出来;本工具判断的是——在网站眼里你是一个什么样的人,以及这个画像和你预期呈现的差了多少。", "Note2": "先选择你希望被看作哪个国家的本地人。", @@ -1410,7 +1410,7 @@ "BrowserInfo": "打开浏览器信息面板", "fullScreenAdvancedTools": "全屏展开高级工具面板", "SecurityChecklist": "打开安全检查清单", - "PersonaCheck": "打开身份画像检测面板" + "PersonaCheck": "打开深度画像检测面板" }, "page": { "title": "IPCheck.ing - 查看我的 IP 地址及归属地 - IP 泄露检测 - DNS 泄露检测 - IP 质量检查 - 网速测试 - Jason Ng 阿禅开源作品", From 020053ee564e894e0f32ee19ed83e4dff62b0f1c Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 19 Aug 2026 11:33:39 +0800 Subject: [PATCH 27/68] Improvements --- AGENTS.md | 4 +- .../advanced-tools/PersonaReport.vue | 118 +++++++++++++----- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b0a3cde4..972977c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,9 @@ use npm / yarn — they'd produce a competing lockfile. ### Comments -- **Every new file opens with a header comment** stating its purpose. +- **Every new file opens with a header comment** stating its purpose — + except `frontend/components/ui/`, which holds shadcn-vue CLI output kept + verbatim so it can be re-synced (see frontend/AGENTS.md). - **Large templates / functions carry block comments** per meaningful region. - **Comments describe the code as it is now** — no changelog narration (`previously…`, `…fixes that`); git history covers the past. A comment diff --git a/frontend/components/advanced-tools/PersonaReport.vue b/frontend/components/advanced-tools/PersonaReport.vue index e963f0c4c..729000b4c 100644 --- a/frontend/components/advanced-tools/PersonaReport.vue +++ b/frontend/components/advanced-tools/PersonaReport.vue @@ -17,7 +17,7 @@ @@ -56,7 +56,7 @@
+ :style="{ width: `${entry.percent}%` }" />
@@ -66,7 +66,7 @@
-

{{ t('personacheck.report.nothingActionable') }}

@@ -81,7 +81,7 @@ {{ t('personacheck.dependencies.measured', { - n: report.counts.scored, total: report.counts.total }) }} + n: measured.scored, total: measured.total }) }}
@@ -92,8 +92,8 @@ - {{ t(`personacheck.checks.${row.id}.title`) }} + :title="row.known ? t(`personacheck.checks.${row.id}.title`) : row.id"> + {{ row.known ? t(`personacheck.checks.${row.id}.title`) : row.id }} {{ t(`personacheck.report.state.${row.verdict}`) }} @@ -103,13 +103,13 @@ - + {{ t(`personacheck.axis.${row.axis}`) }} -

+

{{ t(`personacheck.checks.${row.id}.what`) }}

@@ -160,8 +160,9 @@ -