diff --git a/README.md b/README.md index 0a00d94c8..acdcac158 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 As an open source project, I'm very grateful to the following sponsors for their support: + + 1Password diff --git a/README_FR.md b/README_FR.md index a786b1d71..6dbcbd6ce 100644 --- a/README_FR.md +++ b/README_FR.md @@ -177,6 +177,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 En tant que projet open source, je suis très reconnaissant aux sponsors suivants pour leur soutien : + + 1Password diff --git a/README_PT-BR.md b/README_PT-BR.md index 723d4e601..0965101a7 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -177,6 +177,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 Como projeto de código aberto, sou muito grato aos seguintes patrocinadores pelo apoio: + + 1Password diff --git a/README_RU.md b/README_RU.md index 725382ee4..54d2605d1 100644 --- a/README_RU.md +++ b/README_RU.md @@ -177,6 +177,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 Как автор проекта с открытым исходным кодом, я очень благодарен следующим спонсорам за поддержку: + + 1Password diff --git a/README_ZH-TW.md b/README_ZH-TW.md index 67601a3ab..107ba9aa3 100644 --- a/README_ZH-TW.md +++ b/README_ZH-TW.md @@ -177,6 +177,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 作為一個開源專案,我非常感謝以下贊助者對我的支持: + + 1Password diff --git a/README_ZH.md b/README_ZH.md index 3d18a6dee..beeb33c54 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -177,6 +177,8 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 作为一个开源项目,我非常感谢以下赞助者对我的支持: + + 1Password diff --git a/common/report-schema.js b/common/report-schema.js index c0a45ae84..00c3fd600 100644 --- a/common/report-schema.js +++ b/common/report-schema.js @@ -8,7 +8,10 @@ import { isValidIP, isIPv6, isValidDomain } from './valid-ip.js'; -export const REPORT_VERSION = 1; +// Bumped on breaking section changes; a viewer only renders its own version +// (older links show the unsupported state — reports live at most 7 days). +// 2: ipinfo cards' isProxy / proxy* fields became anonymity / anonymity*. +export const REPORT_VERSION = 2; // TTL choices offered at creation time (days) — shared by the share dialog // and the backend. The first entry (1 day) is the default AND the value the @@ -21,6 +24,14 @@ export const REPORT_TTL_DAYS = [1, 3, 7]; // bounding what one KV key can cost. KV's own value limit (25MB) is far away. export const REPORT_MAX_BYTES = 256 * 1024; +// Anonymity verdict codes for ipinfo cards — made by +// utils/transform-ip-data.js, shared by the builder whitelist and the schema +// enum below. +export const ANONYMITY_CODES = [ + 'tor', 'relay', 'residential', 'suspected_residential', + 'proxy', 'proxy_suspected', 'vpn', 'vpn_suspected', 'none', +]; + // Connectivity status codes — single source shared by the recording component // (ConnectivityTest.vue), the builder whitelist (utils/report-builders.js) // and the schema enum below, so a rename can't drift them apart. @@ -142,13 +153,13 @@ const SECTION_SPECS = { isp: opt(str(128)), // IPCheck.ing-source enrichments — absent on other sources, when // the upstream sign-in-gates them, or when the value is 'unknown' - // (an unknown verdict carries no diagnostic signal). - isProxy: opt(oneOf('yes', 'maybe', 'no')), + // (an unknown line type carries no diagnostic signal). + anonymity: opt(oneOf(...ANONYMITY_CODES)), ipType: opt(oneOf('business', 'residential', 'wireless', 'hosting')), nativeIP: opt(bool()), qualityScore: opt(num(0, 100)), - proxyProtocol: opt(str(32)), - proxyProvider: opt(str(64)), + anonymityProtocol: opt(str(32)), + anonymityProvider: opt(str(64)), })), }), connectivity: obj({ diff --git a/frontend/components/Footer.vue b/frontend/components/Footer.vue index 475e083ec..8ac83f032 100644 --- a/frontend/components/Footer.vue +++ b/frontend/components/Footer.vue @@ -216,6 +216,7 @@ const acknowledgementsList = [ { name: 'Cloudflare Speedtest', link: 'https://github.com/cloudflare/speedtest' }, { name: 'DigitalOcean', link: 'https://www.digitalocean.com/?refcode=fd2634a3981b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge' }, { name: 'V.PS', link: 'https://v.ps/?utm_source=ipcheck.ing&utm_medium=acknowledgement&utm_campaign=footer' }, + { name: 'RapidProxy', link: 'https://www.rapidproxy.io/?ref=myip' }, { name: 'Sentry', link: 'https://www.sentry.io/' }, { name: '1Password', link: 'https://www.1password.com/' }, { name: 'Greptile', link: 'https://www.greptile.com/' }, diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index f70f94484..ddbbcf35c 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -258,12 +258,12 @@ const trackFetchStatus = (status) => { isp: card.isp, // IPCheck.ing-source enrichments (locale-free codes; absent on other // sources or when the field is sign-in-gated). - proxyCode: card.proxyCode, + anonymityCode: card.anonymityCode, ipTypeCode: card.ipTypeCode, isNativeIP: card.isNativeIP, qualityScore: card.qualityScore, - proxyProtocol: card.proxyProtocol, - proxyProvider: card.proxyProvider, + anonymityProtocol: card.anonymityProtocol, + anonymityProvider: card.anonymityProvider, })), }); } diff --git a/frontend/components/User.vue b/frontend/components/User.vue index fb107cd5f..c44c72c63 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -58,8 +58,9 @@ - +
@@ -75,7 +76,7 @@
{{ t(row.labelKey) }} - {{ row.used }} / {{ row.limit }} + {{ t('user.Usage.PercentUsed', { percent: row.percent }) }}
(remoteUserInfo.value?.quota?.multiplier ?? 1) > 1); +// in the locale pack; `current` badges the visitor's own tier (sponsor status +// comes from the backend's quota snapshot). +const isSponsor = computed(() => remoteUserInfo.value?.quota?.isSponsor === true); const benefitTiers = computed(() => [ { key: 'Visitor', icon: UserRound, count: 6, current: !isSignedIn.value }, { key: 'SignedIn', icon: UserRoundCheck, count: 7, current: isSignedIn.value && !isSponsor.value }, @@ -174,10 +175,9 @@ const USAGE_LABEL_KEYS = { const usageRows = computed(() => { const features = remoteUserInfo.value?.quota?.features; if (!features) return []; - return Object.entries(features).map(([key, { used, limit }]) => { - const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0; - const tone = percent >= 100 ? 'fail' : percent >= 80 ? 'ok-slow' : 'ok-fast'; - return { key, labelKey: USAGE_LABEL_KEYS[key] || key, used, limit, percent, tone }; + return Object.entries(features).map(([key, { percent, exhausted }]) => { + const tone = exhausted ? 'fail' : percent >= 80 ? 'ok-slow' : 'ok-fast'; + return { key, labelKey: USAGE_LABEL_KEYS[key] || key, percent, tone }; }); }); diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 2018f889b..c5d8f0f60 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -127,66 +127,118 @@ - -
-
-
- - {{ t('ipInfos.type') }} -
-
{{ data.type }}
-
+ +
+
+
+
+ + {{ t('ipInfos.type') }} +
+
{{ data.type }}
+
-
-
- - {{ t('ipInfos.isProxy') }} -
-
- {{ data.isProxy }} -
-
- {{ data.proxyProvider }} {{ data.proxyProtocol - && - data.proxyProtocol !== 'unknown' - ? - '· ' + data.proxyProtocol : '' }} -
-
+
+
+ + {{ t('ipInfos.anonymity.label') }} +
+
+ {{ data.anonymity }} +
+ +
+ {{ anonymityService }} +
+
-
-
- - {{ t('ipInfos.advancedData.Nativeness') }} - -
-
- - {{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') : - t('ipInfos.advancedData.NativeIPNo') }} -
-
+
+
+ + {{ t('ipInfos.advancedData.Nativeness') }} + +
+
+ + {{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') : + t('ipInfos.advancedData.NativeIPNo') }} +
+
-
-
- - {{ t('ipInfos.qualityScore') }} -
-
- - {{ t('ipInfos.qualityScoreUnknown') }} - -
- - {{ data.qualityScore }}/100 +
+
+ + {{ t('ipInfos.qualityScore') }} +
+
+ + {{ t('ipInfos.qualityScoreUnknown') }} + + + + + + +
+
+
+ + + + +
+
+
+

{{ t('ipInfos.scoreDetails.explainTitle') }}

+ +

+ +

+
+ + +
+

{{ t('ipInfos.scoreDetails.tagsTitle') }}

+
+
+
+ {{ t('ipInfos.scoreDetails.tags.' + tag.key) }} +
+ +
+
+
+
+
+
- -
-
+ + +
@@ -256,13 +308,14 @@ // Used by IPCard (homepage card grid) and QueryIP (manual IP lookup dialog). // Hero IP is NOT part of this panel — consumers render their own hero row since affordances // (copy button, etc.) differ. -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import { useMainStore } from '@/store'; -import { useI18n } from 'vue-i18n'; +import { useI18n, I18nT } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; import { toBgpPrefix } from '@/utils/bgp-prefix.js'; -import { getZoneUtcOffset, getZoneLocalTime } from '@/utils/time-utils.js'; +import { getZoneUtcOffset, getZoneLocalTime, formatIsoDate } from '@/utils/time-utils.js'; +import { buildScoreExplanation, listScoreTags } from '@/utils/ip-score-details.js'; import ASNInfo from './ASNInfo.vue'; import ASNHistory from './ASNHistory.vue'; import CountryTraffic from './CountryTraffic.vue'; @@ -281,6 +334,8 @@ import { Icon } from '@iconify/vue'; import { Activity, Building2, + Check, + ChevronDown, CornerUpRight, Equal, EqualNot, @@ -295,6 +350,7 @@ import { MapPin, ShieldCheck, SignalHigh, + X, CircleQuestionMark, Clock, FolderClock, @@ -367,13 +423,13 @@ const isGatedValue = (value) => value === 'sign_in_required' || value === 'quota // of rendering four individual gated rows. The two sentinels pick different copy. const allAdvancedLocked = computed(() => props.data.type === 'sign_in_required' && - props.data.isProxy === 'sign_in_required' && + props.data.anonymity === 'sign_in_required' && props.data.isNativeIP === 'sign_in_required' && props.data.qualityScore === 'sign_in_required' ); const allAdvancedQuotaExceeded = computed(() => props.data.type === 'quota_exceeded' && - props.data.isProxy === 'quota_exceeded' && + props.data.anonymity === 'quota_exceeded' && props.data.isNativeIP === 'quota_exceeded' && props.data.qualityScore === 'quota_exceeded' ); @@ -382,9 +438,12 @@ const showTypeBadge = computed(() => props.data.type && !isGatedValue(props.data.type) && props.data.type !== t('ipInfos.advancedData.type.unknownType') ); -const showProxyBadge = computed(() => - props.data.isProxy && !isGatedValue(props.data.isProxy) - && props.data.isProxy !== t('ipInfos.advancedData.proxyUnknown') +const showAnonymityBadge = computed(() => + Boolean(props.data.anonymity) && !isGatedValue(props.data.anonymity) +); +// "Provider · protocol"; both are null unless the API named a service. +const anonymityService = computed(() => + [props.data.anonymityProvider, props.data.anonymityProtocol].filter(Boolean).join(' · ') ); const showNativeBadge = computed(() => props.data.isNativeIP !== undefined && !isGatedValue(props.data.isNativeIP) @@ -396,7 +455,7 @@ const showQualityScore = computed(() => // Locked field preview: the 4 advanced fields shown as "label + ***" for signed-out users. const lockedFieldList = computed(() => [ { key: 'type', icon: SignalHigh, label: t('ipInfos.type') }, - { key: 'proxy', icon: ShieldCheck, label: t('ipInfos.isProxy') }, + { key: 'anonymity', icon: ShieldCheck, label: t('ipInfos.anonymity.label') }, { key: 'native', icon: House, label: t('ipInfos.advancedData.Nativeness') }, { key: 'quality', icon: Gauge, label: t('ipInfos.qualityScore') }, ]); @@ -410,6 +469,30 @@ const qualityTone = computed(() => { return 'fail'; }); +// Score-details panel (IPCheck.ing source): explanation sentences (need +// `dimensions`) and attribute rows. Both are empty for gated data, and the +// toggle only shows when either has content. +const scoreExplanation = computed(() => buildScoreExplanation(props.data, { + t, + formatDate: (day) => formatIsoDate(day, locale.value), +})); +const scoreTagRows = computed(() => listScoreTags(props.data.scoreTags)); +// Row state → icon; the question mark marks a suspected residential proxy. +const TAG_STATE_ICONS = { yes: Check, no: X, suspected: CircleQuestionMark }; +const hasScoreDetails = computed(() => + showAdvancedBlock.value && (scoreExplanation.value.length > 0 || scoreTagRows.value.length > 0) +); +// CJK sentences run together; others take a space. +const sentenceGap = computed(() => (/^(zh|ja|ko)/.test(locale.value) ? '' : ' ')); + +const isScoreDetailsOpen = ref(false); +const toggleScoreDetails = () => { + if (!isScoreDetailsOpen.value) trackEvent('IPCheck', 'ScoreDetailsClick', 'Show Score Details'); + isScoreDetailsOpen.value = !isScoreDetailsOpen.value; +}; +// A different IP starts collapsed. +watch(() => props.data.ip, () => { isScoreDetailsOpen.value = false; }); + // Consumer opt-in + a Cloudflare key on the deployment + a country to query. const canShowCountryTraffic = computed(() => props.countryTrafficMode !== 'off' && !props.collapsed diff --git a/frontend/components/report/ReportPage.vue b/frontend/components/report/ReportPage.vue index f41a640ce..610a951fa 100644 --- a/frontend/components/report/ReportPage.vue +++ b/frontend/components/report/ReportPage.vue @@ -145,7 +145,8 @@ const loadReport = async () => { // The API serves { expiresAt, report }; expiresAt is best-effort. const payload = await response.json(); const data = payload?.report; - // Older viewers can't know how to render a newer schema. + // A viewer renders only its own schema version; older and newer + // links both land in the unsupported state. if (data?.v !== REPORT_VERSION || !data.sections) { errorKey.value = 'reportPage.Unsupported'; state.value = 'error'; diff --git a/frontend/components/report/sections/ReportIpinfo.vue b/frontend/components/report/sections/ReportIpinfo.vue index 815449660..ccaf7eed2 100644 --- a/frontend/components/report/sections/ReportIpinfo.vue +++ b/frontend/components/report/sections/ReportIpinfo.vue @@ -9,7 +9,7 @@ {{ t('ipInfos.TimeZone') }} ASN {{ t('reportPage.Col.ISP') }} - {{ t('ipInfos.isProxy') }} + {{ t('ipInfos.anonymity.label') }} {{ t('ipInfos.type') }} {{ t('ipInfos.advancedData.Nativeness') }} {{ t('ipInfos.qualityScore') }} @@ -27,13 +27,13 @@ {{ card.asn || '—' }} {{ card.isp || '—' }} - -