Compare commits

...
493 Commits
Author SHA1 Message Date
jason5ng32 da04a81e58 Improvements 2026-10-29 09:46:34 +08:00
jason5ng32 e461bc168b Improvements 2026-10-29 09:46:17 +08:00
Jason Ng 863e333c53 Merge pull request #536 from jason5ng32/dev
Sync with Dev
2026-10-27 12:46:01 +08:00
jason5ng32 2d244a129d Improvements 2026-10-27 12:41:09 +08:00
Thanut JongteerathanachoteandClaude Opus 5.5 56c68364bf Feat(connectivity): add Lazada to the e-commerce import list (#535)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:54:30 +08:00
Shubham Sangle 80e6a5ca1a Feat(service-status): add Bitbucket, DigitalOcean and Reddit (#534) 2026-10-27 11:54:24 +08:00
jason5ng32 1b1cf9f68f Improvements 2026-10-27 11:47:08 +08:00
jason5ng32 31e8a5c306 Improvements 2026-10-26 00:08:51 +08:00
jason5ng32 ccd7a572a8 Improvements 2026-10-25 23:32:37 +08:00
Ayush-kukrety 61efe98d1d Docs: add Hindi README translation (#529) 2026-10-25 23:19:08 +08:00
Thanut JongteerathanachoteandClaude Opus 5.5 d68328d7bf Feat(dns-resolver): add ThaiNS for Thailand (#533)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:12:36 +08:00
jason5ng32andClaude Opus 5.5 896494d895 Refactor(ip-infos): split IpDetailPanel into advanced and ASN blocks
IpDetailPanel had grown to ~750 lines holding three independent regions.
It is now the shell (metadata grid, inline Map / Country-traffic panels,
dialogs) with unchanged props, emits and multi-root template, rendering
IpAdvancedBlock (with ScoreDetailsPanel beside ScenarioFrictionPanel) and
IpAsnBlock, which relay view-usage / open-tool through it.

The inline /api/asn-history fetch becomes loadAsnHistoryInto in
utils/ip/asn-history.js, matching the other ASN loaders, now with tests.
Markup, copy, analytics and per-IP reset behaviour are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:19:36 +08:00
jason5ng32andClaude Opus 5.5 825db1638f Chore: bump version to 8.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:12:28 +08:00
jason5ng32andClaude Opus 5.5 fd5e04fea6 Feat(ipcard): add abuse activity and scenario friction to the advanced data
The advanced block gains two signed-in fields under the existing four:
Abuse activity (the reputation level) and Scenario friction, ten usage
scenarios each rated smooth / extra checks / may be restricted. The card
shows one tone dot per scenario instead of a score, so it can't be read
as a second quality score; the details list follows the score details'
layout and closes with a Persona Check invitation.

Green and yellow only (a new light-yellow ok-mild tone for extra checks),
no reasons shown, copy in every full locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:12:28 +08:00
jason5ng32andClaude Opus 5.5 e4ec314a6a Fix(connectivity): trim the Mexico and India import lists
Drop SAT (times out from outside Mexico) and Rapido (CORP same-origin
blocks the probe), plus the India members that are corporate or B2B
sites rather than ones people open day to day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:31:35 +08:00
jason5ng32 0c3f05ca45 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-10-24 21:29:38 +08:00
Mayank Chotaliya 717356bd7c Feat(connectivity): expand the India import list (#530) 2026-10-24 21:29:30 +08:00
MarioDevandOpenAI Codex e31a9298d1 Feat(connectivity): add Mexico import list (#528)
Add a balanced set of Mexican government, telecom, banking, news, and shopping targets with localized list names and committed favicons.

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-10-24 21:29:18 +08:00
Thanut Jongteerathanachote 0446364836 Feat(adblocktest): add Truehits to the probe list (#526) 2026-10-24 21:29:07 +08:00
jason5ng32 2329294a8f Improvements 2026-10-24 09:29:34 +08:00
jason5ng32andClaude Fable 5.1 4acf56b593 Feat(footer): add a Contribute entry to the Project column
Links to the repository's GitHub /contribute page, which lists the open
good-first-issues above the contributing guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 22:03:39 +08:00
jason5ng32andClaude Fable 5.1 c0ae017f89 Feat(connectivity): invite visitors to add their country's import list
Closes the "Import a List" grid with a muted line linking to the open
country-list issue, so a visitor who finds their country missing sees
where to add it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 22:03:39 +08:00
Jason Ng 9aae391468 Merge pull request #527 from jason5ng32/dev
Sync with Dev
2026-10-23 21:41:33 +08:00
jason5ng32andClaude Opus 5.5 f6e979f52d Fix(ci): generate Docker image labels with metadata-action
Multi-line release notes in the labels input split into one label per
line and an empty line aborted buildx. Labels now come from
docker/metadata-action with the version pinned to RELEASE_VERSION, and
version inputs reach the shell through env instead of interpolation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:41:04 +08:00
Jason Ng 1a9988388c Merge pull request #525 from jason5ng32/dev
Sync with Dev
2026-10-23 21:16:54 +08:00
jason5ng32 720c4a1a64 Improvements 2026-10-23 21:09:41 +08:00
Thanut Jongteerathanachote 4b8cceff4d Docs: add Thai README translation (#524) 2026-10-23 21:08:02 +08:00
Thanut Jongteerathanachote 5321720c5f Feat(connectivity): add Thailand import list (#523) 2026-10-23 21:07:48 +08:00
jason5ng32 13af650719 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-10-23 21:01:19 +08:00
Mark David PradoandMark David Prado 72a656d18b Feat(adblocktest): add HubSpot to the probe list (#518)
Co-authored-by: Mark David Prado <rqms40@users.noreply.github.com>
2026-10-23 21:00:50 +08:00
jason5ng32andClaude Opus 5.5 a98a6c812f Feat(service-status): add Perplexity, Canva and Docker
Reposition the tool from AI services to well-known internet products:
the Note and the tool card copy change in every full locale, with a
v8.0.0 changelog entry. Perplexity and Docker read the incident.io
structure; Canva is a plain Statuspage row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:59:34 +08:00
jason5ng32andClaude Opus 5.5 a79e88cbb1 Feat(service-status): add per-provider component rename
A `components.rename: { <id>: '<label>' }` selector relabels upstream rows
by component id after selection, so include / exclude keep matching the
upstream names. npm's "www.npmjs.com website" row now reads "Official
Website".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:51 +08:00
anux 6fcaadc751 Feat(service-status): add npm (#516)
npm's status page (status.npmjs.org) runs on Atlassian Statuspage and serves
both /api/v2/summary.json and /api/v2/incidents.json, so it is one provider
row plus its card in ServiceStatus.vue. Its six components are all real
services, so no exclude list is needed.

Refs #513
2026-10-23 20:46:37 +08:00
Jason Ng 29bdac244c Merge pull request #522 from jason5ng32/fix/guard-query-rewrites
Fix(api): make guard query rewrites survive Express 5
2026-10-23 20:31:16 +08:00
jason5ng32andClaude Opus 5.5 e766bd84ce Fix(api): make guard query rewrites survive Express 5
Express 5 exposes req.query through a getter that re-parses the URL on
every read, so the guards' in-place normalization was silently dropped:
?type=caa reached dns-resolver lowercase, ?asn=AS13335 reached the ASN
handlers unparsed, ?q=as13335 missed whois's AS<n> branch. Guards now pin
the parsed query as an own property before rewriting it, and a regression
block exercises them on a real Express request.

Fixes #521

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:24:56 +08:00
Jason Ng 88bb4fdaef Merge pull request #519 from jason5ng32/dev
Sync with Dev
2026-10-23 11:29:15 +08:00
jason5ng32 7fbc782f1d Improvements 2026-10-23 11:24:02 +08:00
jason5ng32andClaude Fable 5.1 9e4a0f5cb6 Improvements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:39:44 +08:00
jason5ng32andClaude Fable 5.1 da1f3fe952 Improvements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:37:55 +08:00
jason5ng32andClaude Fable 5.1 4c9ab04bc5 Improvements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:37:55 +08:00
Jason Ng b7cf99fc36 Merge pull request #512 from jason5ng32/dev
Sync with Dev
2026-10-22 11:24:25 +08:00
ylfeid f9f24ca707 Docs(readme): add Albanian README translation (#511) 2026-10-22 11:08:41 +08:00
jason5ng32 11b4160767 Improvements 2026-10-22 11:06:30 +08:00
jason5ng32 9ee262fdc3 Improvements 2026-10-22 10:25:46 +08:00
ylfeid 24380159a4 Feat(connectivity): add Albania import list (#510) 2026-10-22 10:23:32 +08:00
ylfeid 5ecd2ed089 Feat(connectivity): add Kosovo import list (#509) 2026-10-22 10:23:25 +08:00
jason5ng32andClaude Fable 5.1 c350f0d2d9 Feat(ruletest): test twelve proxy rules, not eight
Four more ptest workers are live, so the Rule Test grid grows to
twelve cards and the README rule snippets cover them. The note above
the grid used to spell out the count in every locale; it now takes
the number from the card list, so the next change in count touches
one line. The CrossingTheWall rule keeps its eight-IP threshold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 00:10:16 +08:00
jason5ng32andClaude Fable 5.1 938dca96dd Fix(service-status): pick sub-services that say something
Supabase's card showed one region and Cloudflare's read "major
outage" on every visit. Supabase and five other providers are
incident.io pages, whose Statuspage-compatible summary flattens
groups away and, for Supabase, returns one component of 189; those
providers now also read the native /proxy/<host> structure and list
its top-level groups, each rolled up to its worst visible child.
Cloudflare's top level is seven continents that go red whenever one
PoP is re-routed, so its card lists a fixed set of products instead
and takes its light from them, not from the page indicator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 23:59:50 +08:00
jason5ng32 d5452c0fce Improvements 2026-10-21 14:31:47 +08:00
jason5ng32andClaude Fable 5.1 e7bc4db293 Feat(analytics): track how the Security Checklist is read and used
GA saw only category switches and resets, so a month of visits could
not say whether anyone read an item's details or ticked one. Four
labels on the existing SecurityChecklist event — ItemInfo and
CategoryIntro when a details panel or intro opens, Check and Ignore
when an item is ticked or set aside — read as a funnel after
ChangeList. Only the positive direction fires; closing and undoing
stay silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 12:24:21 +08:00
jason5ng32andClaude Fable 5.1 49c3dfa887 Feat(tools): move the Security Checklist into a reference group
The checklist is content to read and work through, not a test that
runs, yet it sat among the network diagnostics where a card promises a
result. A fourth group, 'reference', now closes the Advanced Tools grid
and holds it alone; the group's title lands in every full locale, and
the self-hosted grouping test expects the group to survive the gates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 12:24:21 +08:00
jason5ng32 5cfb73994a Improvements 2026-10-21 11:33:54 +08:00
jason5ng32andClaude Opus 5.5 45f920248f Fix(analytics): one NavClick label per tool, sent once
Tool opens labelled NavClick with the slug's first letter capitalized
('Pingtest'), while keyboard shortcuts sent a second, hand-written label
('PingTest', 'AsnProfile') on top of the one openTool already sent. GA
labels are case-sensitive, so one tool split into two rows and every
shortcut open counted twice. The label is now the slug as-is, the same
id as /tools/<slug>, and shortcuts rely on openTool's event alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:44:56 +08:00
jason5ng32andClaude Opus 5.5 50885c6317 Fix(pulse): show cloud-origin outages and drop empty Radar events
Radar files some events against a cloud region (originDetails) rather
than a location or ASN. The feed ignored that subject, so an ongoing
ORIGIN anomaly rendered as a blank first row and escaped dedupe against
its matching outage. Origins are now mapped, shown, and matched in
dedupe; stub annotations with no subject and no text are dropped. The
new PLATFORM outage level gets a translation in every full locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:42:45 +08:00
jason5ng32andClaude Fable 5.1 7a608cb8ce Feat(footer): fold the product links into a four-column footer
The Raycast / curl / Lite badges sat in an unlabelled image row of their
own under the Advanced Tools. The footer now has four columns aligned
with that card grid — brand, Project, Docs & Support, Clients — so the
product entries get a heading and show on every route; the curl
commands keep their dialog. Docs links share one locale-aware helper.
The footer never repeats on-page content, and the fixed footer height
in style.css goes, as the columns size themselves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 18:45:09 +08:00
jason5ng32andClaude Fable 5.1 d28fe239ae Fix(nav): collapse the link row by measured width, not by isMobile
Tablets report a desktop UA, so between 768px and ~1366px the flat
link row rendered and wrapped inside its links; no single breakpoint
fits six locales. use-nav-overflow.js measures the row against the
space beside the brand and actions and collapses to the hamburger
when it doesn't fit. The row stays rendered (invisible, inert) so it
remains measurable. DocsSearch switches to its icon below xl by CSS so
the action group's width never depends on the collapse state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 17:54:46 +08:00
jason5ng32 35e691da6e Improvements 2026-10-19 23:45:41 +08:00
Jason Ng 116a74b1a0 Merge pull request #507 from jason5ng32/feat/adblock-test
Feat: Ad & Tracker Blocking Test
2026-10-19 23:23:34 +08:00
jason5ng32 072a76cae0 Improvements 2026-10-19 23:18:32 +08:00
jason5ng32andClaude Opus 5.5 4cc48d151b Fix(adblocktest): state what the probes cannot tell
A probe is a plain fetch from this page, so a filter rule aimed only at
one request type (e.g. `$script`) can let it through while still
blocking the real script on a website; a line under the list version now
says so. The empty "not blocked only" / "blocked only" views claimed
everything was (or nothing was) blocked even when results had timed out
or were still pending; they now say only that no request was confirmed
in that state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:15:51 +08:00
jason5ng32andClaude Opus 5.5 88423522e3 Feat(report): add the Ad & Tracker Blocking Test to shareable reports
A blocker setup is part of the visitor's own environment, like the
browser info and proxy-rule sections, and is worth handing to whoever
helps debug it. The section stays deliberately small: request counts per
category (blocked / not blocked / no answer / total), no per-address or
per-vendor rows, so a report stays readable and the list can change
without breaking old links. Group ids are capped strings rather than an
enum for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:04:09 +08:00
jason5ng32andClaude Opus 5.5 1163ea4a13 Docs(readme): list the Ad & Tracker Blocking Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:53:43 +08:00
jason5ng32andClaude Opus 5.5 06402ce688 Feat(adblocktest): add the Ad & Tracker Blocking Test
People running uBlock Origin, Privacy Badger, a blocking browser, or a
DNS / VPN / router filter had no way here to see what their setup lets
through. The new tool probes a static list of widely used ad, analytics,
social, error-monitoring and device-maker endpoints plus popular tracking
scripts (fetched with no-cors and no cookies or referrer, never run), and
places bait elements for cosmetic filters. Nothing runs until the visitor
starts it; every group starts selected.

A timeout reads as "no answer" and stays out of the score, and every
listed host was checked to resolve, serve valid HTTPS and not redirect to
http:// or in a loop, since any of those would fail like a block. Element
hiding is left out of the score (DNS-level filters can never pass it) and
instead tells browser-level from network-level blocking. The page copy
says outright that blocking everything is not automatically better.

Ships with the x shortcut, locale copy for every full locale and a
changelog entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:53:43 +08:00
jason5ng32andClaude Opus 5.5 c2751d37f7 Feat(ui): add the shadcn-vue Alert component
Notices across the tools are hand-rolled boxes; Alert gives new ones the
primitive instead. In this JavaScript setup the CLI imports `cn` from a
bare "cn" module and installs an unrelated npm package of that name, so
the dependency is dropped and the components import from @/lib/utils;
frontend/AGENTS.md records the trap for the next `shadcn-vue add`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:53:29 +08:00
jason5ng32andClaude Opus 5.5 b84a1d3764 Refactor(ip-infos): drop the default map images
A card only shows its map once location data has landed, so the bundled
placeholder maps were never meant to be seen, and their labels sat in one
language among many. An empty map URL now renders a same-sized muted box,
which also covers a payload with a country but no coordinates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:35:31 +08:00
jason5ng32andClaude Opus 5.5 6e40a611d3 Fix(api): require a tag-shaped language in the map proxy
Any non-empty language reached Static Maps, so every distinct value was an
uncached upstream call whose answer the edge kept for a year. A tag-shape
check still passes region-tagged UI locales (zh-TW, pt-BR) without an
allow-list, and the handler block now covers both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:35:27 +08:00
Victor SolanoandClaude Opus 5.5 64ace80bf2 Fix(api): accept region-tagged languages in the map proxy (#506)
The UI passes its locale code as the map language, and pt-BR and zh-TW are
registered locales, but /api/map only accepted a bare two-letter code, so
every map request from those two locales answered 400 and the map never
loaded. Pass the tag through like every other ?lang (api/AGENTS.md) and
URL-encode it where the Static Maps URL is built, so the value stays one
query parameter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 09:27:27 +08:00
jason5ng32andClaude Opus 5.5 79a91171f7 Refactor(probes): take chain options as an object and share version validators
runChain's optional originalSite / accept move into an options object, so a
chain that needs only one no longer passes an `undefined` placeholder.

The per-version validators become isValidIPv4 / isValidIPv6 in
common/valid-ip.js, next to isValidIP: the "Valid" prefix keeps them apart
from the non-validating isIPv6 discriminator.

Tests now run the four single-version entry points against a stubbed fetch,
so dropping or swapping a chain's validator fails the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:28:12 +08:00
14649814andAntigravity f4d90fe44c Fix(probes): strictly validate IP version on dedicated v4/v6 chains (#504)
- Require isValidIP && isIPv6 on IPv6-only probes (ipChecking6, cloudflareV6)
- Require isValidIP && !isIPv6 on IPv4-only probes (ipChecking4, cloudflareV4)
- Export isIPv4 and isOnlyIPv6 and add dedicated unit tests in getips.test.js

Co-authored-by: Antigravity <antigravity@google.com>
2026-10-15 23:25:19 +08:00
jason5ng32 70c1cbd2b9 Merge branch 'sponsors-2026-10b' into dev 2026-10-14 18:02:07 +08:00
Jason Ng 5502169374 Merge pull request #505 from jason5ng32/sponsors-2026-10b
Docs(sponsors): add Proxy-Seller and link sponsor names
2026-10-14 18:02:05 +08:00
jason5ng32andClaude Opus 5.5 547e9a1e84 Docs(sponsors): add Proxy-Seller and link sponsor names
Proxy-Seller joins the paid sponsors in all six READMEs and the footer
acknowledgement list. Each sponsor's bold name in the description line now
links to the same destination as its logo, so the copy is clickable too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:01:52 +08:00
jason5ng32 5d378c5e13 Improvements 2026-10-13 09:54:52 +08:00
jason5ng32 47dff987b1 Improvements 2026-10-13 09:48:16 +08:00
jason5ng32 6036a7c570 Merge branch 'readme-supporters-layout' into dev 2026-10-11 10:41:18 +08:00
Jason Ng da9614c87e Merge pull request #502 from jason5ng32/readme-supporters-layout
Docs(sponsors): separate supporters and stack their logos
2026-10-11 10:41:06 +08:00
jason5ng32andClaude Opus 5.5 068ea5a795 Docs(sponsors): separate supporters and stack their logos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:40:29 +08:00
Jason Ng 6211b124b0 Merge pull request #501 from jason5ng32/sponsors-2026-10
Docs(sponsors): add SwiftProxy and split paid sponsors from supporters
2026-10-10 10:25:35 +08:00
jason5ng32 e23f00a818 Merge branch 'sponsors-2026-10' into dev 2026-10-10 10:24:47 +08:00
jason5ng32andClaude Opus 5.5 2961d9ba8d Docs(sponsors): add SwiftProxy and split paid sponsors from supporters
The README listed every sponsor as an equal-size logo at the bottom, with
the only ad copy hidden in a hover-only title attribute. Paid sponsors now
get their own group with a visible one-line description under each logo;
free-plan supporters share one row of smaller logos. V.PS links carry the
README's locale in utm_content.

SwiftProxy joins the paid group and the footer acknowledgement list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:24:19 +08:00
jason5ng32 a509b99207 Improvements 2026-10-09 14:23:04 +08:00
jason5ng32 187ede91a9 Improvements 2026-10-07 15:34:08 +08:00
jason5ng32andClaude Opus 5.5 017e5c9af3 Feat(ip-infos): point score details to a live blocklist check
Score details is one of the most-opened panels, but the score is not a
live reading. A line under it links to Blocklist Check with the IP
carried as `?q=`, which the tool now prefills without running: the
visitor may be signed out or out of quota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:26:02 +08:00
jason5ng32 117ebaa65a Improvements 2026-10-07 10:03:06 +08:00
jason5ng32andClaude Opus 5.5 89a103a3b7 Fix(footer): open the About sheet only on the page on screen
Home and ToolPage are both kept alive with a Footer each, so once a tool
page had been visited, a footer link on Home opened both sheets and the
cached one, stuck on its own tab, covered the right one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:45:35 +08:00
jason5ng32 6d7221538d Improvements 2026-10-06 13:03:58 +08:00
jason5ng32 8d7b53d1de Improvements 2026-10-06 11:35:36 +08:00
Jason Ng 015d6aef7b Merge pull request #500 from jason5ng32/feat/tools-as-pages
Feat/tools as pages
2026-10-06 11:31:09 +08:00
Claude de9d3d2118 Fix(service-status): drop a detail answer from before a reload
A detail request still in flight when the overview reloads (Refresh, or
the re-pull on activation) wrote its answer back after the reload had
cleared the cache, so the reopened card skipped its fetch and showed the
older snapshot. Reload bumps an epoch; an answer from an earlier one is
discarded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 03:03:58 +00:00
Claude de6c5d730d Style: open PageBreadcrumb and UserMenu with their purpose comment
Their purpose was stated in <script setup>; it now opens the template as
in the rest of components/, with the script comment keeping only what
the markup does not say.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 02:59:39 +00:00
Claude f2d684faa8 Chore: document the ASN Profile graph's expand-to-drawer
The profile page keeps ASNConnectivity expandable; the guide, the host
card's comment and the prop's comment said otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 02:57:58 +00:00
Claude 26cb4ad15c Fix(breadcrumb): a section crumb stays a link at the end of the trail
Tool pages end their breadcrumb on Advanced Tools, as the <h1> beside it
names the tool. The crumb was rendered as the current page (aria-current,
not clickable) and its section ignored; now any crumb with a section links
to it, and only a bare last crumb is the current page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 02:57:58 +00:00
Claude 7571e0c71e Fix(service-status): re-pull the overview when shown again after a backend cycle
The tool page is kept alive, so a visitor returning after the backend's
5-minute refresh saw the status pulled at mount until they hit Refresh.
On activation, an overview older than that cycle reloads as Refresh does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 02:57:58 +00:00
Claude e39aed71e3 Fix(achievements): open the sheet when it mounts after the request
UserMenu raises the request from a tool, privacy or report page and goes
Home first. Home's Achievements is an async component: on a first visit it
can mount after the flag is already set, so the request was missed and the
flag stayed set, swallowing every later one. The watcher now runs
immediately and acts only on a raised flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 02:57:58 +00:00
jason5ng32 b616b7e00e Improvements 2026-10-06 10:48:50 +08:00
jason5ng32 8543879bbd Improvements 2026-10-06 10:33:31 +08:00
jason5ng32 78db2f8934 Improvements 2026-10-06 10:16:04 +08:00
Claude 4429020d91 Fix(nav): navigate from the mobile sheet after it has closed
WebKit records a page's back/forward snapshot as it navigates away,
so a tool or section picked from the mobile nav sheet left a snapshot
with the sheet still sliding out, which a swipe back then showed. The
sheet closes first and the navigation follows its slide-out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 01:57:12 +00:00
Claude 3e4a268ce5 Fix(ui): resolve the route in Footer's setup
useRoute() inside the computed ran inject() outside setup, which
returns undefined: Footer threw on every render and the navigation
back to the homepage lost its scroll restore (the page landed at the
top). The route is resolved once in setup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 01:45:51 +00:00
Claude 65ec652135 Refactor(auth): one auth domain per deployment
VITE_FIREBASE_AUTH_DOMAIN is each environment's own host, served by the
/__/auth proxy; the PWA-only VITE_FIREBASE_PWA_AUTH_DOMAIN and
utils/auth-domain.js go. Every OAuth provider lists the handler URL per
environment (Google and GitHub both take several redirect URIs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-06 01:38:29 +00:00
Claude ebe2c36ae3 Fix(auth): offer GitHub sign-in in the installed PWA too
GitHub OAuth apps now take several redirect URIs, with optional
wildcard matching, so the PWA's own auth domain can be registered
there like Google's; the menu no longer hides GitHub when the PWA
signs in through a different domain (signInProvidersFor and the
store getter go). The operator notes name both registrations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 17:04:44 +00:00
Claude 435d9f6413 Chore: drop dead nav locale keys, one isFireBaseSet, page-bg comment
nav.Title / PingTest / MTRTest / RuleTest had no reader in any locale.
The store takes isFireBaseSet from firebase-init.js instead of
recomputing it in checkFirebaseEnv(). The --page-bg comment no longer
claims index.html's colours must match it; they are tuned separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 17:02:50 +00:00
jason5ng32 ae52320d3b Improvements 2026-10-06 00:57:30 +08:00
Claude 96525d2fc7 Chore: trim the branch — leftovers, redundancy, comments
A pass over every file the branch touched, after the iOS animation hunt
left tries and reverts behind it. Removed what nothing reaches: the
view-transition rule for a transition no code starts, an unreachable
guard in the transition hook, a no-op KeepAlive key branch, a sign-in
guard the menu already enforces, usage-dialog wrappers that only
forwarded to the store, a duplicated modifier-click check and card
filter (isPlainClick / listedTools), a test that hand-rolled
listedTools, a drawer-era fixture parameter. auth-flow.js folded into
auth-redirect.js. Comments cut to what the code cannot say itself —
file headers of a few lines, the measured iOS reasons, non-obvious
orderings — and stale ones fixed (Nav under KeepAlive, Home.vue
reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections
tightened to the code as it is now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:50:16 +00:00
Claude 47eca1a4f6 Chore(debug): remove the ?debug=nav overlay
The stall it was built to diagnose is fixed (utils/revive-animations.js);
the overlay, its App.vue gate and the event it listened to go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:39:22 +00:00
Claude a4020442f0 Fix(ui): revive the page's CSS animations after a history step
On iOS, accelerated CSS animations running before a swipe-back stay
visually frozen once the gesture lands on the previous page: the frame
rate, the animations' own time and their iteration events all carry on,
but the compositor paints no new frame for them until their layer is
rebuilt (measured with the ?debug=nav overlay; cancel + play, a
display:none round trip or a re-insert revive them, ancestor style
changes do not). A push revives them as a side effect of its page
transition; a history step, where the browser's gesture replaces our
transition, does not. After every navigation that came from popstate,
utils/revive-animations.js cancels, replays and resets to its time each
running CSS animation, two frames after the DOM lands and again at
400ms and 1200ms in case the gesture snapshot is dismissed late.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:27:19 +00:00
Claude 2f27e62d19 Chore(debug): temporary ?debug=nav overlay for the iOS Nav animation stall
The Nav's CSS animations (Pulse's globe and ping) stall on iOS after a
swipe-back into the homepage, with KeepAlive and the page transitions
both ruled out and no WebKit available off the device. ?debug=nav
(kept in localStorage, ?debug=off clears) mounts widgets/NavDebug.vue:
live rAF rate, whether the ping animation's time advances and iterates,
reference CSS animations in a fixed panel and in the page flow, the
Nav's hidden state, a timeline of popstate (with hasUAVisualTransition)
/ route / visibility / focus / touch / scroll events, buttons that try
one candidate remedy at a time, and a Copy button for the log. Removed
once the cause is known.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:16:08 +00:00
Claude 563c55c6d8 Style: slide between pages with View Transitions
Route changes animate like native navigation: a tool page slides in
from the right, going home slides it back out, tool to tool cross-fades
— utils/page-transition.js decides the direction from the route pair
and wraps the router's DOM update in document.startViewTransition from
router/index.js, so the snapshot, the swap and the scroll restoration
happen before the animation and pages of different heights never
overlap. Nothing animates on a query-only change, under reduced motion,
on a history step the browser already animated (swipe-back), or in a
browser without the API; a navigation arriving mid-transition skips the
running one. The Nav opts out by view-transition-name and stays put
while the page moves.

Reapplied after the Nav's animation stall on iOS proved unrelated: it
reproduces with the transitions removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:10:07 +00:00
Claude 34d69f8ec9 Fix(pwa): keep the iOS status bar opaque in the installed app
Since iOS 26 the system draws its Liquid Glass scroll-edge blur over
the top of a Home Screen web app whose page runs under the status bar,
which black-translucent asked for, and the blur covered the Nav. With
no apple-mobile-web-app-status-bar-style the status bar stays opaque
system chrome above the web view; the strip Nav.vue painted under a
translucent one goes with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:06:07 +00:00
Claude fd9b042019 Revert(ui): drop the page transitions
Reverts the View Transitions hook-up, its keyframes, the Nav naming
and the two diagnostic follow-ups. On iOS the Nav's own animations
(Pulse's globe and ping) stalled after the forward transition and
stayed stalled after a swipe-back, while a transition back home
restored them — the capture of the fixed Nav is the suspect, and the
clean way to confirm it on the device is to run without any of it.

This reverts commits 2a360f0, d6d80da, 38a61fd, 156d0bf and 856f965.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:05:37 +00:00
Claude 2a360f0374 Fix(pwa): keep the status-bar strip hit-testable for iOS's edge blur check
iOS 26+ draws its scroll-edge blur over the top of a Home Screen web app
unless a hit test at the top edge finds a fixed, viewport-wide box with
a plain background colour (WebKit's LocalFrameView::fixedContainerEdges,
which honours pointer-events on its first pass). The safe-area strip is
exactly that box; it no longer opts out of hit testing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:58:12 +00:00
Claude d6d80dac24 Chore(ui): device-side switch for page transitions
`?vt=off` turns the View Transitions off, `?vt=unnamed` keeps them but
leaves the Nav un-named (it slides with the page), `?vt=default`
restores the normal behaviour; the choice lives in localStorage. WebKit
cannot run in the sandbox and the Nav's animations stall on iOS after
a transition, so this is how the trigger gets bisected on the device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:58:12 +00:00
Claude 38a61fdf92 Fix(ui): restart the Nav's animations after a page transition
WebKit leaves the accelerated CSS animations inside an element captured
under its own view-transition-name stalled once the transition is over:
after the first page transition Pulse's spinning globe and ping only
repainted in half-second jumps (measured frame by frame on iOS 26), and
a swipe back brought the stalled Nav home. Every transition now ends by
cancelling and replaying the running animations in the named elements
(restartAnimations in utils/page-transition.js), which recreates them
on the compositor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:47:43 +00:00
Claude 1c7e7c68cd Fix(auth): give the installed PWA its own auth domain
VITE_FIREBASE_AUTH_DOMAIN stays the shared sign-in domain every browser
uses; the new optional VITE_FIREBASE_PWA_AUTH_DOMAIN is the domain the
installed PWA initialises Firebase with — the site's own host, served
by the /__/auth proxy, so the redirect handler runs inside the PWA
(utils/auth-domain.js). GitHub's OAuth app has one callback, on the
shared domain, so when the PWA domain differs the account menu offers
Google only. .env.example documents both and the console-side steps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:28:57 +00:00
Claude 156d0bff1b Fix(ui): name the Nav for view transitions only while one runs
A view-transition-name makes an element a captured, separately
rendered group, and WebKit kept the Nav's own animations (Pulse's
spinning globe and ping) janky after an iOS swipe-back with the name
always on. The Nav and the status-bar tint now carry their names only
under html[data-page-transition], which the router hook sets before
the snapshot and clears when the transition finishes, so outside one
the Nav is an ordinary fixed element again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:28:57 +00:00
Claude 856f965101 Style: slide between pages with View Transitions
Route changes animate like native navigation: a tool page slides in
from the right, going home slides it back out, tool to tool cross-fades
— utils/page-transition.js decides the direction from the route pair
and wraps the router's DOM update in document.startViewTransition from
router/index.js, so the snapshot, the swap and the scroll restoration
happen before the animation and pages of different heights never
overlap. Nothing animates on a query-only change, under reduced motion,
on a history step the browser already animated (swipe-back), or in a
browser without the API; a navigation arriving mid-transition skips the
running one. The Nav and the iOS status-bar tint opt out by
view-transition-name and stay put while the page moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:04:12 +00:00
Claude 1c3a36d253 Fix(auth): serve Firebase's auth handler from the site's own origin
The installed iOS PWA opens an out-of-scope authDomain in the system's
in-app browser view, whose storage the PWA's own webview cannot read,
so a redirect sign-in completed there never reached the app. The
frontend server and the Vite dev server now proxy /__/auth/* to
https://<project>.firebaseapp.com (common/firebase-auth-proxy.js),
mounted only when VITE_FIREBASE_PROJECT_ID is set, so with
VITE_FIREBASE_AUTH_DOMAIN set to the site's host the handler runs
in-scope, inside the PWA itself. .env.example documents the three keys
and the console-side requirements (authorized domain, OAuth callback at
https://<host>/__/auth/handler).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 15:02:01 +00:00
Claude e3eaef369f Fix(ui): one scroll offset for section shortcuts and nav links
The w / d / s / c keys scrolled a section 80px below the viewport top
while the nav links used 70px, so the same section landed at two
different spots. SECTION_SCROLL_OFFSET now lives in utils/scroll-to.js
and every section scroll — nav, breadcrumb, keyboard — uses it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 14:51:26 +00:00
Claude 08473e3e54 Fix(auth): sign in by redirect inside the installed PWA
An iOS standalone PWA has no popup with a usable opener, so
signInWithPopup never delivered its result there: the provider sent
the visitor back to the homepage and the app saw no sign-in. The store
now picks the flow (utils/auth-flow.js): popup in a browser tab, as
before; signInWithRedirect in the installed PWA, and as the fallback
when a popup is blocked. A redirect marks its provider in sessionStorage
(utils/auth-redirect.js) and boot consumes the marker before first
render with getRedirectResult, so the first authenticatedFetch carries
the token like the signed-in boot path. The toast host now shows an
alert raised before mount, which a failed redirect return is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 14:50:45 +00:00
Claude f0d27fe510 Refactor(nav): one navigation bar on every route, breadcrumb in the page
The homepage sections and the advanced tools are peers, so every route
shows the same Nav, rendered once from App.vue alongside the User and
Preferences hosts. What differs is the click: on the homepage a section
link scrolls; elsewhere it navigates home and then scrolls, re-aiming
while the freshly mounted homepage still resizes; an advanced tool
entry opens /tools/<slug> from anywhere (utils/nav-target.js +
composables/use-nav-target.js). The slim page header, its tools menu
and the body padding switch it needed are gone; open sheets close on a
route change.

Tool pages, /privacy and /r/:id carry a breadcrumb above their title
(PageBreadcrumb.vue): Home steps back in history when the homepage is
the previous entry, Advanced Tools goes home to the cards, the current
page is the last crumb. Cards use an arrow instead of the drawer icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 14:43:20 +00:00
Claude 2f5d3b426b Feat(tools): open the Advanced Tools as pages
Every way into a tool — the homepage cards, the nav dropdown and mobile
sheet, the keyboard shortcuts, ASN Info's profile link, the banners —
navigates to /tools/<slug>; the bottom drawer, its full-screen toggle
and the ?tool= query it was driven by are gone from Advanced.vue. The
pages stay alive (App.vue / ToolPage.vue), so browser back and forward
move between tools and the homepage, each restored as it was left.

Legacy /?tool=<slug> links redirect to the page in a router beforeEach
(utils/legacy-tool-link.js: known slug → its page with the remaining
query, unknown → the homepage). Persona Check drops noStandalone: with
the homepage kept alive its dependency commands stay registered, so the
page dispatches them in place and only a direct visit navigates home
first. The four sign-in tools no longer close a drawer before opening
the usage dialog, which PageHeader's User hosts.

Changelog entries in the six full locales; frontend/AGENTS.md describes
the page model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 14:25:02 +00:00
Claude 728dd7d087 Refactor(nav): shared page header with tools and account menus
The pages outside the homepage (/tools/:slug, /privacy, /r/:id) get one
PageHeader: a back button that steps back in history when the previous
entry is the homepage (restoring the kept-alive page and its scroll) and
pushes / otherwise (utils/back-target.js), the brand, a tools menu
grouped by category (ToolsMenu.vue, over listedTools() in
utils/tool-availability.js) so a tool can be switched without going
home, the current page's title, and the account menu. Below `sm` the
row keeps the back arrow, the title and icon triggers.

The account menu is UserMenu.vue, extracted from Nav.vue, which keeps
its look; the header also hosts User and Preferences so sign-in,
benefits & usage and the preferences sheet work on every page. Each
Preferences instance opens only on the page on screen and closes when
its page is left; My Achievements off the homepage navigates home first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 14:10:34 +00:00
Claude 44e897a48b Refactor(router): keep the homepage and tool pages alive
App.vue wraps the router view in a KeepAlive holding Home and the tool
page, so leaving for /tools/:slug and coming back neither re-runs the
homepage tests nor loses the scroll position; ToolPage (StandaloneTool
renamed) reads its slug reactively — the same instance now switches tool
on in-app navigation — and caches each visited tool per slug in its own
KeepAlive (max 8), dropping them all when the signed-in account goes
away (utils/tool-cache.js).

Cached pages pause while hidden: composables/use-route-active.js gives
useRouteActive / useActiveValue / useActiveEventListener, and the head
writer, the shortcut map, the scroll trackers, the ?q= watchers of ASN
Profile and IP Calculator, the User dialog triggers and the overlay
counter all act only for the page on screen. An overlay still open when
its page is left closes, as its portal lives outside the cached page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 13:56:19 +00:00
Jason Ng 16a5ecd5b5 Merge pull request #499 from jason5ng32/fix/radar-cap-dataset-cron
Improvements
2026-10-05 15:09:39 +08:00
jason5ng32 d8426e6017 Improvements 2026-10-05 15:06:00 +08:00
jason5ng32 bbcc53c518 Improvements 2026-10-05 14:59:24 +08:00
jason5ng32 4840c9ce01 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-10-05 14:52:09 +08:00
Jason Ng 13091bf5b5 Merge pull request #498 from jason5ng32/feat/ip-hitlist
Feat/ip hitlist
2026-10-05 14:49:54 +08:00
jason5ng32 b1875562fc Improvements 2026-10-05 14:43:48 +08:00
jason5ng32andClaude Opus 5.5 1dd10fbd2f Chore: point the asn-input / mac-input comments at the moved bridges
The layout refactor moved the two re-export bridges into utils/ip/ and
utils/features/; their header and test comments still named the old
paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:41:22 +08:00
Jason Ng 76833b91f3 Merge pull request #497 from jason5ng32/feat/tool-categories
Feat(tools): group the Advanced Tools cards by category
2026-10-05 14:30:38 +08:00
Jason Ng f6903513dc Merge pull request #496 from jason5ng32/refactor/file-layout
Refactor(layout): split backend-only code out of common/, group front…
2026-10-05 14:24:09 +08:00
Jason Ng a185d1844d Merge pull request #495 from jason5ng32/dev
Sync with Dev
2026-10-05 14:19:18 +08:00
jason5ng32 fa7ec2108e Improvements 2026-10-05 14:17:38 +08:00
jason5ng32andClaude Opus 5.5 deb7f11468 Feat(tools): add the IP Blocklist Check
A new In-depth Checks tool shows which blocklists an IP is on: the
real-time DNSBLs and the threat intelligence lists the private API's
/iphitlist checks. Listings are split by kind, so a VPN or Tor exit
reads as an anonymity mark rather than a bad record; hits stay open with
their reasons and removal links, the remaining lists fold away, and a
legend explains every mark.

/api/ipblocklist proxies the upstream with the caller's headers and a
30s timeout (a cold DNSBL pass outlasts the default 8s), passing sign-in
and quota rejections through. Signed-in users only, original site only,
150 checks a month (ip_hitlist in the usage dialog and the pre-flight
quota gate). Shift+B opens it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:15:21 +08:00
jason5ng32andClaude Opus 5.5 8c02ab3d14 Feat(tools): group the Advanced Tools cards by category
Fifteen cards in one flat grid mixed network diagnostics, lookups and the
in-depth checks that only run on IPCheck.ing. Each registry entry now
carries a category, and Advanced.vue renders the cards under three
headed groups: Network Diagnostics, Lookup & Calculation and In-depth
Checks. A group whose tools all fail their gates drops out with its
heading, so a self-hosted deployment simply shows no In-depth Checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:24:32 +08:00
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00
jason5ng32 ec4d536b60 Improvements 2026-10-05 12:20:03 +08:00
jason5ng32 d0e4023ce4 Cache Improvements 2026-10-05 12:17:18 +08:00
jason5ng32 c0cd8418e3 Improvements 2026-10-05 11:58:32 +08:00
jason5ng32 95f4196c88 Improvements 2026-10-05 11:56:44 +08:00
Jason Ng af45bcee18 Merge pull request #494 from jason5ng32/feat/mac-oui-offline
MAC Lookup from the local IEEE registries, with prefix queries and examples
2026-10-05 11:43:36 +08:00
jason5ng32andClaude Opus 5.5 a1bcc62965 Fix(ui): an empty MAC Lookup submit clears the last answer
Returning early on empty input left the previous result and error on
screen. It now clears them first, and the button stays disabled for a
whitespace-only input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:38:08 +08:00
jason5ng32andClaude Opus 5.5 0b4069b1b2 Fix(datasets): defer, not drop, a watcher check during this process's publish
Skipping a row while this process published it also dropped a change
someone else made in that window: no file moved afterwards, so no later
check came. The watcher now looks again once the publish has recorded its
files, and reloads if they are no longer its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:38:08 +08:00
jason5ng32andClaude Opus 5.5 3072941edc Fix(datasets): never take this process's own publish for a foreign change
The watcher told its own publish apart by the fingerprint reloadRow
records, but that lands only after the reload finishes. A settle tick in
between — the state write and a slow reload outlast the settle window, as
on a loaded CI runner — saw new files under an old fingerprint and reloaded
them a second time as a "file change". Rows being published by this
process are now skipped by the watcher until that reload has recorded them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:32:37 +08:00
jason5ng32andClaude Opus 5.5 d3df177df9 Fix(ui): Enter on an empty MAC Lookup input does nothing
The shared parser rejects an empty string, so Enter in the empty field
showed "Invalid physical address". onSubmit now returns early on empty
input, as IP Calculator does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:29:50 +08:00
jason5ng32andClaude Opus 5.5 87d49cec3d Fix(mac): a Company ID's range is not a randomized address
isRand marked every locally administered unicast address, CID ranges
included. A CID is assigned local space (IEEE 802c ELI), so an address a
registered CID covers is no longer reported as random. For the rest the
U/L bit stays the signal: it is the only mark randomization leaves on an
address, and what the previous provider reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:29:09 +08:00
jason5ng32andClaude Opus 5.5 000d5b4181 Fix(tests): run the MAC Lookup handler over fixture registries
The success test read the checkout's downloaded registries and fell back
to asserting a 503 without them, so CI never exercised a 200. The handler
now comes from createMacChecker(lookup); the tests pass a fixture lookup
for the 200 and a null one for the 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:28:13 +08:00
jason5ng32andClaude Opus 5.5 675ad8967b Fix(mac): trailing country code, cut-short files, multicast range
Three review findings in the IEEE registry reader:
- A country code after the postal code now wins over a state code before
  it ("Dover DE 19901 US" → US), except after NL / IT, whose postcodes are
  followed by letters (Dutch letter pairs, Italian provinces). Over the
  live registries this corrects two addresses and changes no other.
- A registry file that stops mid-row, or inside a quoted field, is refused
  as truncated; IEEE ends every row, the last included, with CRLF.
- A multicast query shows its block's multicast range (01:00:5E:…), which
  holds the address asked about, instead of the unicast registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:28:13 +08:00
jason5ng32andClaude Opus 5.5 e57b9e331d Feat(mac): prefix queries and example pills in MAC Lookup
The form validates with the shared common/mac-input.js parser, so vendor
prefixes go through. A collapsed "See examples" fold under the input, as in
IP Calculator, holds one pill per kind of answer: full address, vendor
prefix, MA-M block, private registration, multicast and randomized. Browsers
expose no MAC address, so there is no "my address" pill. Changelog entry in
every full locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:21:12 +08:00
jason5ng32andClaude Opus 5.5 7a52e8acea Feat(mac): answer MAC Lookup from the local IEEE registries
MAC Lookup no longer calls maclookup.app. The five public IEEE registries
(MA-L, MA-M, MA-S, IAB, CID) are a new `oui` dataset row — no key needed,
published as downloaded, refused below per-registry row floors — and
common/oui-db.js answers the longest assignment covering a query, the group
bit masked so a multicast address resolves to the block it derives from.
The country is read off the end of the registered address.

?mac= now takes a vendor prefix as well as a full address (6–12 hex digits,
colon / hyphen / dotted / bare), parsed by common/mac-input.js, which the
form shares. An address no block covers is still answered, with its flags.
The route is gated by requireOfflineData while the registries download at
boot. MAC_LOOKUP_API_KEY is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:21:12 +08:00
Jason Ng 83cba6f3ba Merge pull request #493 from jason5ng32/feat/dataset-updater
One dataset updater, and serve while offline data downloads
2026-10-05 10:32:21 +08:00
jason5ng32andClaude Opus 5.5 6a563e437f Fix(datasets): log every scheduled run and each row's outcome
A scheduled or catch-up run that found every dataset current logged
nothing — only a publish wrote a line — so a run that fired looked like one
that never did. updateDatasets now logs the run's start and, per row, that
it was up to date or skipped for another process's lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CauPFca6bYL9qpuYEmsU9H
2026-10-05 02:23:24 +00:00
jason5ng32andClaude Opus 5.5 c29de4f97c Fix(tests): give the healthy row headroom in the stalled-row timeout test
updateDatasets applies one timeout to every row, so the 50 ms that stops
the stalled row also bounded the healthy one — a lock, a temp dir and two
fsync'd state writes — and a loaded CI runner timed it out too. 1 s keeps
the stall test meaningful with room for the healthy row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CauPFca6bYL9qpuYEmsU9H
2026-10-05 01:52:28 +00:00
jason5ng32andClaude Opus 5.5 971cc5203c Fix(datasets): repair an interrupted publish or an unusable snapshot at boot
- A run that died or lost its lock between renames left every file present
  (possibly a mixed MaxMind City / ASN pair), and the boot download skipped
  the row because the files existed. The in-flux state now carries
  `publishing: true`, cleared by the final record, and boot re-fetches a row
  whose last publish never finished.
- The boot skip asked whether a snapshot file existed, so an empty or
  corrupt one — which the reader refuses — kept the row unrepaired. Rows now
  supply isLoaded(), the reader's own readiness (all four), and boot
  downloads whenever the reader holds nothing usable; files under the
  published names are re-fetched even when the state records the remote's
  version (updateDataset's new `force`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CauPFca6bYL9qpuYEmsU9H
2026-10-05 01:45:46 +00:00
jason5ng32andClaude Opus 5.5 f81fce0c02 Fix(datasets): roll back a publish a timeout cuts short; skip only on a lost lock
publish took the combined signal (caller timeout + lock loss) and treated
any abort as a lost lock, so a scheduled or boot timeout firing between
renames skipped the rollback and cleanup while this process still held the
lock, leaving a mixed set and .next / .bak files. The combined signal still
cancels, but only the lock's own signal now stops rollback and cleanup.
Once every rename is done, only a lost lock keeps the version from being
recorded, so a timeout landing after a complete publish no longer forces a
re-download.

The lock-loss test now trips the engine's real onCompromised handler; a
new test covers the timeout path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CauPFca6bYL9qpuYEmsU9H
2026-10-05 01:45:45 +00:00
jason5ng32andClaude Opus 5.5 f867e0d189 Fix(datasets): watch from before the boot downloads; honour hand-named CAIDA files
- The watcher started only after every boot step, so a dataset published
  by another process (or placed by hand) while another step was running
  became the watcher's baseline and was never loaded. It now starts before
  the boot downloads.
- The CAIDA readers serve the newest *.txt under any name, but the boot
  download checked only the published names, so an upgrade with a
  hand-named snapshot re-downloaded it. A row can now supply hasSnapshot();
  as2org / as-rel use their reader's own rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:46:22 +08:00
jason5ng32andClaude Opus 5.5 1db89e1dc4 Fix(datasets): size the Sentry monitor's maxRuntime to the scheduled rows
Rows update one after another, each with up to 30 minutes, so four datasets
can legitimately take two hours while the monitor failed a run after one.
maxRuntime is now every scheduled row's timeout plus a 10-minute margin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:44:05 +08:00
jason5ng32andClaude Opus 5.5 ce21aa33bb Fix(datasets): mark state in flux while publishing; no file work after a lost lock
Rolling back after a lost lock could overwrite what the lock's new holder
had just published. Once the lock is lost, publish now touches nothing more
(no rollback, no cleanup). To keep a half-done set from passing for
current, the state names no version from the start of publishing until the
final write, so whoever runs next — after a lost lock, a crash or a kill —
re-fetches and publishes a whole set. A rename failure with the lock still
held is rolled back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:43:23 +08:00
jason5ng32andClaude Opus 5.5 c85c33d13a Fix(tests): expect a 502 from the ASN Profile handler test without CAIDA data
With connectivity now an error when no as-rel snapshot is loaded, the
network-down handler test has no working source on a data-less checkout
(CI), so the handler rightly answers 502. The test asserts that there and
keeps its 200 expectations where a snapshot exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:38:14 +08:00
jason5ng32andClaude Opus 5.5 034f5177c6 Fix(datasets): check for a lost lock between publish steps
The lock-loss signal was checked only before publishing began, so a lock
compromised mid-copy or mid-rename let publication continue. It is now
checked before every copy and rename, an abort taking the same rollback,
and again before the state is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:36:40 +08:00
jason5ng32andClaude Opus 5.5 5c0891a323 Docs(readme): use DATASET_AUTO_UPDATE in the docker run example
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:35:23 +08:00
jason5ng32andClaude Opus 5.5 d84fefbbea Fix(asn): report unloaded local data as an error, not a cacheable empty
- PeeringDB is 'disabled' only without a Cloudflare key; configured but not
  loaded (still downloading, or the download failed) is an 'error', so the
  ASN Profile isn't cached for a week without it.
- Connectivity without an as-rel snapshot is an 'error' too, not an empty
  graph; a missing as2org still only blocks while it downloads, as RIPEstat
  names the nodes.
- A CAIDA as2org / as-rel file that parses to nothing (corrupt) no longer
  counts as loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:35:23 +08:00
jason5ng32andClaude Opus 5.5 02476aec7e Fix(datasets): abort on a lost lock, roll back new files, validate watched files
- A compromised lock (failed heartbeat; another process may take it over)
  aborts the update holding it, download included, before it publishes,
  instead of only being logged.
- A failed publish removes files it had just added when the dataset had none
  before, as well as restoring replaced ones from backup: never half a set.
- The watcher reloads hand-placed or externally published files only once
  they have held still for the settle interval and passed the row's
  validator, so a file still being copied never replaces healthy data.
- Back to write-file-atomic 8, with engines aligned to its range
  (^24.15.0 || >=26.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:31:28 +08:00
jason5ng32andClaude Opus 5.5 65e8432768 Fix(datasets): bound scheduled runs, catch up by schedule, retry failed reloads
- Scheduled and catch-up runs give each dataset 30 minutes, download
  included, so a stalled upstream can't hold up the rest or, under croner's
  overrun protection, every later run.
- Downloads go through fetchUpstream (project User-Agent, a timeout on the
  wait for a response); the body stays bounded by the caller's signal.
- Catch-up asks whether a scheduled run fell between the last check and now,
  instead of a 25-hour age that missed a check skipped just after one.
- A remote that names no version (no Last-Modified) is always fetched, not
  taken for unchanged; maxmindIdentifier is null when an edition omits it.
- The watcher records a dataset's files as loaded only after the reload
  succeeds, so a failed load is retried on their next change.
- write-file-atomic 7, which supports the project's Node >=24 (8 needs 24.15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:29:48 +08:00
jason5ng32andClaude Opus 5.5 b3cd1ceef7 Feat(backend): listen before offline data finishes downloading
The backend downloaded every missing dataset before listening, so a first
boot (or a newly added dataset) refused every request for a minute or more.
Snapshots already on disk now load before the listener opens and missing
ones download behind it. Meanwhile only routes reading still-missing data
answer 503 (never edge-cached): /api/maxmind, /api/asn-connectivity and
/api/service-status. An ASN Profile section whose data is still loading
answers 'error' on its own, and the connectivity graph client doesn't cache
a 503, so both recover once the data lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:22:06 +08:00
jason5ng32andClaude Opus 5.5 287c50fe1a Feat(datasets): one updater engine for every offline dataset
MaxMind and CAIDA each had their own updater with its own lock, state file,
publish step, boot download and timer. One engine (common/dataset-updater.js)
now owns all of that, and each dataset is a row in common/datasets.js that
holds only its own logic: find the newest remote version, fetch or distill
it, validate, reload.

- Locks: proper-lockfile, a heartbeat lock shared by every process, so a
  crashed holder's lock goes stale on its own; a booting server waits for a
  live holder and loads what it published.
- State: one file per dataset, written with write-file-atomic; the old
  .maxmind-update-state.json / .caida-update-state.json are read once, so an
  upgrade doesn't re-download.
- Schedule: croner at a fixed time (DATASET_UPDATE_CRON, default 04:30 local)
  instead of 24 h from process start, plus a catch-up run at boot for a check
  a downtime skipped. DATASET_AUTO_UPDATE (default on) replaces
  MAXMIND_AUTO_UPDATE / CAIDA_AUTO_UPDATE, which still apply when it is unset.
- Reload: the engine watches every dataset's files, so a publish by another
  process or a hand-placed .mmdb is picked up; MaxMind's own watcher goes.

MaxMind's City and ASN editions now publish together under one version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:21:58 +08:00
Jason Ng 152906b762 Merge pull request #492 from jason5ng32/feat/asn-profile-improve
ASN Profile improvements
2026-10-05 02:04:14 +08:00
jason5ng32andClaude Opus 5.5 dcff924d3d Fix(asn): show no country shares when the lookup budget runs out
Shares over the part of the space walked before the budget ran out would
read as the ASN's whole IPv4 distribution. An exhausted budget now gives no
shares, like a lookup without an answer, and the answer stays uncached.
Real ASNs use at most ~30k of the 100k lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:02:01 +08:00
jason5ng32andClaude Opus 5.5 57f68bacb5 Fix(asn): keep incomplete country shares and stale profiles out of the cache
buildCountryShares now reports whether its walk completed: a lookup with
no answer (no database, or a failed lookup) and the lookup budget, now a
hard cap across blocks, both mark it incomplete, and the bgp-prefixes view
serves such an answer uncached. A prefix-less AS needs no lookups, so it
stays cacheable even without MaxMind. ASN_PROFILE_VERSION goes to 6 so
profiles cached with first-address country shares are not served on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:57:54 +08:00
jason5ng32andClaude Opus 5.5 0054c9dde1 Fix(asn): follow ?q= changes while the ASN Profile stays open
The profile read `q` only on mount, so a new ASN link or history step with
the drawer open changed the URL but not the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:49:35 +08:00
jason5ng32andClaude Opus 5.5 8ebe3ea0ea Fix(asn): don't cache prefix countries computed without MaxMind
With MaxMind unavailable (a failed download) the bgp-prefixes view answered
`countries: []` as complete, and it was edge-cached for a week. The answer
is now marked partial, and the ASN Profile's prefixes section incomplete,
so neither is cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:49:35 +08:00
jason5ng32andClaude Opus 5.5 5ef0e48e8b Fix(asn): split a prefix's addresses across MaxMind networks
Country shares credited a whole announced prefix to the country of its
first address, so a /8 spanning many countries counted as one. Each prefix
is now walked one MaxMind network at a time, within a per-request lookup
budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:49:34 +08:00
jason5ng32andClaude Opus 5.5 8bcbb91624 Refactor(asn): drop prefix and relationship counts from the ASN info panel
The panel's IPv4/IPv6 prefix counts came from Radar's routes/stats, an
endpoint that fails often and, as one of the profile's summary segments,
kept complete ASN Profiles out of the edge cache. The ASN Profile already
shows prefix counts (from the pfx2as list) and relationships (the CAIDA
graph), so the panel drops both: no more routes/stats call, and the Radar
asn view no longer reads the local CAIDA snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:49:34 +08:00
Jason Ng 846deb50a8 Merge pull request #490 from jason5ng32/feat/asn-profile
Feat/asn profile
2026-10-04 00:10:33 +08:00
jason5ng32andClaude Fable 5.1 04079783af Fix(tests): keep the logger off its worker thread during specs
The pino-pretty transport runs in a worker thread. In a loaded parallel
run, a spec that logs shortly before finishing could occasionally be kept
alive by that thread and never exit, stalling the whole suite until the
CI timeout. `pnpm test` now preloads tests/setup.js, which switches the
logger to its synchronous JSON output unless LOG_FORMAT is already set.

Runtime logging is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 00:00:39 +08:00
jason5ng32andClaude Fable 5.1 d5715104d5 Fix(asn): never cache an upstream fault as an empty answer
Review findings on the ASN Profile work, mostly one class of bug: a failed
upstream read as "no data", which the 7-day edge cache would then pin.

- AS Rank: a 200 carrying GraphQL errors is a failure, not an unknown AS.
- Radar: an answer built from partly failed segments is still shown but
  flagged `incomplete` and kept out of the cache, on the aggregate and on
  the standalone asn view. A 404 is Radar's "no such AS" and counts as
  empty, as does a segment that simply has no figures.
- Reputation: a malformed or non-200 answer is an error.
- Prefixes: a payload without `prefix_origins` is an error; [] stays valid.
- Radar is `empty` when only local CAIDA counts are present.
- requireValidASN enforces AS1–AS4294967295 and canonicalizes; the range
  rule lives once in common/asn-input.js, shared with the frontend.
- The PeeringDB listing lookup goes through fetchUpstream.
- New functions follow the const-arrow convention; test stubs compare
  parsed hostnames instead of URL substrings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 23:43:55 +08:00
jason5ng32 a5d3cbf2c8 ASN Profile Shortcut 2026-10-03 22:55:28 +08:00
jason5ng32andClaude Fable 5.1 bf1e4a934a Style(asn): tighten the ASN Profile title and tool note
The tool is titled as a lookup, its card note is one short line, and the
intro names PeeringDB among its sources — across all six locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:53:17 +08:00
jason5ng32 076a9e36d8 Improvements 2026-10-03 22:42:22 +08:00
jason5ng32andClaude Fable 5.1 4eee98aa93 Feat(asn): PeeringDB data in ASN Profile
A third offline dataset joins the CAIDA updater: the daily PeeringDB dump,
distilled by a streaming pass into a compact per-ASN index (network type,
scope, peering policy, exchange and facility presence). Contact tables are
never parsed and the raw dump is deleted after distilling. It downloads
only when the Cloudflare key is configured, since the tool is hidden
without it.

The profile shows network type, scope and website in the header and a
"Peering & facilities" card whenever the AS lists an exchange or facility.

Aggregate deadlines now sit above the fetch timeouts they wrap, so a
source that degrades internally still delivers its partial answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:31:11 +08:00
jason5ng32andClaude Fable 5.1 5ea781c513 Chore(asn): changelog entry and cleanup for ASN Profile
Adds the v7.7.0 changelog line for the new tool, drops two exports
nothing imports, and aligns the reputation test fixtures with the fields
the private API actually returns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 21:43:29 +08:00
jason5ng32andClaude Fable 5.1 901092ef66 Feat(asn): ASN Profile tool
A full profile of one autonomous system at /tools/asn?q=AS<n>, served by
a single /api/asn-profile request: identity and registration with the raw
Whois record, key facts (rank, announced IPv4, prefixes, users), traffic
shares and connection quality, where its IPv4 space is used, the announced
prefix list with RPKI status, and connectivity with the topology graph.
A reputation card appears beside the header when the backend returns it.

- The ASN Info panel now opens the profile instead of linking out.
- ASNConnectivity gains `expandable` / `bordered` so the profile can embed
  it without the nested drawer and the second border.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 21:33:51 +08:00
jason5ng32andClaude Fable 5.1 6189a96498 Feat(asn): one aggregate endpoint for the ASN Profile page
/api/asn-profile gathers every source for one AS in parallel and answers
once: Radar entity and traffic data, announced prefixes with country
shares, the CAIDA topology and neighbours, the RDAP registration record,
AS Rank, and (when the private API is configured) reputation. Each source
has its own deadline; a slow or failed one is simply absent, flagged in a
`status` map, and only complete answers are edge-cached.

- Upstream / peer / customer counts now come from the local CAIDA
  snapshot with the same rule the topology graph applies, for both the
  profile and the ASN Info panel; the graph builder itself is unchanged.
- The ASN data family (aggregate, Radar asn and bgp-prefixes views)
  shares a 7-day edge cache.
- /api/asrank is folded into the aggregate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 21:33:38 +08:00
jason5ng32andClaude Fable 5.1 c55634c8e0 Feat(asn): frontend groundwork for the ASN Profile tool
- Registry gate `requiresConfig`: a tool is listed in the Advanced Tools
  grid and the nav only when its backend config is present; the listing
  filter is one pure function. ASN Profile requires the Cloudflare key.
- `useAsnInfo` owns the Radar ASN fetch and its session cache, shared by
  the IP cards and the query dialog.
- ASN Info's traffic-share and connection-quality blocks become small
  components over pure helpers, reusable by the profile page.
- ASN Profile lands as a mock-data page for now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:47:15 +08:00
jason5ng32andClaude Fable 5.1 0e6e411beb Feat(asn): backend data for the ASN Profile page
- cfradar `view=asn` keeps the RPKI route counts from routes/stats.
- New cfradar `view=bgp-prefixes`: the AS's announced prefixes (pfx2as)
  plus IPv4 country shares resolved against the local MaxMind database.
- New /api/asrank: CAIDA AS Rank lookup (rank, customer cone, degree).
  Misses answer 200 with null fields and stay out of the edge cache via
  the new `cacheIf` option on `cacheable`.
- /api/asn-connectivity adds `neighbours`: provider / peer / customer
  counts and name lists from the local AS-relationship snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:40:52 +08:00
jason5ng32andClaude Fable 5.1 09d3cef4c0 Feat(whois): look up AS numbers
The Whois tool and /api/whois now take `AS<n>` (prefix in any case) next
to IPs and domains, answered from the RIR's RDAP autnum record. A bare
number stays invalid input, so it can never be mistaken for anything else.

The raw blocks no longer start with the template's own indentation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:10:14 +08:00
jason5ng32 3495204fc4 Improvements 2026-10-03 11:52:56 +08:00
jason5ng32 3dea2421d2 Improvements 2026-09-29 16:00:44 +08:00
jason5ng32 aedee4fd3d Improvements 2026-09-29 15:48:24 +08:00
jason5ng32 8ace7a2e30 Improvements 2026-09-29 15:38:54 +08:00
Jason Ng 701b610833 Merge pull request #488 from jason5ng32/dev
Sync with Dev
2026-09-29 15:35:56 +08:00
jason5ng32 681051b1bc Improvements 2026-09-29 15:31:25 +08:00
jason5ng32 b24874b69e Improvements 2026-09-29 15:27:43 +08:00
jason5ng32andClaude Opus 5.5 cf3bb69d8b Feat(ipinfo): show how the quality score was reached and rename the proxy field
Follows the v3 score output of MyIP-api (this frontend no longer reads
the v2 shape; deploy it right after the API).

- A toggle next to the quality score opens a panel: a short explanation
  assembled from the score dimensions (network type, anonymity with its
  last-observed date, reputation, the ASN's neighbourhood, the verdict),
  and all boolean attributes as check / cross / question-mark icons.
  Residential proxies seen only in the API's own sighting history read
  as "suspected".
- The card's "Proxy" field becomes "Anonymizer": Tor exit, iCloud
  Private Relay, residential proxy, proxy, VPN (each with a suspected
  variant when only one source agrees) or not detected. Card fields are
  anonymity / anonymityCode / anonymityProvider / anonymityProtocol; the
  provider line hides when the API returns null. Texts live under
  ipInfos.anonymity.*, with one code-to-key map for card and report.
- Diagnostic reports store the anonymity code (REPORT_VERSION 2; v1
  links show the unsupported-version page, reworded to fit either
  direction). The persona collector no longer sends the unused isProxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:19:14 +08:00
jason5ng32 74b2c69524 Improvements 2026-09-26 17:02:59 +08:00
jason5ng32andClaude Opus 5.5 78d5f95238 Chore(history): cap IP history retention at 30 days
Ninety days is longer than most visitors need for a local record. Stored
preferences above the new cap clamp to 30 on read, and older day buckets
are pruned on the next load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:54:53 +08:00
jason5ng32 5c59c6677d Improvements 2026-09-24 09:37:12 +08:00
jason5ng32 2b5485f316 Improvements 2026-09-22 15:30:32 +08:00
jason5ng32 d77342cc83 Improvements 2026-09-21 23:21:46 +08:00
jason5ng32 47446b3039 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-09-21 23:21:23 +08:00
Jason Ng 83d2679d61 Merge pull request #487 from jason5ng32/dev
Chore(release): set the v7.6.0 release date
2026-09-21 23:21:10 +08:00
jason5ng32andGPT-6 aa9f31472e Chore(release): set the v7.6.0 release date
Apply only the release-date change from
f7e332c15d.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-21 23:20:03 +08:00
jason5ng32 f7e332c15d Improvements 2026-09-21 23:17:23 +08:00
jason5ng32 7f3d38e147 Add Map to IP History 2026-09-21 23:11:43 +08:00
jason5ng32andGPT-6 5d764cff29 Feat(history): group IP records with a daily view
Collapse repeated daily IP records into summaries ordered by last seen.
Keep country and IP-type filters across both views and expand dates only
for IPs recorded on multiple days. Preserve the daily storage format.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-21 22:43:01 +08:00
Jason Ng abd8e2b683 Merge pull request #486 from jason5ng32/dev
Sync with Dev
2026-09-21 22:23:50 +08:00
jason5ng32 ab42ba6acf Improvements 2026-09-18 10:15:28 +08:00
jason5ng32 40342a9631 Improvements 2026-09-18 10:00:47 +08:00
jason5ng32 1adefb13d2 Improvements 2026-09-17 10:57:30 +08:00
jason5ng32andGPT-6 0c6e5f2c7a Fix(ip): validate subnet split limits before conversion
Return null for invalid options or limits instead of throwing during
BigInt conversion. Preserve zero, default, and safe integer limits.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:49:17 +08:00
jason5ng32andGPT-6 6de54d7e44 Fix(ip): reject out-of-family prefix containment values
Validate both network and address against the selected IP family.
Cover negative values, overflow, and valid IPv4 and IPv6 boundaries.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:49:17 +08:00
jason5ng32andGPT-6 1ec87945ce Fix(ipcalculator): parse octal input directly as BigInt
Avoid Number overflow when parsing long standalone or dotted octal
input. Cover oversized values, leading zeros, and IPv4 boundaries.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:49:17 +08:00
Jason Ng 39ae0ad528 Merge pull request #484 from jason5ng32/dev
Sync with Dev
2026-09-17 10:48:59 +08:00
jason5ng32 45be0e711c Improvements 2026-09-17 10:33:38 +08:00
jason5ng32andGPT-6 65907c90e5 Fix(ipcalculator): sync share URLs after prefix changes
Update the input and query parameter when a slider change is committed.
Preserve the host address and other query parameters, and keep dragging
from replacing the URL. Cover IPv4, IPv6, and numeric inputs.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:23:38 +08:00
jason5ng32andGPT-6 b2b98b519c Fix(dns): share fallback probes within each test run
Reuse pending, successful, and exhausted provider probes across cards.
Keep refreshes isolated and collapse repeated observations in reports.
Cover shared retry budgets, refresh races, and report deduplication.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:23:24 +08:00
jason5ng32 7c146976ef Improvements 2026-09-17 09:58:10 +08:00
jason5ng32 d3ada7e6b8 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-09-17 09:55:28 +08:00
Jason Ng 7909cbfbf5 Merge pull request #483 from avirajkmar848-png/add-skydns-resolver
Feat(dns): add SkyDNS (Russia) resolver
2026-09-17 09:54:48 +08:00
jason5ng32 3edaaf5f79 Improvements 2026-09-17 09:44:29 +08:00
jason5ng32andGPT-6 7c5c3e801d Fix(ip): reject leading zeros in IPv4 octets
Reject ambiguous IPv4 input before geolocation requests reach upstreams.
Cover shared validation, public-IP checks, and the API guard while
preserving valid zero octets and IPv6 leading zeros.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 09:42:37 +08:00
Aviraj Kumar beb5653b5f Feat(dns): add SkyDNS (RU) to the resolver list
Adds SkyDNS (193.58.251.251) to api/data/dns-resolvers.js, giving
Russia a second public resolver alongside Yandex.DNS, as invited
in #390.

UDP entry only: SkyDNS's DoH endpoint serves RFC 8484 wire format
rather than the application/dns-json API this handler appends
name=&type= to (same call as Yandex.DNS and CZ.NIC ODVR).

Verified from outside RU: RA set, honest NXDOMAIN, non-truncated.
2026-09-17 01:56:21 +05:30
jason5ng32 a3b334d218 Improvements 2026-09-16 20:51:01 +08:00
jason5ng32andGPT-6 a11539004c Fix(dnsleaks): obtain bash.ws test IDs from the provider
Keep a single probe and use bash.ws as a fallback after Surfshark.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-16 18:49:22 +08:00
jason5ng32andGPT-6 cc0053ac16 Refactor(ipinfo): share Intl country names with the frontend
Replace country-code-lookup with the existing Intl.DisplayNames helper.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-16 18:29:19 +08:00
jason5ng32andGPT-6 9d9de4ed22 Refactor(dns): use the native Promise resolver API
Remove callback adapters while preserving record formatting and errors.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-16 18:29:07 +08:00
jason5ng32 a1ee760d88 Improvements 2026-09-13 22:47:01 +08:00
jason5ng32 020bfcedd6 InfoBanner support open sheet 2026-09-08 09:26:13 +08:00
jason5ng32 c49cc4246a Improvements 2026-09-07 01:09:18 +08:00
jason5ng32 057e87269b Improvements 2026-09-06 09:11:28 +08:00
Jason Ng ddd0a2bedd Merge pull request #476 from Mora140/feat/connectivity-travel
Feat(connectivity): add Travel & Booking import list
2026-09-06 09:09:01 +08:00
jason5ng32andClaude Fable 5.1 0b3b4ccd38 Feat(ui): add a "Testing…" status to the card-grid tests
Connectivity, WebRTC, DNS Leak and Rule Test cards only knew "Awaiting
Test" and the final verdict, so a manual refresh left the label saying
the test had not started while it was already running. A new
StatusTesting sentinel is written the moment a run is scheduled, on boot
and on refresh alike; "Awaiting Test" now only means the test has not
been triggered (Connectivity with auto-run off). Tone and pulse stay on
the wait level, the info mask skips the new placeholder, and the English
ruletest.StatusWait drops its stray "or Test Error" suffix along with
the translations derived from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 08:56:14 +08:00
Mora 56de3a3b23 Merge upstream/dev into feat/connectivity-travel
Keep both the Sports and Travel import lists.
2026-09-05 19:06:26 +02:00
jason5ng32 a62a017468 Merge branch 'dev' of https://github.com/jason5ng32/MyIP into dev 2026-09-05 22:02:30 +08:00
AntisocialandJason Ng f7b0fb157c Feat(connectivity): add Nigeria import list (#471)
Co-authored-by: Jason Ng <jason5ng32@users.noreply.github.com>
2026-09-05 21:36:39 +08:00
jason5ng32 c596a15b30 Improvements 2026-09-05 21:14:57 +08:00
Jason Ng e6949bd580 Merge pull request #475 from Mora140/feat/connectivity-sports
Feat(connectivity): add Sports import list
2026-09-05 21:09:49 +08:00
jason5ng32andGPT-6 6cf3b58a6b Fix(speedtest): improve feedback on slow connections
Show a fixed preliminary test before the selected measurements and keep
formal results independent. Start testing without waiting for charts or
connection metadata, buffering samples until charts are ready.

Add localized phase labels and immediate startup progress, reduce the
formal latency sample count to 20, and document the improvement.

Validation: pnpm check (1533 tests passed and build succeeded).

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-05 09:37:15 +08:00
jason5ng32 a16cdf7852 Improvements 2026-09-04 22:14:08 +08:00
jason5ng32 3da73075a5 Improvements 2026-09-04 22:11:41 +08:00
jason5ng32 05f837df27 Improvements 2026-09-04 21:55:00 +08:00
Jason Ng 238ded1e1f Merge pull request #478 from Mora140/feat/connectivity-perplexity
Feat(connectivity): add Perplexity to the AI list
2026-09-04 16:48:26 +08:00
jason5ng32 2a9f62327a Improvements 2026-09-04 10:32:21 +08:00
Morariu RazvanandCopilot Autofix powered by AI 481661212f Fix capitalization of Tripadvisor entry
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-04 03:54:43 +02:00
Mora 11536c9dc5 Feat(connectivity): add Perplexity to the AI list
Add Perplexity to the AI import list, plus its favicon.

Hugging Face is already on the list. The earlier Replicate change was closed unmerged, so this uses a different site.

Related to #399
2026-09-04 03:49:09 +02:00
Mora e1754e3efc Feat(connectivity): add Travel & Booking import list
Add a Travel & Booking import list (Booking.com, Airbnb, Expedia, Skyscanner, Tripadvisor, Agoda, KAYAK, Trip.com, Hotels.com, MakeMyTrip), locale names, and favicons.

Closes #403
2026-09-04 03:48:54 +02:00
Mora d5ed6c9428 Feat(connectivity): add Sports import list
Add a Sports import list (ESPN, NBA, FIFA, Premier League, Formula 1, DAZN, Flashscore, Sofascore, Sky Sports, beIN Sports, MLB, NFL), locale names, and favicons.

Cricbuzz is already on the India list, so it is not duplicated.

Closes #402
2026-09-04 03:48:46 +02:00
jason5ng32 3cf534d6ee Improvements 2026-09-03 20:01:08 +08:00
jason5ng32 a63ef4521a Improvements 2026-09-03 19:39:51 +08:00
jason5ng32 65145ca5e8 Improvements 2026-09-03 19:10:40 +08:00
Jason Ng 647e03d963 Merge pull request #470 from jason5ng32/dev
Sync with Dev
2026-09-03 18:55:24 +08:00
jason5ng32andClaude Fable 5.1 eed1f985d1 Fix(ipcalculator): use the number-input emoji for the tool card
The abacus reads as region-specific; 🔢 is the neutral choice, since
Unicode's pocket calculator (U+1F5A9) has no colour emoji presentation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 e7074cc12c Chore(release): open v7.6.0 with the IP Calculator changelog entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 3b19e0253e Docs: describe the IP Calculator and drop changelog entry counts
READMEs and the AGENTS overview list the new tool; frontend/AGENTS.md
records the shareable `?q=` input pattern. TRANSLATING.md no longer quotes
the number of changelog entries, which went stale on every release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 e30fcb17e5 Feat(ipcalculator): add the IP Calculator advanced tool
One smart input with example pills and the visitor's own IPs, result cards
per kind (IPv4, IPv6, range, prefix list) built on a prefix slider + bitmap,
value rows with copy buttons, and a subnet splitter. The query mirrors to
?q= so results are shareable. The splitter only follows the slider on
release and value rows have a fixed min-height, so nothing below the thumb
resizes mid-drag. Registered in tools.js with shortcut `a`; six locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 e6670b16ec Feat(ipcalculator): add the pure classification and analysis layer
classifyInput() recognises IPv4 / IPv6, prefixes (length or dotted mask),
integers and hex, ranges, prefix lists and the inet_aton spellings browsers
accept; calculate() derives display-ready analysis: IANA special-purpose
blocks, subnet math, PTR names, obfuscated and embedded-IPv6 forms, Teredo /
ULA / multicast decoding and EUI-64 → MAC recovery. Nothing throws and no
BigInt reaches a template. MAC input is deliberately left to MAC Lookup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 6f4c96e64b Refactor(rdap): share IP arithmetic through common/ip-math.js
rdap.js carried its own BigInt IP parser and CIDR containment; the new
common/ip-math.js (strict parsers, RFC 5952 formatting, mask / count math,
split / aggregate / range → CIDR) becomes the single implementation, bridged
to the front-end for the upcoming IP Calculator. Bootstrap matching is
unchanged; tests/rdap-ip.test.js still passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:10 +08:00
jason5ng32andClaude Fable 5.1 70f058ed4f Fix(i18n): load optional locale datasets through one fallback-aware loader
Privacy copy and the security checklist each fetched their locale chunk on
their own. The privacy page revealed before any fallback had merged when the
active chunk failed (raw keys until English arrived), and the checklist had
no handling at all — a failed chunk meant an endless spinner plus an
unhandled rejection. utils/locale-datasets.js now walks the fallback chain
at fetch time, skipping chunks that fail or ship no default export, so both
pages render the next available language and only a whole-chain failure
degrades further.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:40:03 +08:00
jason5ng32 0199902495 Improvements 2026-09-03 18:25:16 +08:00
Jason Ng ad1d6db208 Merge pull request #469 from fly1d/codex/finance-payments
Feat(connectivity): add Finance & Payments list
2026-09-03 18:22:24 +08:00
fly1dandOpenAI Codex ffdc9213d1 Feat(connectivity): add Finance & Payments list
Add ten traditional finance and payment probes, localized list names for every full locale, and committed favicons for the curated import.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-09-03 17:20:42 +08:00
jason5ng32andClaude Fable 5.1 c72dd00984 Fix(configs): widen the /api/configs timeout to 10s
The boot request competes with every other first-render fetch; on slow
links the default 5s cut it short and left every feature switch unset
for the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:11 +08:00
jason5ng32andClaude Fable 5.1 675a600d1f Fix(user): mark remote user info fetched only on success
The fetched flag was set outside try/catch, so one failed /api/getuserinfo
hid the level badge and achievements for the whole session with no way
to refetch. The one-shot trigger is also cleared after use so Nav and
Achievements can request the fetch again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:11 +08:00
jason5ng32andClaude Fable 5.1 1c797b30a5 Fix(i18n): tolerate locale chunks without a default export
A dynamic import that resolves to an empty module threw on destructure:
the app still mounted with no messages at all, and the privacy page sat
on its loading state forever. Skip the empty pack, settle the chain with
allSettled, and always flip the privacy page's ready flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:11 +08:00
jason5ng32andClaude Fable 5.1 8d84ec37bd Fix(sentry): drop html-to-image stylesheet noise and match firebase idb errors
html-to-image console.errors on every cross-origin stylesheet it cannot
read (the docs assistant widget leaves one behind) and then carries on,
so the screenshot still renders; those lines are now dropped in
beforeSend. The firebase-auth ignoreErrors entry was written as
'Database is closing/hidden', which never substring-matched either real
message — split into the three actual wordings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:10 +08:00
jason5ng32andClaude Fable 5.1 244a121e98 Chore(sentry): skip Sentry init in dev runs
A local pnpm dev with a DSN in .env reported every half-finished edit
against the production projects. The frontend gate now also checks
import.meta.env.DEV; the backend bootstrap skips init under
SENTRY_ENVIRONMENT=development, mirroring the cron check-in rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:10 +08:00
jason5ng32 a8f75bdb46 Improvements 2026-09-02 15:51:51 +08:00
jason5ng32 92f910872e Improvements 2026-09-02 15:50:46 +08:00
Jason Ng 111424e9f5 Merge pull request #468 from fly1d/codex/online-education
Feat(connectivity): add Online Education list
2026-09-02 15:49:33 +08:00
fly1dandOpenAI Codex c2432a4dd0 Feat(connectivity): add Online Education list
Add twelve browser-safe education probes, localized list names, and committed 64px favicons for the curated connectivity import.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-09-02 14:54:04 +08:00
Jason Ng 53f3006070 Merge pull request #467 from sage-s11/add-kr-kt-resolver
Add KT (Korea) public DNS resolver, closes #393
2026-08-31 20:31:11 +08:00
s4ge 6934bf9e5b Add KT (Korea) public DNS resolver, closes #393 2026-08-30 21:54:48 -06:00
Jason Ng 948fdd47e3 Merge pull request #466 from jason5ng32/dev
Sync with Dev
2026-08-31 09:44:03 +08:00
jason5ng32 8ab3675081 Improvements 2026-08-31 09:41:54 +08:00
Jason Ng 15f19a9103 Merge pull request #464 from sage-s11/feat/ci-badge-readmes
Docs(readme): add CI workflow badge to all four READMEs
2026-08-30 10:47:02 +08:00
s4ge b236dbb7e3 Docs(readme): add CI badge to ZH-TW and PT-BR READMEs for parity 2026-08-29 07:24:43 -06:00
s4ge 37b29c3975 Docs(readme): add CI workflow badge to all four READMEs 2026-08-28 11:34:52 -06:00
jason5ng32andClaude Fable 5 7738a84dc0 Chore(changelog): note ASN info and connectivity graph improvements
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:20:24 +08:00
jason5ng32andClaude Fable 5 da0363dd86 Chore(ui): version the ASN panel API cache keys
Both ASN endpoints sit behind 30-day caches that also live in visitors'
browsers, where no purge reaches — a ?v= bump is the only way changed
payloads actually arrive. Start both at v=2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:20:24 +08:00
jason5ng32andClaude Fable 5 fd5aa25034 Feat(asn): add prefix, relationship and connection quality data to the ASN panel
Three new Radar segments on the asn view: BGP route stats (IPv4/IPv6
prefix counts), AS relationships (upstream / downstream / peering counts,
falling back to the local CAIDA snapshot when Radar has no rows), and
speed-test aggregates shown as an "average connection quality" group with
a source tooltip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:20:16 +08:00
jason5ng32 c4d2bbbafd Improvements 2026-08-28 15:44:18 +08:00
jason5ng32andClaude Fable 5 51238f289a Feat(ui): compact bundled layout for the ASN connectivity graph
Peering edges made star graphs (origin + 15+ Tier 1s) tall single columns
with per-edge lanes hatching the corridors. Take over positioning from
dagre (keep its layering/order): pin Tier 1s to one terminal column that
wraps into staggered sub-columns, stack and center columns, nudge
single-node columns crossed by skip edges, bundle edges per target, and
bend right after the source so long edges run at target height instead of
under boxes. Render peering dashed with legend entries in all full
locales, and release the Drawer's flex-1 hosts during capture so tall
graphs screenshot whole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 13:41:30 +08:00
jason5ng32andClaude Fable 5 68591df044 Feat(connectivity): draw Tier-1 peering and suppress misinferred providers
CAIDA's p2c-only walk missed how hypergiants actually reach the backbone
(settlement-free clique peering) and recursed into misinferred small
"providers", producing topologies unrelated to bgp.tools/Radar. Index p2p
rows, emit kind-tagged peering edges, and drop non-Tier-1 providers of
clique-adjacent nodes behind dual trusted bars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 13:41:10 +08:00
jason5ng32andClaude Fable 5 097fca6965 Refactor(api): consolidate Cloudflare Radar routes into one view-dispatched endpoint
/api/cfradar, /api/cfradar-traffic and /api/outages all proxied Cloudflare
Radar with their own route, guard wiring and cache TTL — every new Radar
dataset meant another copy of that boilerplate. All Radar data now rides
the single /api/cfradar route: ?view= dispatches over the RADAR_VIEWS
registry in common/cf-radar.js, where each view declares its guards,
edge-cache TTL and fetch function. cacheable() accepts a per-request TTL
resolver so 30-day profiles and the 1-hour outage feed share the route
without losing their cache behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 21:20:04 +08:00
jason5ng32 b4994b9418 Improvements 2026-08-27 10:59:18 +08:00
jason5ng32andClaude Fable 5 21311cc787 Fix(connectivity): replace Genius with Musixmatch in the music list
genius.com serves its favicon with Cross-Origin-Resource-Policy:
same-origin, so the browser blocks our cross-origin probe and the card
always reads as unreachable even though the site is fine. Musixmatch
covers the same lyrics ground and probes cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:45:55 +08:00
Jason Ng 87765d54bf Merge pull request #463 from WilliamK112/feat/music-connectivity-395
Feat(connectivity): add Music & Audio list
2026-08-27 10:43:15 +08:00
jason5ng32 da1b81ec49 Improvements 2026-08-27 10:32:09 +08:00
jason5ng32andClaude Fable 5 983382e628 Feat(connectivity): Organize tests into user-managed multiple lists
- Multi-list model in the connectivityLists preference
  (utils/connectivity-lists.js), migrated from the flat
  connectivityTargets / customConnectivityTargets eras; legacy keys
  kept for rollback, never written
- Header gains a list picker once a second list exists; in-page
  switching is per-visit, the startup list is a preference
- Curated imports pick a destination per list (or spin up a new list
  named after the preset); custom adds pick a destination once a
  second list exists
- Per-card retest moves to the latency figure (retry icon on failed
  cards); card titles optionally open the site in a new tab via a new
  setting, off by default (titles are plain text otherwise)
- Preferences gets a dedicated Connectivity section (default list +
  three switches) and a reworked two-line PrefRow layout that fixes
  vertical centering and tip wrapping
- List names allow 40 chars, truncated with an ellipsis where tight

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:07:47 +08:00
jason5ng32 78724bafb1 Improvements 2026-08-27 09:09:04 +08:00
WilliamK112andOpenAI Codex 57af5ff327 Feat(connectivity): add Music & Audio list
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-26 18:58:29 +08:00
Jason Ng 40900aef3a Merge pull request #461 from WilliamK112/feat/germany-connectivity-460
Feat(connectivity): add Germany country list
2026-08-26 18:49:49 +08:00
Jason Ng 244be604ab Merge pull request #462 from 9anna-na/fix/zh-tw-localization
Fix(i18n): refine Traditional Chinese terminology
2026-08-26 18:49:45 +08:00
jason5ng32 3c68dba490 Improvements 2026-08-26 18:20:23 +08:00
jason5ng32andClaude Fable 5 4af258a069 Feat(ipinfo): expose map and traffic panels inline in QueryIP
Split CountryTraffic and LocationMap into bare content panels plus
dialog shells, and turn the enableMap/enableCountryTraffic booleans
into mapMode/countryTrafficMode ('dialog' | 'inline' | 'off'). IPCard
keeps the dialogs; QueryIP — itself a Dialog, where stacking another
one is confusing — now offers both affordances as a single-select
Collapsible under the metadata grid, mirroring its ASN panels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 18:18:57 +08:00
jason5ng32andClaude Fable 5 d7e46c5dd5 Refactor(ipinfo): extract map dialog into LocationMap component
Verbatim markup move out of IpDetailPanel so the map lives at the same
component layer as CountryTraffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 18:01:38 +08:00
jason5ng32andClaude Fable 5 0c2b2d2bbe Feat(ipinfo): add country online-activity heatmap dialog
A weekday × hour heatmap of Cloudflare Radar HTTP traffic for the IP's
country, opened from a button beside the Country field. Country-level
rather than per-ASN because global ASNs have no single diurnal rhythm,
and ~250 cache keys let a 30-day edge cache absorb nearly every request.
Defaults to likely-human traffic with a switch for all traffic (the
other scope prefetches for instant flipping); hours render in the IP's
own timezone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 17:57:53 +08:00
9anna b256cb6809 Fix(i18n): refine Traditional Chinese terminology
Clarify Taiwan-facing terminology across IP details, WebRTC, DNS leak, advanced DNSSEC, ping, and MTR copy without changing the source meaning.\n\nCo-authored-by: OpenAI Codex <noreply@openai.com>
2026-08-26 16:53:31 +08:00
WilliamK112andCodex 955ab60ff0 Feat(connectivity): add Germany country list
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com>
2026-08-26 10:12:46 +08:00
jason5ng32 6da1e69d4a Improvements 2026-08-26 09:55:03 +08:00
jason5ng32 bf145173ac Improvements 2026-08-26 09:45:36 +08:00
Jason Ng 68e88bb709 Merge pull request #459 from jason5ng32/dev
Sync with Dev
2026-08-26 09:41:51 +08:00
jason5ng32andClaude Fable 5 00b21a73b0 Feat(i18n): promote Brazilian Portuguese to full locale status
- flip pt-BR to status:'full' in the locale registry
- add pt-BR boot-screen quips and slow hint to index.html, and list it
  in the JSON-LD inLanguage
- fill the productivity import-list name that landed after the pt-BR pack
- swap TRANSLATING.md's new-language walkthrough example to es-MX now
  that pt-BR is a real full locale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 09:30:12 +08:00
jason5ng32andClaude Fable 5 2ce2f3298e Docs(readme): restructure README and rebuild all translations
The feature list had fallen behind the product (v7.x additions missing,
stale copy) and 18 flat bullets scanned poorly. Regroup features into
five sections, adopt GFM alerts for the required configuration, collapse
the proxy rules into a details block, put Docker before Node, add the
missing License section, and fix long-standing typos. All five
translations are rebuilt from the new English text with tool names
aligned to each UI locale pack, plus a community-maintained disclaimer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 09:25:49 +08:00
Jason Ng f623c07eae Merge pull request #455 from vitorparras/i18n/pt-br-security-checklist
Feat(i18n): complete Brazilian Portuguese locale coverage
2026-08-26 09:20:07 +08:00
Jason Ng 338febf955 Merge pull request #458 from vitorparras/docs/pt-br-readme
Docs(readme): add Brazilian Portuguese README translation
2026-08-26 09:06:46 +08:00
Vitor Parras 7796304dcd Docs(readme): add Brazilian Portuguese README translation 2026-08-25 15:06:13 -03:00
Vitor Parras 1332db40c0 Fix(i18n): correct Brazilian Portuguese checklist and changelog 2026-08-25 14:44:25 -03:00
Jason Ng 2d5431af2d Merge pull request #457 from 9anna-na/feat/productivity-import-list
Feat(connectivity): add productivity import list
2026-08-25 21:41:31 +08:00
Jason Ng 2e6594d99d Merge pull request #447 from vsolano9/test/globalping-waitfor
test(globalping): wait on conditions instead of fixed delays
2026-08-25 21:31:50 +08:00
Victor Solano 7ed58d8330 Style: make waitFor a const arrow and trim its comment to current behavior
Both changes requested in review.

waitFor was declared with `async function`; AGENTS.md requires new functions
to use const arrow syntax, matching `tick` directly above it.

The comment's middle paragraph narrated the removed 80ms sleep and the
failure it produced. That history belongs to the PR, not the source. The
remaining text states only the current constraint: the run spans a POST plus
up to three real pollInterval timers, so no constant works, and the timeout
only bounds a hang.

Greptile's P1 on the `await tick()` before `scope.stop()` is deliberately not
applied, per the maintainer's analysis: Node fires expired timers in expiry
order and drains microtasks between callbacks, so the assertion, stop() and
clearTimeout all complete before the 50ms poll could run. Removing the tick
is the riskier direction.

No behavior change. tests/composable-globalping-measurement.test.js 22/22;
full `pnpm check` 1139/1139 plus build green. Re-ran the mutation control
after the edit: deleting clearTimeout from cancel() still fails exactly the
disposal case (21 pass, 1 fail), so the style change did not weaken it.
2026-08-25 14:42:40 +02:00
9annaandOpenAI Codex 70370ade01 Feat(connectivity): add productivity import list
Add a curated Work & Productivity preset with 12 globally unique HTTPS targets, committed favicons, and locale labels.

Refs #404.

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-08-25 20:17:32 +08:00
jason5ng32andClaude Fable 5 6433784309 Fix(ui): keep country-picker sticky tally flush once results render
sticky top-0 resolves against the scroll container's content box and
never covers the container's own padding, so the results-state layout
(where the padded wrapper itself became the scroll container) pushed
the tally down by py-3. Split overflow and padding into separate
layers so the scroll container is always padding-free, in all three
Globalping-backed tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:42:02 +08:00
Jason Ng c09eefe920 Merge pull request #456 from fly1d/codex/dns-sb-resolver
feat(dns): add DNS.SB resolver
2026-08-25 19:19:46 +08:00
fly1d 0449c22e29 Feat(dns): add DNS.SB resolver
Add DNS.SB as a verified EU resolver with UDP and JSON DoH endpoints for #392.

OpenAI Codex assisted with issue review, verification, implementation, and tests.
2026-08-25 11:27:34 +08:00
jason5ng32 abb91611a3 Improvements 2026-08-25 10:57:54 +08:00
Vitor Parras 3e82a51452 Feat(i18n): complete Brazilian Portuguese locale coverage 2026-08-24 23:48:48 -03:00
Jason Ng 56f4413d00 Merge pull request #454 from jason5ng32/dev
Sync with Dev
2026-08-25 10:17:23 +08:00
Vitor Parras aa8f6c3d14 Feat(i18n): complete Brazilian Portuguese locale coverage
Complete the remaining pt-BR translation datasets:
- add the Security Checklist translation;
- localize the complete release history.

Preserve checklist slugs, priorities, links, and the beta locale status.
2026-08-24 23:14:20 -03:00
jason5ng32 9c0e36a74a Improvements 2026-08-25 10:13:54 +08:00
jason5ng32andClaude Fable 5 cd5b25fec4 Fix(connectivity): correct the Justdial spelling
The India list entry misspelled the brand as Justdail in the id, the
display name, and the favicon filename; the URL already pointed at
justdial.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:01:36 +08:00
Jason Ng 13ce7cb1c8 Merge pull request #448 from iamprashanthchowdary/feat/india-connectivity-list
Feat/India-connectivity-list
2026-08-25 10:00:27 +08:00
jason5ng32 9b49bae493 Improvements 2026-08-25 09:46:47 +08:00
iamprashanthchowdary c480efac90 feat(i18n): add India to Traditional Chinese locale 2026-08-24 22:40:32 +05:30
iamprashanthchowdary 855a6f82b8 feat(connectivity): update India import list with new services as per suggestion 2026-08-24 22:39:43 +05:30
iamprashanthchowdary 107f6f8c99 chore(remove) : digilocker it was under cdn 2026-08-24 22:39:43 +05:30
iamprashanthchowdary 017e2b8878 Feat(connectivity): add India import list 2026-08-24 22:39:43 +05:30
jason5ng32 2cc732a027 Improvements 2026-08-25 00:24:24 +08:00
jason5ng32andClaude Fable 5 7b3594efee docs: add the Traditional Chinese README
Translate README_ZH into Taiwan-terminology Traditional Chinese, link it
from every README's language switcher next to Simplified Chinese, extend
the language-support line in all five, and count it among the maintained
READMEs in CONTRIBUTING.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:15:22 +08:00
jason5ng32andClaude Fable 5 eb16afb539 Feat(i18n): promote Traditional Chinese to full
Backfill zh-TW across all 163 changelog entries with Taiwan terminology
aligned to the UI pack, flip the registry status to full and group the
locale under Simplified Chinese as plain 繁體中文, add the zh-TW boot
copy index.html requires of full locales, and update the full-locale
enumerations in the contributor docs (including the stale 161-entry
changelog count).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:15:14 +08:00
jason5ng32andClaude Fable 5 050be6ecda Fix(checklist): repair the corrupted KeePass plugins link
The en source carried a nested-markdown corruption pointing the plugins
link at a nonexistent host; fr and ru inherited it verbatim. Restore
https://keepass.info/plugins.html (zh and zh-TW already had it right)
and localize the ru link text while there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:15:02 +08:00
Jason Ng 13f226fb72 Merge pull request #453 from dofy/seven/zh-tw-translation
Feat(i18n): add Traditional Chinese beta locale
2026-08-24 23:59:48 +08:00
Seven YuandCodex 7a3989fbb2 Feat(i18n): complete Traditional Chinese locale packs
Co-authored-by: Codex <codex@openai.com>
2026-08-24 23:37:28 +08:00
Seven YuandCodex 0828f0bb44 Feat(i18n): complete Traditional Chinese main locale
Co-authored-by: Codex <codex@openai.com>
2026-08-24 23:20:04 +08:00
Seven YuandCodex 0208d23025 Feat(i18n): add Traditional Chinese beta locale
Add the zh-TW locale registry entry and translate the core navigation, preferences, metadata, and utility copy as a partial beta pack.

Co-authored-by: Codex <codex@openai.com>
2026-08-24 22:59:32 +08:00
jason5ng32 abe608f98e Improvements 2026-08-24 22:06:46 +08:00
jason5ng32 70f9b84648 Improvements 2026-08-24 19:59:53 +08:00
Jason Ng a82a61292d Merge pull request #446 from vsolano9/fix/445-standalone-header-320
fix(header): keep the standalone header inside a 320px viewport
2026-08-24 19:52:48 +08:00
Victor Solano 9ab4505c60 test(globalping): pin the disposal timer cancellation
The disposal test asserted only that no poll fired, which onScopeDispose
satisfies through `disposed` alone — poll() returns early on it. Deleting
the clearTimeout left the whole test green, so the cancellation it is
named for was unpinned. It now requires the scheduled poll timer itself
to have been cleared.
2026-08-24 12:43:36 +02:00
Victor Solano c855c00e71 test(globalping): wait on conditions instead of fixed delays
tests/composable-globalping-measurement.test.js asserted after fixed
sleeps that had to cover a POST plus up to three real pollInterval
timers. "retries while in-progress" allowed 80ms for that chain and
failed under a loaded parallel suite with status still 'running' — a
wait that expired, reported as a wrong result. Measured 1 failure in 15
runs locally, and it failed two of three full `pnpm check` runs.

waitFor() polls the same observable the assertion reads, so each test
now takes as long as its work takes. Its timeout only bounds a hang.

The disposal test had the opposite problem: its 10ms sleep could land
before the POST resolved, leaving no pending timer for scope.stop() to
cancel, so it could pass without exercising cancellation. It now waits
for the create body to be read. Its trailing 80ms sleep stays — that
one asserts an absence, which no condition can prove.
2026-08-24 12:42:40 +02:00
Jason Ng 6eb5aad488 Merge pull request #444 from jason5ng32/dev 2026-08-24 18:42:20 +08:00
Victor Solano d920ddb69c fix(header): keep the standalone header inside a 320px viewport
The standalone page header (/tools/:slug, /privacy, /r/:id) scrolled
horizontally at 320px. The row is a flex line whose only shrinkable item
is the breadcrumb title, so once that collapsed to zero the row still
needed brand (132.3px) + separator + three gaps + px-4 + the back
button, which is 148.2px in en and 178.5px in pt-BR.

Making the back button icon-only below sm frees that budget: overflow
goes to zero in every locale, and the breadcrumb gets 91.2px at 320px,
so it renders text there for the first time instead of a dangling "/".

sr-only rather than hidden keeps the button's accessible name in every
locale at every width, and being out of flow it costs no gap-2. The
sm and wider layout is unchanged.

Closes #445.
2026-08-24 12:40:03 +02:00
jason5ng32andClaude Fable 5 0a0a739edd Style: fix a misplaced guard comment and two quoting nits
The provider-id comment block sat above requireValidRecordType; move it
back over requireValidProviderId. Also drop needless quote escapes in
the resolver-check script and match the ui/ double-quote style in
ButtonGroupSeparator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:21:38 +08:00
jason5ng32andClaude Fable 5 f2df6e669d Fix(dnsresolver): keep the queried hostname out of availability warns
Node DNS errors carry the looked-up name in err.message, and warn-level
logs mirror to telemetry; the availability branch now logs only the
error code, which is the whole signal for a timeout anyway. The
local-only debug branch keeps the full error for diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:21:31 +08:00
jason5ng32andClaude Fable 5 3e3fb7bdb7 Fix(dnsresolver): keep CNAME answers out of the DoH SOA result
A SOA query for an aliased name returns the CNAME chain in Answer with
the zone SOA in Authority; taking Answer verbatim rendered the CNAME
target as the SOA value and made the row disagree with its UDP twin.
Filter SOA answers by record type on both sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:21:20 +08:00
jason5ng32andClaude Opus 5 2faadd4527 Chore(tests): add the bridge spec for the DNS record type list
Every common/ helper re-exported through frontend/utils/ is supposed to
ship a spec that imports both paths and asserts they agree — that is what
stops a bridge from quietly regrowing a second implementation. The
dns-record-types bridge shipped without one.

Also pins the two invariants that would otherwise fail silently: the
lookup set matching the ordered list, and every name-valued type actually
being a supported one, since a type missing from the list would skip
withRootDot and surface as punctuation noise rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:11:23 +08:00
jason5ng32andClaude Opus 5 d31785c6dd Style(i18n): rewrite the DNS resolver intro copy
The old text was written years ago for one audience: it led with
operators contaminating domains for political reasons and warned that
some built-in providers were Chinese. Naming a country in product copy
is not something we do any more, and it buried what the tool is.

The new copy answers three questions instead — what it does, what a
difference between providers usually means, and how to run it. Filtering
and redirection are still named as a cause, just as a phenomenon rather
than as anyone's fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:41 +08:00
jason5ng32andClaude Opus 5 fa7dfc901d Refactor(ui): rebuild the DNS resolver's query row and region filter
Eight record types no longer fit one line as toggle buttons, and the
region filter grows every time a resolver from a new country is added —
both were solved by wrapping, which is what the connected ToggleGroup
form is worst at: its border-l-0 seam only reads as one bar on a single
line and breaks apart the moment it wraps.

So the record type becomes a Select joined to the input inside a
ButtonGroup, with the run button beside it — one row at every width, the
trigger floored at CNAME's width so switching type does not jolt the row.
The region filter becomes detached pills, the same shape IPHistory uses,
which wraps cleanly at any number of countries.

The type list now comes from common/, so the picker cannot drift from
what the backend guard accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:41 +08:00
jason5ng32andClaude Opus 5 d05c6aac0c Feat(api): whitelist the DNS resolver's record type
`?type=` was never validated. The UDP branch fell through to a throw and
returned N/A, but the DoH branch interpolated whatever arrived straight
into the upstream URL, which made the route a query proxy for record
types we never intended to support.

requireValidRecordType checks it against DNS_RECORD_TYPES and uppercases
it in place, the same way requireValidDomain lowercases its param — so
`?type=caa` now works as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:41 +08:00
jason5ng32andClaude Opus 5 c122df73be Fix(dnsresolver): make one provider's DNS and DoH rows read identically
Every provider contributes two rows to the results table, and the whole
premise of the tool is that a difference between rows means something
happened. Three cosmetic mismatches were reading as real disagreements:

- SOA dropped the trailing root dots that the DoH path returns, though
  the MX branch had appended them for that exact reason.
- CNAME and NS had the same gap; both transports now normalize through
  withRootDot, which also covers a DoH endpoint that strips them.
- A SOA query below the zone apex returned N/A on every row, because the
  answer arrives in the authority section. dohRecords now falls back to
  it, filtered to the SOA type so a stray NS never leaks in.

Also drops the CAA tag-collision fallback that arrived with #442: it
guessed at a record shape that cannot occur, and admitted in its own
comment that the flag was unrecoverable in the case it handled.

The shared record-type list lands here because withRootDot needs to know
which types are name-valued; the guard and the picker read it next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:25 +08:00
jason5ng32andClaude Opus 5 aa497fb526 Fix(valid-ip): accept underscored service labels in domain names
The pattern rejected any name containing an underscore, so the DNS
resolver could not look up `_dmarc.example.com` or a `_domainkey`
selector — the two most common TXT lookups there are — even though TXT
has been a supported record type all along.

Underscore is allowed as a label prefix only, per RFC 8552, and never in
the TLD: `_dmarc.example.com` passes, `has_underscore.com` and
`example._com` still do not.

Widening the shared helper rather than adding a DNS-only variant also
lets Whois and the OONI route reach the same names; both simply fail
upstream on a name their provider has nothing for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:25 +08:00
jason5ng32andClaude Opus 5 a13e89fbae Chore(ui): add the shadcn-vue button-group primitive
Needed to join the DNS resolver's record-type Select and its input into
one bordered control. Added through the shadcn-vue CLI, so the files stay
verbatim upstream output and can be re-synced.

The same run resynced ui/separator, which still carried a hand-written
early-refactor version, to current upstream — data-slot and
data-[orientation] variants instead of a JS ternary, behaviourally the
same for all three existing callers. It also refreshed one transitive
postcss patch in the lockfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:07:05 +08:00
Jason Ng 820e2ff24d Merge pull request #442 from vsolano9/feat-400-dns-soa-caa
Feat(dnsresolver): support SOA and CAA record lookups
2026-08-24 17:26:34 +08:00
Jason Ng 8e92e19919 Merge pull request #441 from fly1d/codex/dns-resolver-health
Chore(dns): add scheduled resolver health checks
2026-08-24 17:14:50 +08:00
fly1dandOpenAI Codex 79a5269fbc Chore(dns): align resolver diagnostics with runtime usage
Keep resolver health checks on demand and surface actionable runtime availability failures through the shared logger. Remove the GitHub Actions workflow so transient resolver connectivity does not affect code CI.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-24 16:44:46 +08:00
Victor SolanoandClaude ffa5a0715c Fix(dnsresolver): recover a CAA tag named like a metadata field
RFC 8659 allows any alphanumeric property tag, `type` and `critical`
included. Node writes the tag onto the record under its own name, so such
a tag overwrites the metadata field of the same name and the scan for a
non-metadata key finds nothing. Destructuring undefined then threw, and
the surrounding handler turned an otherwise valid answer into N/A for
that resolver.

The tag is now recovered from whichever field stopped holding a metadata
value: `type` is otherwise always the constant 'CAA', and `critical` is
otherwise always a number. A tag named `critical` displaces the flag
itself, which Node has already lost by that point, so it falls back to 0.

Reported by review on #442. The suggested fix there was to stop excluding
`type`, which would break every ordinary record: a live google.com answer
is `{critical: 0, type: 'CAA', issue: 'pki.goog'}`, and without the
exclusion the scan picks `type` and renders `0 type "CAA"` instead of
`0 issue "pki.goog"`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 10:34:47 +02:00
jason5ng32andGPT-5 3543f6920b Fix(i18n): correct pt-BR privacy wording
Align the telemetry disclosure with the English source so the named Sentry recipient does not contradict a broader claim that data is never shared with third parties.

Co-Authored-By: GPT-5 <noreply@openai.com>
2026-08-24 16:33:38 +08:00
Jason Ng ca726d063d Merge pull request #443 from akidsfree/codex/pt-br-privacy-policy
Feat(i18n): add Brazilian Portuguese privacy policy
2026-08-24 16:32:31 +08:00
jason5ng32andGPT-5 1611c6b701 Refactor(dns): centralize hostname validation
Route DNS resolver requests through the shared domain guard so hostname presence, syntax, and canonical lowercasing follow the same contract as other domain-taking APIs.

Co-Authored-By: GPT-5 <noreply@openai.com>
2026-08-24 16:16:36 +08:00
akidsfreeandCodex f3a0d92a72 Feat(i18n): add Brazilian Portuguese privacy policy
Completes the 53-key privacy dataset for pt-BR.

Co-authored-by: Codex <codex@openai.com>
2026-08-24 16:06:11 +08:00
Victor SolanoandClaude 624656bd32 Feat(dnsresolver): support SOA and CAA record lookups
Add SOA and CAA to the DNS resolver's record types, resolved through the
existing backend switch. The DoH path is unchanged.

SOA is formatted in DNS presentation order. CAA property tags are derived
from each record's own key rather than a fixed whitelist, so a
provider-specific tag renders as itself instead of collapsing to a
placeholder. Node's Resolver returns `critical` and `type` as metadata
alongside that one extensible tag, so both are excluded when picking it.

Eight record types no longer fit one row on a phone, where the control
previously pushed past its parent. The selector is now a four-column grid
on narrow viewports and stays a single row from `md` up.

Formatting is exported and covered offline by unit tests, including a
provider-specific CAA tag. No test performs a real DNS lookup.

Closes #400

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 09:49:11 +02:00
fly1dandOpenAI Codex bb8caa6cc2 Chore(dns): add resolver health checks
Run a daily and dev-push probe for every curated UDP resolver, checking recursive answers and honest NXDOMAIN responses. Add mocked unit coverage and publish a machine-readable report in the Actions summary.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-24 11:13:47 +08:00
Jason Ng c9077af057 Merge pull request #439 from fly1d/codex/fdn-resolver
Feat(dns): add CZ.NIC ODVR (Czechia) resolver
2026-08-24 10:50:59 +08:00
Jason Ng b3b11f26e3 Merge pull request #440 from fly1d/codex/yandex-resolver
Feat(dns): add Yandex.DNS (Russia) resolver
2026-08-24 10:50:39 +08:00
Jason Ng 3ae4ab74e5 Merge pull request #438 from fly1d/codex/quad101-resolver
Feat(dns): add HiNet and GIGA (Taiwan) resolvers
2026-08-24 10:49:59 +08:00
fly1dandOpenAI Codex 8bb9dc777b Feat(dns): add Yandex.DNS (Russia) resolver
Add Yandex.DNS (77.88.8.8), a reachable Russian public resolver, to the curated list. UDP-only: Yandex's DoH endpoint speaks RFC 8484 wire format rather than the application/dns-json API this endpoint queries.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-24 10:35:01 +08:00
fly1dandOpenAI Codex cbc562a332 Feat(dns): add CZ.NIC ODVR (Czechia) resolver
Retarget the rescoped Europe-resolver issue (#392) to CZ.NIC's ODVR (193.17.47.1), a verified reachable European public resolver run by the .cz registry. UDP-only: its DoH endpoint does not serve the JSON API.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-24 10:23:42 +08:00
fly1dandOpenAI Codex 7f8051bdc7 Feat(dns): add HiNet and GIGA (Taiwan) resolvers
Replace the since-discontinued Quad101 with two verified Taiwan resolvers (issue #391 was rescoped to reachable public resolvers):

- HiNet (Chunghwa Telecom) 168.95.1.1
- GIGA (Taiwan Fixed Network) 203.133.1.6

Both are UDP-only (no JSON DoH endpoint). Also add a contributor rule to the data-file header requiring that a resolver actually answer with the ra flag before a PR, so future additions verify reachability instead of trusting published docs.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-24 10:22:43 +08:00
Jason Ng ccfa0dcc74 Merge pull request #437 from jason5ng32/dev 2026-08-24 08:51:32 +08:00
Jason Ng 3f80f9118e Merge pull request #435 from taljeon/codex/fix-434-node24-requirement
docs: require Node.js 24 for contributor setup
2026-08-24 00:03:38 +08:00
taljeon e9808e31db docs: require Node.js 24 for contributor setup 2026-08-24 00:52:40 +09:00
Jason Ng 4c22558a3f Merge pull request #433 from VedantMadane/fix-430-advanced-tools-nav-truncation
fix(nav): prevent truncation of long tool names in mobile drawer and desktop dropdown (#430)
2026-08-23 23:20:59 +08:00
Jason Ng dd759863df Merge pull request #431 from MFA-G/fix/connectivity-status-overflow
Fix(ui): keep the connectivity status label inside its card
2026-08-23 23:20:31 +08:00
Vedant Madane b56b748df6 fix(nav): prevent truncation of long tool names in mobile drawer and desktop dropdown (#430)
Widen the mobile navigation drawer from w-72 to w-80 and the desktop dropdown grid from min-w-[24rem] to min-w-[28rem]. Replace truncate with leading-snug on tool item buttons so long translated tool names (such as in Portuguese and French) wrap neatly instead of being clipped with an ellipsis.

Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com>
2026-08-23 19:45:15 +05:30
Fadhlan 40ccec123c Fix(ui): keep the connectivity status label inside its card
On phones the connectivity cards sit two per row, and the "Awaiting Test"
label in the longer locales (pt-BR 16 chars, fr 18) pushed past the card
edge: the span carried `whitespace-nowrap` with nothing to clip it, and
`font-mono` cost roughly 20% extra width for what is prose, not data.

The status row now drops one size on phones only (`text-sm md:text-base`),
the label loses `font-mono` and gains `truncate` with a `:title` so the
full string stays reachable, and `shrink-0` on the latency value makes the
label give way first. The label stays on a single line, so a card keeps
its height when a run finishes and the page does not jitter on refresh.

Closes #429
2026-08-23 20:15:29 +07:00
jason5ng32andClaude Opus 5 2f0af8e241 Fix(ui): stop Safari shifting the select trigger's value
The shadcn default geometry (h-9 + py-2 + text-sm) leaves an 18px content
box for a 20px line, so the button content overflows by 2px. Safari
re-centers that overflow only when the content changes, which nudged the
selected label up ~2px on every pick; Chrome stayed put. py-1 gives the
line room, and the value span drops -webkit-box line-clamp for real
flexbox so its vertical position no longer depends on legacy layout.

SelectValue keeps ellipsis for plain text by wrapping the default label in
a truncating span — as a flex child, a bare text node can't truncate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:42:22 +08:00
Jason Ng 5fe62ba000 Merge pull request #427 from jason5ng32/dev
Dev
2026-08-23 13:37:55 +08:00
jason5ng32andClaude Fable 5 58bd271d8b Refactor(ci): drop the i18n PR comment bot
Republishing PR-computed content under the bot identity needed ever more
laundering (fences, then schema validation) and the residue was still
reviewer-flagged — the data originates from code the PR controls, so no
amount of validation closes that. The convenience isn't worth the
privileged surface: the CI job summary already shows the same dashboard
from inside the PR's own sandbox, so the sticky comment, its artifact
hand-off and the --json mode all go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:26:28 +08:00
jason5ng32andClaude Fable 5 10b3368f67 Fix(ci): render the i18n comment from validated numbers only
Even fenced plain text from the PR's run let a hostile PR speak in the
bot's voice. The artifact now carries a structured --json snapshot
(counts, locale codes, key paths); the privileged side validates every
field against enums, integer bounds and tight regexes, then renders the
comment text entirely with trusted code. A hostile PR can at most
misstate its own coverage numbers — it cannot emit prose, links or
instructions as the bot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:41:00 +08:00
jason5ng32 5217815f54 Improvements 2026-08-23 12:28:43 +08:00
jason5ng32andClaude Fable 5 16d3e1675c Fix(ci): treat the i18n report artifact as untrusted text
The report is produced by the PR's own code, so the privileged comment
workflow no longer republishes its Markdown: the artifact now carries
raw text, and the trusted side strips backticks, caps the size and
renders it inside a code fence it authors itself. Also paginate the
sticky-comment lookup past 100 comments, and move both new workflows'
purpose headers to line one per the file-header convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:27:45 +08:00
jason5ng32andClaude Fable 5 b411af164b Fix(i18n): shorten pt-BR navbar labels
Four labels wrapped to two lines and misaligned the desktop navbar;
bring them down to the length the fr/ru packs already fit in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:27:45 +08:00
jason5ng32andClaude Fable 5 09b63acc12 Feat(ci): greet newly opened PRs
First-time contributors get the full onboarding message, returning
contributors a shorter welcome back; maintainer and bot PRs are
skipped. No PR code is ever checked out or executed here, which is
what makes pull_request_target safe for this job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:00:21 +08:00
jason5ng32andClaude Fable 5 46a42b1e65 Feat(ci): report translation status on locale PRs
CI renders the i18n-status dashboard into the run summary and, when a
PR touches locale data, hands it to a workflow_run job as an artifact;
that job upserts one sticky PR comment. Split in two on purpose: the
report is computed under the read-only token fork PRs get, and only
trusted default-branch code holds the write token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:00:21 +08:00
jason5ng32andClaude Fable 5 29ae0ff45f Fix(i18n): refine four pt-BR strings
Post-merge polish on #426: 'Visitor share' and the persona-check
header/script labels named the wrong referent, and the service-status
banner read as 'operating systems'; keep the banner short enough for
its card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:00:21 +08:00
jason5ng32 4a2085abac Style(preferences): switch Beta badge to outline variant 2026-08-23 12:00:10 +08:00
jason5ng32 8f2751b15f Fix(logging): log 403 IP-source fail-over as warning
A source declining with 403 is a fail-over, not a defect — keep it below
the level error monitoring captures, while every other failure stays an
error so a real outage still surfaces.
2026-08-23 12:00:10 +08:00
jason5ng32 89cbb35401 Chore(deps): bump @cloudflare/speedtest and vite 2026-08-23 12:00:10 +08:00
Jason Ng 865f4c8c94 Merge pull request #426 from vitorparras/i18n/pt-br-ui-translation
Add Brazilian Portuguese beta UI translation
2026-08-23 11:42:22 +08:00
Vitor Parras 36d6177d15 Feat(i18n): expand Brazilian Portuguese UI translation 2026-08-22 16:27:41 -03:00
Vitor Parras da8698f308 Feat(i18n): add Brazilian Portuguese beta locale 2026-08-22 14:09:46 -03:00
Jason Ng c978bfb367 Merge pull request #425 from jason5ng32/copilot/fix-badge-vertical-alignment 2026-08-23 00:56:33 +08:00
copilot-swe-agent[bot]andjason5ng32 8c6e145513 Style(preferences): fix Beta badge vertical alignment
Co-authored-by: jason5ng32 <1546290+jason5ng32@users.noreply.github.com>
2026-08-22 16:51:18 +00:00
Jason Ng 030ce343c2 Merge pull request #424 from jason5ng32/copilot/update-change-log-for-developer 2026-08-23 00:43:33 +08:00
copilot-swe-agent[bot]andjason5ng32 0b44f3c25e Fix(i18n): correct "For Forker" typo to "For developers" in changelog
Co-authored-by: jason5ng32 <1546290+jason5ng32@users.noreply.github.com>
2026-08-22 16:39:11 +00:00
Jason Ng c92c13683d Merge pull request #423 from jason5ng32/dev 2026-08-22 22:41:22 +08:00
jason5ng32 0549deca90 Improvements 2026-08-22 22:32:12 +08:00
jason5ng32andClaude Fable 5 21792cbf11 Docs: present ipcheck.ing as the demo deployment, not the project
The contributor docs read as if the project and the hosted site were one
thing; a "(demo: …)" tag makes clear IPCheck.ing is one deployment of a
toolbox anyone can self-host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:31:00 +08:00
jason5ng32andClaude Fable 5 9a534e6497 Docs(community): route security, conduct and support reports to the right channels
SECURITY.md used to ask for vulnerability reports in public issues while
requesting no public disclosure in the same breath; it now points at
GitHub's private advisory form, states the latest-release-only support
policy and scopes out non-code findings. The Code of Conduct's
enforcement contact — a literal blank since the file landed — now uses
the same private form. New SUPPORT.md and an issue-chooser config send
questions to Discussions Q&A and docs before they become issues, and
CONTRIBUTING drops its stale "questions also go to Issues" line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:30:49 +08:00
Jason Ng 31077dd808 Merge pull request #417 from jason5ng32/dev
Improve i18n Arc
2026-08-22 22:16:22 +08:00
jason5ng32 599d670e6c Improvements 2026-08-22 22:02:22 +08:00
jason5ng32 b6cf3132fc Improvements 2026-08-22 21:42:28 +08:00
jason5ng32andClaude Fable 5 f38c169159 Refactor(i18n): retire the ?lang allow-list, resolution lives with the data
common/langs.js is gone. Its list conflated two unrelated things — the
UI's locales and the languages upstream data comes in. The MaxMind set
(verified against the shipped City database) now lives in
maxmind-service.js, where lookupMaxMind family-normalizes any raw tag
itself, so de/es/ja/pt-BR finally serve their real localized names and
zh-TW-class variants resolve into their family instead of English. The
dns-leak proxy forwards ?lang untouched — its upstream owns resolution —
retiring the odd zh-CN default for langless calls. Net effect: no ?lang
allow-list anywhere, and a new UI locale is a front-end-only change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:30:38 +08:00
jason5ng32andClaude Fable 5 a9240b5e5b Chore(i18n): realign zh/fr key order with en
Pure reorder — key sets and values verified identical before and after.
The packs drifted under years of hand-maintenance; aligning them once
keeps every future pnpm i18n-sync diff free of position noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:18:30 +08:00
jason5ng32andClaude Fable 5 3d1e98884a Docs(i18n): TRANSLATING.md and a partial-translations-welcome contribution path
TRANSLATING.md walks a contributor from pnpm i18n-new to a mergeable
partial pack: the skeleton convention, the registry line, what the gate
enforces, the fallback chain users see, and the beta-to-full promotion
bar. CONTRIBUTING flips its "adding a locale is heavier than it looks"
warning into an invitation, AGENTS.md restates the i18n rule in
full/beta terms, and the README points translators at the guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:17:20 +08:00
jason5ng32andClaude Fable 5 e61a1992f3 Feat(i18n): empty-value skeletons with scaffolding and build-time pruning
A beta pack is now en's full skeleton with untranslated values left as
"", so translation review is always a `"" -> text` diff and English left
in place can't masquerade as translated. pnpm i18n-new scaffolds a pack
and its registry line (--privacy / --checklist for the optional files,
which ship finished or not at all); pnpm i18n-sync realigns packs when
en changes. The gate flips accordingly (exact key parity everywhere, ""
legal only in beta main packs) and a Vite plugin prunes empty values at
build time, so shipped chunks carry only translated strings and ""
behaves exactly like an absent key at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:17:20 +08:00
jason5ng32andClaude Fable 5 084fe65eaa Feat(i18n): hold index.html's inline copy to the locale registry
index.html can't import the registry, so its boot quips, JSON-LD and
inline language pick have always been synced by hand. A new spec now
holds them to it: full locales must cover the boot copy (beta ones are
exempt), JSON-LD inLanguage mirrors the full locales' htmlLang — fixing
the imprecise "zh" — and a sentinel evaluates the inline matcher against
matchLocale so the two can't drift apart silently. The inline pick
itself gains the same exact/base/family steps, closing the split where
?hl=zh-CN booted in one language and mounted in another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:17:20 +08:00
jason5ng32andClaude Fable 5 6301d8b838 Feat(i18n): beta locale support with fallback chains, pack gate and status dashboard
A locale can now ship partially translated: registry status drives a Beta
badge in the picker, missing keys walk variant -> base -> en (one chain
definition feeds vue-i18n, the privacy/checklist datasets and the
changelog), and matchLocale gained family matching so pt-PT finds a
pt-BR pack. Changelog history is only required of full locales, removing
the biggest barrier for new-language PRs. tests/locale-packs.test.js is
the hard gate a pack must pass; scripts/i18n-status.js (un-gitignored
for contributors) reports coverage without ever failing. Only visible
behavior change: ?hl= resolves regional tags (?hl=zh-CN -> zh) instead
of dropping to English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 20:13:14 +08:00
jason5ng32andClaude Fable 5 30da86042f Refactor(i18n): central locale registry with glob discovery and exact-first matching
One registry (common/locale-registry.js) now owns the locale list; the
lazy-load maps, picker options, <html lang>, zh->zh-CN mappings and test
locale lists all derive from it, so adding a language becomes one JSON
pack plus one registry line. Browser-language detection matches exact
tags before base languages, clearing the path for regional packs like
zh-TW. Pure refactor: behavior for the four shipped locales is unchanged
except the picker now renders in registry order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:50:35 +08:00
Jason Ng 5bb9dd4113 Merge pull request #416 from jason5ng32/dev
Dev
2026-08-22 18:49:48 +08:00
jason5ng32andClaude Fable 5 daac0b7535 Fix(ipinfo): reset completion flags on whole-grid refresh
A whole-grid ipinfo:refresh left fetchStatus set from the previous pass,
so the first card to settle re-emitted ipinfo:finished and resolved the
command early with a mix of fresh and stale cards. Clearing the flags at
the top of checkAllIPs makes the snapshot wait for every card again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:36:41 +08:00
jason5ng32andClaude Fable 5 8b2e49b57e Docs(readme): add a Contributing section and open the door to README translations
The READMEs — the project's highest-traffic pages — carried no
contribution invitation. All four gain a short Contributing section
pointing at the good-first-issue label and CONTRIBUTING.md, which now
also documents community-maintained README translations (English stays
canonical) as a starter area distinct from UI translations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:19:38 +08:00
豆腐たそandtasodoufu c291e07794 Docs(prefs): fix stale locale comment (tr → ru) (#409)
Co-authored-by: tasodoufu <313684430+tasodoufu@users.noreply.github.com>
2026-08-22 18:04:13 +08:00
jason5ng32andClaude Fable 5 06d25f373b Docs(contributing): rewrite onboarding and slim the GitHub templates
CONTRIBUTING now reflects the real workflow (pnpm-only setup, dev-in
dev-out, pnpm check) and points contributors at the AGENTS.md files
and the online Developer Guide. The bug report template drops
self-host-only fields most reporters can't fill; the PR checklist
shrinks to five verifiable items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 17:23:57 +08:00
jason5ng32andClaude Fable 5 a28aaeba0f Feat(dnsresolver): country-annotated resolver list with region filter
Move the resolver list into api/data/dns-resolvers.js as structured
per-country data so adding a resolver becomes a one-object PR, gated
by a new data-integrity test. The API returns one flat annotated list
and the UI grows badge-style region filter chips (flags + localized
names via Intl.DisplayNames) above the result table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 17:23:50 +08:00
jason5ng32andClaude Fable 5 d325144f83 Feat(connectivity): ship the favicon fetcher and auto-heal missing icons
The fetch script was gitignored while the data test hard-required its
output, so outside contributors were blocked by a tool they couldn't
see. The script is now committed (pnpm fetch-favicons) and the data
test auto-downloads missing icons on local runs; CI stays offline and
keeps enforcing that PNGs are committed. Also adds a locale-coverage
check for list names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 17:23:41 +08:00
jason5ng32andClaude Fable 5 1e6608fd18 Chore(changelog): v7.5.0 beta notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:33:33 +08:00
jason5ng32andClaude Fable 5 f6ef42f98f Docs(agents): slim frontend/AGENTS.md back to 150 lines
Drop what the file structure and header comments already say; keep rules,
deliberate exceptions, and traps. Documents the command bus alongside the
event bus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:33:33 +08:00
jason5ng32andClaude Fable 5 f0aa85588d Refactor(ui): turn InfoBanner into a data-driven per-section promo slot
Every homepage section ends with one fixed slot; frontend/data/banners/
<section>.js (gitignored — deploy-time data, placing a file IS the decision
to show it) decides what renders. Data contract in utils/banners.js: inline
four-language copy, url+utm or internal to target, requireSettled /
transition / sweep switches, BannerClick_<section> analytics. The Enhanced
DNS Leak promo moves from hardcoded component copy into the mechanism, so
its locale keys retire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:33:27 +08:00
jason5ng32andClaude Fable 5 0907d563af Feat(nav): let the docs assistant run the visitor's core tests
Second GitBook tool run_my_tests rides the command bus: optional sections
subset, navigate home first when needed, waitForAppCommand + dispatch per
test with a 60s cap, allSettled partial-failure semantics, and the same
snapshot shape the read tool returns plus ran/failed lists. Confirmation
label and summary land in all four locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:33:05 +08:00
jason5ng32andClaude Fable 5 4ae3646700 Feat(core): add the app command bus and route cross-component triggers through it
Events keep saying "this happened"; commands say "do this" — single owner,
dispatch resolves when the work finishes with the owner's result, reject
codes unavailable/timeout (bus) and auth/quota/input (owners, via
appCommandError). Homepage sections register ipinfo:refresh /
connectivity:run / webrtc:run / dnsleak:run / speedtest:toggle; keyboard
shortcuts, the refresh orchestrator, and Persona Check's dependency repair
dispatch instead of reaching through template refs, and Home.vue sheds the
test-section refs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:32:57 +08:00
Jason Ng 60cac96e03 Merge pull request #388 from jason5ng32/dev
Dev
2026-08-22 10:48:28 +08:00
jason5ng32 46ce6138e0 Improvements 2026-08-22 10:37:57 +08:00
jason5ng32 4df8b78ca6 Improvements 2026-08-22 10:37:45 +08:00
Jason Ngandgreptile-apps[bot] f98ee53aa2 Update frontend/locales/fr.json
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-21 21:41:50 +08:00
jason5ng32 40929b7581 Improvements 2026-08-21 21:35:50 +08:00
jason5ng32 6c70d47941 Improvements 2026-08-21 21:27:50 +08:00
jason5ng32 41b635483d Improvements 2026-08-21 09:20:59 +08:00
jason5ng32andClaude Fable 5 e4ae72cae7 Fix(shortcuts): Register reactively instead of racing configs on a 2s timer
Shortcuts now register on mount (base keys work from first paint) and the
map rebuilds when /api/configs lands, so config-gated keys (i/D/P/p) no
longer vanish when the fetch loses the old 2-second race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:18:19 +08:00
jason5ng32andClaude Fable 5 d711b7be82 Feat(pulse): Two-tier gating — outage feed no longer needs the pulse beacon backend
The Earth Online entry and panel now show on any deployment whose backend
has CLOUDFLARE_API_KEY (runtime cloudFlare configs flag); the social
sections (composer / feed / visitor map / beacon) stay gated on the pulse
beacon backend. VITE_PULSE_URL is renamed to VITE_PULSE_BEACON_URL, no
legacy fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:04:34 +08:00
jason5ng32 80c0d880d3 Improvements 2026-08-21 08:21:16 +08:00
jason5ng32andClaude Fable 5 b5b60a0406 Refactor(ui): Move ToolLoadingSkeleton into components/ui
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:39:45 +08:00
jason5ng32andClaude Fable 5 0f0160da99 Feat(ui): Skeleton for tool chunk loading, in-context spinners for tool data
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:33:25 +08:00
jason5ng32andClaude Fable 5 d50befc22d Feat(ui): Show a spinner while a lazy tool component loads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:44:55 +08:00
jason5ng32andClaude Fable 5 df3771292d Fix(connectivity): Sanitize the stored target set on load
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:39:53 +08:00
jason5ng32andClaude Fable 5 c85e370ece Feat(connectivity): Add the Brazil Essentials import list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:38:51 +08:00
0e8db72855 Fix(ui): Move focus into the drawer on open
vaul-vue swallows open-auto-focus, so the trigger stayed focused inside
#mainpart while it was aria-hidden — Chrome blocked that, and Tab then
walked the hidden page. Also drop the leftover Bootstrap scrollspy
tabindex wrapping the homepage.

Co-Authored-By: Cursor Grok 4.6 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:24:59 +08:00
jason5ng32 3f9feb1143 Improvements 2026-08-20 22:03:21 +08:00
jason5ng32andClaude Fable 5 95401e3100 Fix(connectivity): Scope writes to the current run and verdicts to the pass's own targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:37:34 +08:00
jason5ng32 8d94aaa14d Improvements 2026-08-20 21:29:28 +08:00
jason5ng32andClaude Fable 5 cc2442fbea Fix(connectivity): Settle-time aggregate over surviving targets, fail fast on unknown default ids
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:22:44 +08:00
jason5ng32andClaude Fable 5 e2c0d8d1ca Feat(connectivity): User-managed target set with removable presets, plus multi-round latency bars
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:15:38 +08:00
jason5ng32 e757c619ea Improvements 2026-08-20 16:53:54 +08:00
jason5ng32 1776107065 Improvements 2026-08-20 16:38:33 +08:00
jason5ng32 2eddb7287e Improvements 2026-08-20 11:35:55 +08:00
jason5ng32 a703442ba8 Improvements 2026-08-20 11:34:47 +08:00
jason5ng32 89c4327969 Improvements 2026-08-20 11:33:32 +08:00
jason5ng32andClaude Fable 5 ccac693c48 Fix(ui): Drop shortcut actions queued when an overlay opens
The dispatcher checked the overlay state on keydown, but actions run
from the 10ms key-pool timer — an overlay opening inside that window
still had the queued action fire behind it. The callback now rechecks
before running and clears the pool either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:23:29 +08:00
jason5ng32andClaude Fable 5 e548b6e1e9 Fix(ui): Anchor ASN graph gesture zoom on the SVG rect
The focal-point math measured from the scroll container's edge, so the
px-4 padding and the m-auto centering offset made the graph drift away
from the pinch midpoint. Anchoring on the SVG's own rect and correcting
against a post-render re-measure keeps the point under the fingers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:23:29 +08:00
jason5ng32andClaude Fable 5 0cc7efc3d2 Fix(ui): Show IP History addresses in the clear
The panel is opt-in and easily closed, so the global info mask only
hurt readability there — masking targets the main page a screenshot
would capture, not a panel the visitor chose to open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:23:16 +08:00
jason5ng32 802507ee6f Improvements 2026-08-20 11:05:33 +08:00
jason5ng32andClaude Opus 5 f5d2601589 Fix(ui): Suspend keyboard shortcuts while an overlay is open
Every shortcut in the map is a home-page action, so firing one from
behind a Dialog, Sheet or Drawer acted out of sight — and the
network-heavy ones (speed test, connectivity, DNS leak) spent the
visitor's bandwidth on a result they could not see. Pressing "?" inside
the Advanced Tools drawer also stacked the Help dialog on top of it.

The dispatcher now ignores keystrokes while any overlay is open. The
rule keys off the component's form rather than its purpose: the ui/
roots register themselves through use-overlay-shortcuts.js, so anything
built on them inherits it and overlays nest. Esc and the native
scrolling keys are untouched.

Drops the "f" fullscreen shortcut, which only ever acted on an open
drawer; the drawer header keeps its button.

Also fixes two long-standing dispatcher bugs the same code touches:
registration appended to a module-level map, so re-mounting Home (route
away and back) stacked a second copy of every action and left the first
pointing at unmounted refs; and the typing guard covered INPUT but not
TEXTAREA or contenteditable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:47:31 +08:00
Jason Ng d6fb27deac Merge pull request #376 from Jasonhiddle512/dev
Fix the scrolling issue
2026-08-20 09:54:52 +08:00
jason5ng32 93d867d206 Improvements 2026-08-20 09:53:42 +08:00
Jason Hiddle fd3403a9af Fix the scrolling bug 2026-08-20 09:30:00 +08:00
Jason Hiddle cd621c1a1f Try to fix scrolling bug 2026-08-20 09:29:08 +08:00
jason5ng32 f2460db67e Improvements 2026-08-19 17:20:24 +08:00
jason5ng32andClaude Opus 5 123f17fc5e Improvements
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:38:51 +08:00
jason5ng32andClaude Opus 5 8c78e37a39 Fix(ui): Skip geo lookup when a source returns a non-public IP
DNS hijacking, captive portals and LAN echoes hand back reserved
addresses. The card sent them straight to /api/*, where requirePublicIP
rejected them with a 400 the visitor saw as a failed source.

fetchIP now gates on isUsablePublicIP — the shared definition the guard
itself uses — so the address is still shown and the card settles, only
the detail request is skipped. selectIPGeoSource's refetch filter moves
to the same predicate; isValidIP accepted reserved space and re-sent the
400 on every database switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:24:48 +08:00
jason5ng32andClaude Opus 5 30a3250faf Fix(api): Tolerate missing optional fields from ipinfo.io
Upstream answers 200 with `loc` / `org` / `country` absent for anycast,
bogon and degraded responses. Splitting them unguarded threw, and
geo-handler turned that into a 500 the visitor's card surfaced as a
failed source.

Missing coordinates now degrade to null instead of Number('') === 0,
which would have placed those IPs at the origin of the coordinate
system; a missing org yields empty asn / org; an unknown ISO code falls
back to the label that was already written but unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:24:48 +08:00
jason5ng32 3bb49db80e Improvements 2026-08-19 14:22:48 +08:00
jason5ng32 69e75a70cd Improvements 2026-08-19 13:56:41 +08:00
jason5ng32 26f744b463 Improvements 2026-08-19 12:01:07 +08:00
jason5ng32 ac06336979 Improvements 2026-08-19 11:53:28 +08:00
jason5ng32 020053ee56 Improvements 2026-08-19 11:33:39 +08:00
jason5ng32 337d03fb8a Improvements 2026-08-19 11:09:48 +08:00
jason5ng32 8d55118a78 Add shortcut to persona check 2026-08-19 10:37:18 +08:00
jason5ng32andClaude Fable 5 918f667420 Feat(report): Include Persona Check in shareable reports
The tool now emits persona:finished, and the report carries the grade
against the declared country plus each check's verdict. Verdict-only by
design: the live results' detail objects hold what the visitor supplied
(issuing bank, the country a shared position resolved to), and a report
link is readable by anyone who has it, so the builder drops them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:31:51 +08:00
jason5ng32 d377f1912e Improvements 2026-08-19 10:23:50 +08:00
jason5ng32 f6c8556c0d Docs(privacy): Disclose what Persona Check sends
The one tool whose measurements leave the browser gets its own section:
what travels with a run, that nothing is stored, and the two opt-in
inputs — a coarsened position and a card issuer prefix checked against a
third-party database.
2026-08-19 10:22:29 +08:00
jason5ng32andClaude Fable 5 7becf39074 Feat(tools): Add Persona Check
New sign-in-gated advanced tool: declare a target country, run the
browser-side probes, and get a graded report of how closely the persona
sites can see matches the one you expect — with four-locale copy and
Node-runner coverage for every non-visual piece.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:12:15 +08:00
jason5ng32andClaude Fable 5 87d0f82e7d Feat(api): Add persona evaluate proxy
POST /api/persona/evaluate — thin proxy to the private evaluator, with
smoke tests and the AGENTS note on its header handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:12:05 +08:00
jason5ng32andClaude Fable 5 9d653651ed Feat(widgets): Add CountryPicker
Searchable, virtualized country selector over the full ISO 3166-1 set,
localized via Intl.DisplayNames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:11:57 +08:00
jason5ng32andClaude Fable 5 0ff2615bab Chore: Add vue-input-otp dependency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:11:57 +08:00
jason5ng32 228755d691 Adv Tools can be unstandalone 2026-08-19 00:46:24 +08:00
jason5ng32 735555d29f Sentry Improvement 2026-08-19 00:44:42 +08:00
jason5ng32 42dfd92579 Add OTP Component 2026-08-19 00:44:00 +08:00
jason5ng32 552763bead Add Combobox 2026-08-18 17:54:20 +08:00
jason5ng32andClaude Opus 5 7cd879ffd8 Improvements
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:19:48 +08:00
jason5ng32 33637f53de Improvements 2026-08-16 23:19:38 +08:00
jason5ng32 228d457a32 Improvements 2026-08-16 23:15:16 +08:00
jason5ng32 3841af0246 Improvements 2026-08-16 23:01:49 +08:00
jason5ng32 153f76a8b6 Improvements 2026-08-16 23:01:41 +08:00
jason5ng32 5f7dfe552f Improvements 2026-08-16 15:29:00 +08:00
jason5ng32 26ab6ace3b Improvements 2026-08-15 20:50:54 +08:00
jason5ng32 cc8e9a0a52 Improvements 2026-08-15 20:39:53 +08:00
jason5ng32andClaude Fable 5 aa4524da40 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 <noreply@anthropic.com>
2026-08-15 11:07:07 +08:00
jason5ng32 34ac46ee44 Merge branch 'dev' into feat/quota 2026-08-15 10:52:18 +08:00
jason5ng32andClaude Fable 5 7d009fb29f 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 <noreply@anthropic.com>
2026-08-15 01:59:58 +08:00
jason5ng32andClaude Fable 5 aab3cc56a0 Chore(changelog): stamp v7.3.0 release date, start v7.4.0 entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 01:59:58 +08:00
jason5ng32andClaude Fable 5 fdd1281e66 Feat(ui): localize all user-visible dates via shared time helpers
Dates across the app now render in the UI language instead of the
browser/OS locale, in one consistent medium style: account created-at,
achievement unlock times, shared-report timestamps (new isoToDateTime),
the OONI observation window, IP history day headers and Service Status
incident dates. unixToDateTime gains a locale param and rejects unusable
input. Deliberate ISO exceptions (ASN history columns, AI report intro,
Service Status refresh clock) now carry comments saying why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 01:59:49 +08:00
jason5ng32andClaude Fable 5 a062bbcec2 Feat(quota): monthly quotas for advanced features, benefits & usage dialog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 02:10:14 +08:00
672 changed files with 50880 additions and 8283 deletions

No files matched your search

+3 -18
View File
@@ -18,23 +18,8 @@ npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# Runtime-downloaded databases — never bake them into the image. A clean CI build
# Runtime-downloaded datasets — never bake them into the image. A clean CI build
# wouldn't have them anyway; this keeps a local build (which has them on disk from
# `pnpm dev`) consistent with CI, so the container always downloads fresh data on
# first boot. Kept in sync with the matching block in .gitignore.
common/maxmind-db/.maxmind-update-*.json
common/maxmind-db/.maxmind-update.lock
common/maxmind-db/*.bak
common/maxmind-db/*.next
common/maxmind-db/*.mmdb
common/as-org-db/*.txt
common/as-rel-db/*.txt
common/as-org-db/.caida-update-state.json
common/as-rel-db/.caida-update-state.json
common/as-org-db/.caida-update.lock
common/as-rel-db/.caida-update.lock
common/as-org-db/*.bak
common/as-rel-db/*.bak
common/as-org-db/*.next
common/as-rel-db/*.next
# first boot. Same pattern as .gitignore.
common/*-db/
+18
View File
@@ -0,0 +1,18 @@
# EditorConfig — the file-level basics (charset, line endings, indent, final
# newline) that every editor and formatter should agree on, so a "format
# document" in one editor doesn't produce diff noise for the next person.
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
# Two trailing spaces are a hard line break in Markdown — don't strip them.
trim_trailing_whitespace = false
+10 -7
View File
@@ -8,7 +8,6 @@ IPINFO_API_KEY=""
CLOUDFLARE_API_KEY=""
IPAPIIS_API_KEY=""
IP2LOCATION_API_KEY=""
MAC_LOOKUP_API_KEY=""
RIPESTAT_SOURCE_APP=""
# Shareable diagnostic reports — stored in Cloudflare Workers KV. Both values
# below must be set (together with CLOUDFLARE_API_KEY, which additionally
@@ -17,20 +16,24 @@ RIPESTAT_SOURCE_APP=""
# dashboard, not its name.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_KV_NAMESPACE_ID=""
# MaxMind
# MaxMind — GeoLite2 is downloaded only when both are set
MAXMIND_ACCOUNT_ID=""
MAXMIND_LICENSE_KEY=""
MAXMIND_AUTO_UPDATE="false"
# CAIDA
CAIDA_AUTO_UPDATE="false"
# Offline datasets (MaxMind, CAIDA, PeeringDB): missing ones download at boot;
# all are re-checked daily at DATASET_UPDATE_CRON (server-local time) unless
# DATASET_AUTO_UPDATE is "false"
DATASET_AUTO_UPDATE="true"
DATASET_UPDATE_CRON="30 4 * * *"
# SITE
VITE_SITE_URL="https://ipcheck.ing"
# DOCS SITE — origin of the GitBook docs site powering the in-app
# assistant and the Help Center link. Empty disables both.
VITE_DOCS_URL=""
# EARTH ONLINE — full URL of the pulse backend (trailing slashes stripped).
# Empty disables the feature entirely (build-time, like Sentry).
VITE_PULSE_URL=""
# Empty hides the social parts (status feed / visitor map / visit beacon) at
# build time, like Sentry. The outage feed is independent — it only needs
# CLOUDFLARE_API_KEY above.
VITE_PULSE_BEACON_URL=""
# CURL API
VITE_CURL_IPV4_DOMAIN=""
VITE_CURL_IPV6_DOMAIN=""
+17 -42
View File
@@ -1,58 +1,33 @@
---
name: Bug report
about: Bug Report Template
title: "[Short Description of the Bug]"
about: Something isn't working
title: ''
labels: bug
assignees: ''
---
## Environment:
## What happened?
* Operating System:
* Browser (if applicable):
* Node Version:
* Vite Version:
* Vue3 Version:
* Docker Version (if applicable):
* Deployment Method (Vercel/Docker/Node):
* Environment Variables (without sensitive info):
* Any other relevant software versions:
A clear description of the bug. Screenshots or recordings help a lot.
## Description:
## Steps to reproduce
Provide a clear and concise description of what the bug is.
1. …
2. …
Steps to Reproduce:
## What did you expect?
1. Step one to reproduce
2. Step two...
3. ...
## Environment
## Expected Behavior:
- Where: ipcheck.ing / self-hosted (Docker / Node / Vercel)
- OS & browser (for page issues):
- Node version (self-hosted only):
Describe what you expected to happen.
## Logs (if applicable)
## Actual Behavior:
Browser console errors for page issues; terminal output for self-hosted / backend issues.
Describe what actually happened. Include screenshots or screen recordings if possible.
## Terminal & Console Logs:
**Important**: Please include the terminal logs and browser console logs if applicable. These logs are crucial for identifying the root cause of the issue.
### Terminal Logs (include any error messages or stack traces):
[Logs here]
### Browser Console Logs (if frontend issue):
[Logs here]
Additional Context:
Add any other context about the problem here, like links to related issues, logs, etc.
## Possible Solution (optional):
If you have an idea of how to fix the issue, please describe it here.
```text
paste logs here
```
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 📖 Documentation
url: https://docs.ipcheck.ing
about: Deployment, MaxMind setup, environment variables, and how each tool works.
- name: 🔒 Report a security vulnerability
url: https://github.com/jason5ng32/MyIP/security/advisories/new
about: Private report, visible only to the maintainer. Please don't open a public issue.
+9 -19
View File
@@ -1,23 +1,13 @@
# Pull Request Template
> ⚠️ **Open this PR against the `dev` branch, not `main`.** `main` only receives release merges from `dev`.
> ⚠️ **Please open this PR against the `dev` branch, not `main`.** All contributions go through `dev`; `main` only receives release merges.
## What & why
## Description
Please include a summary of the change and which issue is fixed.
Summary of the change, and the issue it fixes if any (e.g. `Closes #123`).
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
## Checklist:
- [ ] This PR targets the `dev` branch, not `main`.
- [ ] I have followed the contribution guidelines.
- [ ] My code follows the style guidelines of this project.
- [ ] I have performed a self-review of my own code.
- [ ] I have commented my code, particularly in hard-to-understand areas.
- [ ] I have made corresponding changes to the documentation.
- [ ] My changes generate no new warnings.
- [ ] I have added tests that prove my fix is effective or that my feature works.
- [ ] New and existing unit tests pass locally with my changes.
- [ ] Targets `dev`; one concern per PR
- [ ] `pnpm check` is green locally (tests + build)
- [ ] Logic changes ship with a spec in `tests/`
- [ ] User-visible copy lands in every `full` locale (`en` / `zh` / `zh-TW` / `fr` / `ru` / `pt-BR`)
- [ ] I've read [CONTRIBUTING.md](../CONTRIBUTING.md) and the relevant `AGENTS.md`
+16 -1
View File
@@ -14,6 +14,9 @@ jobs:
check:
name: Test + Build
runs-on: ubuntu-latest
# Normal run is ~1 min; a hosted runner that loses its connection would
# otherwise sit "in progress" for the 6h default before failing.
timeout-minutes: 10
steps:
- name: Checkout
@@ -22,7 +25,7 @@ jobs:
- name: Setup pnpm
# Reads the `packageManager` field in package.json to pin the pnpm version,
# so CI uses the exact same pnpm as local dev.
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
@@ -37,6 +40,18 @@ jobs:
# out of sync with package.json, rather than silently rewriting it.
run: pnpm install --frozen-lockfile
- name: Translation status
# Locale progress dashboard into the run summary, so a translation PR's
# author and reviewer see coverage and the next keys to translate without
# a local run. Report only — the script always exits 0; the mechanical
# gate for locale packs lives in pnpm test below.
run: |
{
echo '```text'
node scripts/i18n-status.js
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Run tests
run: pnpm test
+21 -10
View File
@@ -22,6 +22,9 @@ jobs:
build-and-push:
runs-on: ubuntu-latest
environment: production
# The multi-arch build normally lands in ~5 min; a hosted runner that loses
# its connection would otherwise sit "in progress" for the 6h default.
timeout-minutes: 20
steps:
- name: Check Out Repo
@@ -43,17 +46,27 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get the version and release notes
- name: Get the version
if: github.event_name == 'release'
run: |
printf "RELEASE_VERSION=%s\n" "${{ github.event.release.tag_name }}" >> $GITHUB_ENV
printf "RELEASE_NOTES<<EOF\n%s\nEOF\n" "${{ github.event.release.body }}" >> $GITHUB_ENV
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: echo "RELEASE_VERSION=$TAG_NAME" >> "$GITHUB_ENV"
- name: Set version for manual dispatch
if: github.event_name == 'workflow_dispatch'
run: |
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
echo "RELEASE_NOTES=Manually triggered build" >> $GITHUB_ENV
env:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
run: echo "RELEASE_VERSION=$DISPATCH_VERSION" >> "$GITHUB_ENV"
# OCI labels (title / description / source / revision / …) from the repo;
# tags stay hand-written below. A manual dispatch runs on `main`, so the
# version label is pinned to RELEASE_VERSION.
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
labels: |
org.opencontainers.image.version=${{ env.RELEASE_VERSION }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v7
@@ -64,7 +77,5 @@ jobs:
jason5ng32/myip:${{ env.RELEASE_VERSION }}
ghcr.io/${{ github.repository_owner }}/myip:latest
ghcr.io/${{ github.repository_owner }}/myip:${{ env.RELEASE_VERSION }}
labels: |
org.opencontainers.image.version=${{ env.RELEASE_VERSION }}
org.opencontainers.image.description=${{ env.RELEASE_NOTES }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
+67
View File
@@ -0,0 +1,67 @@
# Greets every newly opened PR from an outside contributor: first-time
# contributors get the full onboarding message, returning contributors a
# shorter welcome-back one. Maintainer PRs (owner / member / collaborator)
# and bot PRs are skipped.
#
# pull_request_target is safe here precisely because this workflow never
# checks out or executes anything from the PR — it only posts a comment
# with trusted code from the base branch. Keep it that way: no checkout,
# no build steps in this file, ever. It also means the greeting posts
# immediately, even while CI still sits behind the fork-approval gate.
name: PR welcome
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
welcome:
if: >-
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
&& github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
steps:
- name: Post greeting
uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
const author = pr.user.login;
const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}`;
// The PR just opened is included in the count, so 1 = first ever.
const { data: search } = await github.rest.search.issuesAndPullRequests({
q: `repo:${context.repo.owner}/${context.repo.repo} is:pr author:${author}`,
per_page: 1,
});
const isFirstTime = search.total_count <= 1;
const firstTime = [
`Thanks for your first PR here, @${author} — contributions like this are what keep MyIP going! 🙏`,
'',
'This project has a single maintainer, so reviews are usually quick but not instant — expect a response within a day or two. A couple of things that speed a merge up:',
'',
'- `pnpm check` green locally (tests + build) is the fastest path through review.',
`- Conventions live in [AGENTS.md](${repoUrl}/blob/dev/AGENTS.md); translation PRs additionally in [TRANSLATING.md](${repoUrl}/blob/dev/TRANSLATING.md).`,
"- If the checks tab says a workflow is *awaiting approval*, that's normal for first-time contributors — the maintainer approves it as part of the review.",
'',
'_This is an automated greeting; a human review follows._',
];
const returning = [
`Thanks for another PR, @${author} — always good to see you back! 🙌`,
'',
'The maintainer will take a look shortly (usually within a day or two). As ever, `pnpm check` green locally is the fastest path through review.',
'',
'_This is an automated greeting; a human review follows._',
];
await github.rest.issues.createComment({
...context.repo,
issue_number: pr.number,
body: (isFirstTime ? firstTime : returning).join('\n'),
});
+19 -19
View File
@@ -25,6 +25,7 @@ dist-ssr
*.sln
*.sw?
.env
.env.forker
.vscode/extensions.json
package-lock.json
@@ -32,28 +33,26 @@ localtest
.VSCodeCounter
common/maxmind-db/.maxmind-update-*.json
common/maxmind-db/.maxmind-update.lock
common/maxmind-db/*.bak
common/maxmind-db/*.next
common/maxmind-db/*.mmdb
# CAIDA datasets — Non-Commercial license, don't commit to public repo.
common/as-org-db/*.txt
common/as-rel-db/*.txt
common/as-org-db/.caida-update-state.json
common/as-rel-db/.caida-update-state.json
common/as-org-db/.caida-update.lock
common/as-rel-db/.caida-update.lock
common/as-org-db/*.bak
common/as-rel-db/*.bak
common/as-org-db/*.next
common/as-rel-db/*.next
# Offline datasets (server/datasets/datasets.js): every common/<name>-db/ directory
# holds only downloaded data, its state and lock — created at runtime, never
# committed (CAIDA's data is under a non-commercial license).
common/*-db/
.learnings/
docs/
.plan/
.skills/
# Local Scripts
scripts/
# Local Scripts (the listed exceptions are public — contributors need
# fetch-favicons.js for the Connectivity favicon pipeline, and the i18n pair
# to scaffold a locale pack and see how far along it is)
scripts/*
!scripts/fetch-favicons.js
!scripts/i18n-status.js
!scripts/i18n-scaffold.js
!scripts/check-dns-resolvers.js
# Section banners — deploy-time data (ads and campaign promos stay out of git)
frontend/data/banners/*
# Site configuration
public/sitemap.xml
@@ -63,6 +62,7 @@ public/llms-full.txt
public/tools/
public/robots.txt
public/ads.txt
public/funding.json
# Private AI context (per-machine, not for the public repo)
local-context.md
+1
View File
@@ -0,0 +1 @@
https://ipcheck.ing/funding.json
+38 -12
View File
@@ -3,22 +3,39 @@
Single source of truth for anyone — human or AI — contributing to MyIP.
Area-specific details: @frontend/AGENTS.md (Vue SPA) · @api/AGENTS.md (Express API).
## Local skill discovery
Before choosing a workflow for a project task, check the repository-root
`.skills/` **directory**. It contains local skills at `.skills/<name>/SKILL.md`
and may not appear in the agent's automatically supplied skill list.
- If the directory exists, discover its skill files with
`rg --files --hidden --no-ignore .skills -g SKILL.md` (it is gitignored).
- Read each skill's frontmatter `name` and `description`; read the full
`SKILL.md` for any skill that applies, then follow its workflow.
- Complete this discovery before choosing a fallback tool or telling the
user that a project capability or service is unavailable. Load supporting
files only when the selected skill requires them.
## Overview
**MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity
tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security
checklist, browser fingerprint, anonymity checks, and more. Single repo, two
halves: a Vue 3 SPA front-end and an Express 5 back-end API.
tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, ASN profile,
security checklist, browser fingerprint, anonymity checks, persona check, IP
calculator, and more. Single repo, two halves: a Vue 3 SPA front-end and an
Express 5 back-end API.
Licensed under AGPL-3.0-only (`LICENSE`).
## Stack
| Layer | Technology |
|---|---|
| Frontend | Vue 3 (`<script setup>`) · Pinia · vue-router (HTML5 history) · vue-i18n (`en`/`zh`/`fr`/`ru`) |
| Frontend | Vue 3 (`<script setup>`) · Pinia · vue-router (HTML5 history) · vue-i18n (locales registered in `common/locale-registry.js`) |
| Build | Vite + `@vitejs/plugin-vue`; Tailwind CSS v4 + `tw-animate-css` |
| UI | shadcn-vue copy-in primitives (reka-ui) · lucide icons · circle-flags via `@iconify/vue` · vaul-vue drawer · vue-sonner toast |
| Backend | Express 5 |
| Logger | `pino` singleton at `common/logger.js` (+ `pino-http`, opt-in) |
| Logger | `pino` singleton at `server/logger.js` (+ `pino-http`, opt-in) |
| Auth | Firebase Auth (optional, env-gated) |
| Error monitoring | Sentry — optional & env-gated on both halves: `@sentry/vue` (no `VITE_SENTRY_DSN_FRONTEND`, no Sentry in the build — see frontend/AGENTS.md) + `@sentry/node` (no `SENTRY_DSN_BACKEND`, never loaded — see api/AGENTS.md) |
| PWA | `manifest.webmanifest` only — installable but online-only, no service worker |
@@ -47,8 +64,11 @@ use npm / yarn — they'd produce a competing lockfile.
├── AGENTS.md / CLAUDE.md ← this file + Claude pointer to it
├── frontend/ ← Vue 3 SPA (see frontend/AGENTS.md)
├── api/ ← Express handlers (see api/AGENTS.md)
├── common/ ← code shared by both halves (valid-ip /
│ fetch-with-timeout / guards / logger / …)
├── server/ ← backend-only code (logger / guards / …;
│ datasets/ = the offline dataset engine)
├── common/ ← code the frontend or build imports too
│ (valid-ip / fetch-with-timeout / …) + the
│ dataset dirs (`*-db/`, a self-host mount point)
├── tests/ ← Node test runner specs
├── backend-server.js ← Express app (default port 11966)
├── sentry-instrument.js ← backend Sentry bootstrap via `node --import`;
@@ -77,7 +97,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
@@ -85,13 +107,17 @@ use npm / yarn — they'd produce a competing lockfile.
### i18n coverage
- Copy-surfacing features land in **all four locales** in the same change —
including `frontend/data/changelog.json` entries
(`tests/changelog.test.js` enforces it).
- Copy-surfacing features land in **every `full` locale** in the same change —
including `frontend/data/changelog.json` entries (`tests/changelog.test.js`
and `tests/locale-packs.test.js` enforce it against `en`). `beta` locales are
free to lag: their gaps resolve down the fallback chain in
`common/locale-registry.js`, which is also where a new language is registered.
- Contributor-facing walkthrough: [TRANSLATING.md](TRANSLATING.md) — keep it in
step when the registry, the pack gate or `pnpm i18n-status` changes.
### Logging (backend)
- **Always the shared logger** (`common/logger.js`) in backend files; bare
- **Always the shared logger** (`server/logger.js`) in backend files; bare
`console.*` is banned there (frontend keeps using `console.*`).
- Pino first-arg-is-context: `logger.error({ err, ip }, 'short message')`.
- Env knobs: `LOG_LEVEL` (default info), `LOG_FORMAT=json` for shippers,
+6 -2
View File
@@ -59,8 +59,12 @@ representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
reported privately to the maintainer using GitHub's
[private report form](https://github.com/jason5ng32/MyIP/security/advisories/new)
— it reaches only the maintainer; start the report with "Code of Conduct" so it
isn't triaged as a security issue. Behavior that violates
[GitHub's own terms](https://github.com/contact/report-abuse) can also be
reported to GitHub directly.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
+103 -53
View File
@@ -1,82 +1,132 @@
# CONTRIBUTING.md
# Contributing to MyIP
## Welcome Contributors! 👋
Thanks for your interest in contributing! MyIP (demo website: [IPCheck.ing](https://ipcheck.ing)) is an
open-source IP toolbox — IP lookup, connectivity tests, WebRTC / DNS-leak detection,
speed test, and more — built as a Vue 3 SPA with an Express 5 backend.
We're delighted that you're interested in contributing to our project! This document provides guidelines to ensure a smooth contribution process for everyone involved.
New here? Look for issues labeled
[`good first issue`](https://github.com/jason5ng32/MyIP/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
— they're scoped to be doable without knowing the whole codebase.
### Table of Contents
## Quick start
- [CONTRIBUTING.md](#contributingmd)
- [Welcome Contributors! 👋](#welcome-contributors-)
- [Table of Contents](#table-of-contents)
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Reporting Bugs](#reporting-bugs)
- [Feature Requests](#feature-requests)
- [Submitting Changes](#submitting-changes)
- [Setting Up Your Environment](#setting-up-your-environment)
- [Testing](#testing)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Code Review Process](#code-review-process)
- [Community and Support](#community-and-support)
- [Thank You! 👏](#thank-you-)
1. **Fork** the repo and create your branch **from `dev`** (never `main` — `main` only
receives release merges from `dev`).
2. **Set up:** Node.js 24+ (same as CI) and pnpm. The pnpm version is pinned
via the `packageManager` field, so the easiest path is:
### Code of Conduct
```bash
corepack enable # or: npm install -g pnpm
pnpm install
pnpm dev # starts Vite + the backend together
```
This project adheres to a [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
**pnpm only.** npm or yarn would produce a competing lockfile — PRs that touch
`package-lock.json` / `yarn.lock` will be asked to redo the install with pnpm.
3. Make your change — **one concern per PR**, don't bundle unrelated changes.
4. **Run `pnpm check`** (tests + production build). It must be green before you open a PR.
5. Open your PR **against `dev`**, rebased onto the latest `dev`, with a clear
description of what changed and why.
### Getting Started
For anything non-trivial, open an issue first to discuss the approach — it saves you
from building something that can't be merged.
If you're new to the project, consider:
## Project map
- Reading the project's README for an overview.
- Checking the issues labeled as `good first issue` for an easy entry point.
```
frontend/ Vue 3 SPA (Pinia, vue-router, vue-i18n, Tailwind v4 + shadcn-vue)
api/ Express 5 handlers, one file per route (wired in backend-server.js)
server/ Backend-only code (logger, guards, upstream clients, dataset engine, …)
common/ Code both halves import (validators, fetch helper, report schema, …)
tests/ Node test runner specs (node --test)
```
### Reporting Bugs
**The real architecture and convention docs are the AGENTS.md files:**
[`AGENTS.md`](AGENTS.md) (root), [`frontend/AGENTS.md`](frontend/AGENTS.md), and
[`api/AGENTS.md`](api/AGENTS.md). Don't let the filename fool you — they're written
for humans and AI agents alike, and they're the single source of truth for how this
repo works. Read the root one plus whichever half you're touching before writing code.
Please follow the bug report template provided in [BUG_REPORT_TEMPLATE.md](.github/ISSUE_TEMPLATE/bug_report.md). Include terminal and console logs for a comprehensive report.
Prefer a guided tour? The online **[Developer Guide](https://docs.ipcheck.ing/developer)**
walks through the project architecture, configuration, and deployment in detail
(also available in 中文, français, and русский).
### Feature Requests
## Key conventions (short version)
We love to hear your ideas! Open an issue with the tag `feature request` and provide a clear and detailed explanation of the feature and its benefits.
Details and rationale live in the AGENTS.md files; the headlines:
### Submitting Changes
- **JavaScript only** — no TypeScript, no `lang="ts"`.
- **New functions use `const` arrow syntax** (`const fn = async () => {}`).
- **Every new file opens with a header comment** stating its purpose.
- **The `full` locales land together** — user-visible copy ships in `en` / `zh` / `zh-TW` /
`fr` / `ru` / `pt-BR` in the same PR (`frontend/locales/`; tests enforce it). `beta`
locales are exempt — they fall back to English.
- **Backend logging goes through the shared pino logger** (`server/logger.js`) —
no `console.*` in `api/`, `server/` or `common/`.
Before making any changes, please:
## Good places to start
1. Open a new issue discussing your proposed change.
2. Fork the repository and create your branch from **`dev`** — all contributions are based on `dev`, not `main`.
**DNS resolvers** — the resolver list lives in `api/data/dns-resolvers.js`, a
country-annotated data file written for exactly this kind of PR (the header
comment documents the entry shape and rules, and `tests/dns-resolvers-data.test.js`
checks your entry). Adding a well-known public resolver — especially from a
country not yet represented — is a one-object change; the UI groups results
by country automatically.
### Setting Up Your Environment
**Connectivity test sites & lists** — curated site lists live in
`frontend/data/connectivity-import-lists.js`. Each member needs a committed 64px PNG
icon at `public/favicons/<id>.png`, but you normally don't source it yourself: run
`pnpm test` locally and the data test auto-downloads any missing icons (also
runnable directly as `pnpm fetch-favicons`). Only if auto-fetch can't find a usable
PNG do you hand-source one (on macOS, `sips` handles ICO→PNG). Remember to commit
the PNGs with your change — CI stays offline and only checks they exist.
To set up the development environment for this project, you'll need to:
**README translations** — beyond the READMEs we maintain (`en` / `zh` / `zh-TW` / `fr` /
`ru`) and the community-maintained `pt-BR` one, translations in any language are welcome. Create
`README_<LANG>.md` from the English [README.md](README.md) (the canonical source),
open it with a one-line "community-maintained translation — English is canonical"
note in your language, and add your language to the switcher row near the top of the
existing READMEs. Keep code blocks, URLs, and badges unchanged. Check open issues
for requested languages before starting.
1. Install Node.js, Vite, and Vue3.
2. Clone the repository.
3. Run `pnpm install` to install dependencies.
4. Follow the instructions for Docker and Vercel deployment in our documentation if necessary.
**UI translations** — improvements to the existing `en` / `zh` / `zh-TW` / `fr` / `ru` /
`pt-BR` packs are always welcome. New languages are added by visitor demand, not on request:
the open **Translations wanted** issue lists the ones currently up for claiming, and a pack
for any other language won't be merged. For a listed language, adding it is two files: a
locale pack under `frontend/locales/` and a line in `common/locale-registry.js`. **A partial
pack is a welcome first PR** — anything you don't translate falls back to English, and the
language ships as `beta` until it's complete. Full walkthrough:
**[TRANSLATING.md](TRANSLATING.md)**.
### Testing
## Bugs & feature requests
Ensure that all tests pass and, if applicable, add new tests for your changes. Run `pnpm test` to execute tests.
Use the issue templates in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/) —
one for bug reports (include terminal / browser console logs) and one for feature
requests. Questions, setup help, and "is this a bug?" go to
[Discussions → Q&A](https://github.com/jason5ng32/MyIP/discussions/categories/q-a)
instead — see [SUPPORT.md](SUPPORT.md). Security vulnerabilities go through the
private channel in [SECURITY.md](SECURITY.md), never a public issue.
### Pull Request Guidelines
## Testing
When you're ready to submit your changes:
- Specs live in `tests/` and run with `pnpm test` (Node's built-in test runner).
- Non-visual logic — pure functions, composables, transforms, validators — ships with
a spec in the same PR. Tests never hit real upstreams (the one exception: the
connectivity data test may download missing favicons on local runs, never in CI).
- UI rendering and browser APIs are out of scope for the Node runner; visual changes
are verified by the maintainer during review, so mention in your PR what to look at.
1. **Open your pull request against the `dev` branch — not `main`.** `main` only receives release merges from `dev`, so any PR targeting `main` will be asked to retarget.
2. Rebase your branch onto the latest `dev` before submitting.
3. Keep one concern per PR — don't bundle unrelated changes (e.g. a feature plus a dev-environment tweak) into the same pull request.
4. Ensure your changes adhere to the coding standards and guidelines, with a clear description of what you changed.
## License
### Code Review Process
MyIP is licensed under [AGPL-3.0](LICENSE). By submitting a contribution, you
agree that it is licensed under the same terms.
The project maintainers will review your pull request. They might request changes or provide feedback before merging.
## Code of Conduct
### Community and Support
This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). By participating,
you agree to uphold it.
Join our community channels (link your channels here) for support and discussions.
---
## Thank You! 👏
Your contributions are what make this community great. We appreciate your efforts in making this project better!
Thank you for making MyIP better! Every contribution — a one-line fix, a new
resolver, a better translation — is appreciated.
+1
View File
@@ -29,6 +29,7 @@ COPY --from=build-stage /app/frontend-server.js ./
# npm start boots the backend with `--import ./sentry-instrument.js`
COPY --from=build-stage /app/sentry-instrument.js ./
COPY --from=build-stage /app/api ./api
COPY --from=build-stage /app/server ./server
COPY --from=build-stage /app/common ./common
EXPOSE 18966
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2024 Jason Ng
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+110 -57
View File
@@ -17,8 +17,11 @@
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
🇺🇸 [English](README.md) | 🇨🇳 [简体中文](README_ZH.md) | 🇷🇺 [Русский](README_RU.md) | 🇫🇷 [Français](README_FR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
An open-source, all-in-one IP toolbox: IP lookup from multiple sources, connectivity tests, WebRTC & DNS-leak detection, speed test, MTR, censorship checks, Whois, and more — self-hostable with one Docker command.
👉 Demo: [https://ipcheck.ing](https://ipcheck.ing)
@@ -28,42 +31,66 @@ Feel free to bookmark the demo or deploy your own.
</div>
## 👀 Main Features
## 👀 Features
* 🛜 **View Your IPs**: Detects and displays your local IPs, sourcing from multiple IPv4 and IPv6 providers.
* 🔍 **Search IP Information**: Provides a tool for querying information about any IP address.
* 🕵️ **IP Information**: Presents detailed information for all IP addresses, including country, region, ASN, geographic location, and more.
* 🛰️ **ASN History & Upstream Topology**: View historical AS announcements for an IP prefix, and visualize the upstream paths from an ASN to the Tier 1 backbone networks.
* 🚦 **Availability Check**: Tests the accessibility of various websites, such as Google, GitHub, YouTube, ChatGPT, and others.
* 📡 **Service Status**: Shows the live availability of well-known services (Claude, OpenAI, GitHub, Cloudflare, and more) from their official status pages, with per-service status and recent incidents.
* 🚥 **WebRTC Detection**: Identifies the IP address used during WebRTC connections.
* 🛑 **DNS Leak Test**: Shows DNS endpoint data to evaluate the risk of DNS leaks when using VPNs or proxies.
* 🚀 **Speed Test**:Test your network speed with edge networks.
* 🚏 **Proxy Rule Testing**: Test the rule settings of proxy software to ensure their correctness.
* ⏱️ **Global Latency Test**: Performe lantency tests on servers located in different regions around the world.
* 🚉 **MTR Test**: Perform MTR tests on servers located in different regions around the world.
* 🔦 **DNS Resolver**: Performs DNS resolution of a domain name from multiple sources and obtains real-time resolution results that can be used for contamination determination.
* 🚧 **Censorship Check**: Check if a website is blocked in some countries.
* 📓 **Whois Search**: Perform whois information search for domain names or IP addresses
* 📀 **MAC Lookup**: Query information of a physical address
* 🖥️ **Browser Fingerprints**:Multiple ways to caculate your browser fingerprint
* 📋 **Cybersecurity Checklist**:A comprehensive cybersecurity checklist with a total of 258 items
### 🪪 Your IP & Identity
## 💪 Also
* 🛜 **IP Cards**: Detects your IPv4 and IPv6 from multiple independent sources side by side — country, region, city, ASN, organization, and the IP's local time zone.
* 🔍 **Query IP**: Looks up the same detailed information for any IP address you're curious about.
* 🧾 **IP History**: Keeps a local record of the IPs you've been seen with, filterable by type and country — stored in your browser only.
* 🖥️ **Browser Fingerprint**: Calculates your browser fingerprint in multiple ways and shows what makes you identifiable.
* 🌗 **Dark Mode**: Automatically toggles between dark and daylight modes based on system settings, with an option for manual switching.
* 📲 **PWA Supported**:Can be added as a desktop app on your phone as well as a Chrome app on your computer.
* ⌨️ **Keyboard Shortcuts**: Supports keyboard shortcuts for all functions, press `?` to view the shortcut list.
* 🌍 Based on availability test results, it indicates whether global internet access is currently feasible.
* 🇺🇸 🇨🇳 🇷🇺 🇫🇷 English, Chinese, Russian, French support.
### 🕵️ Leaks & Privacy
* 🚥 **WebRTC Detection**: Reveals the IP address exposed during WebRTC connections — including whether your browser's privacy hardening is on.
* 🛑 **DNS Leak Test**: Shows which DNS endpoints resolve your queries, to evaluate the risk of DNS leaks when using VPNs or proxies.
* 🛡️ **Ad & Tracker Blocking Test**: Checks what your ad blocker, browser, DNS filter, VPN or router actually blocks — ad networks, analytics, social trackers, error monitoring, device-maker telemetry, tracking scripts and element hiding.
* 📋 **Security Checklist**: A 258-item personal cybersecurity checklist across 12 areas, with progress saved in your browser.
### 📡 Network Tests
* 🚦 **Connectivity Check**: Tests the reachability of up to 60 sites of your choice, with multi-round minimum-latency results — plus curated import lists, from country packs to AI, social, streaming, gaming, developer, and more. Based on the results, it signals whether global internet access is currently feasible for you.
* 🚀 **Speed Test**: Measures your download, upload, and latency against edge networks.
* ⏱️ **Global Latency Test**: Pings your target from probes all over the world — pick countries from all available Globalping probes, grouped by continent.
* 🚉 **MTR Test**: Runs MTR from globally distributed probes to see the route packets actually take.
* 🚧 **Censorship Check**: Shows where a website is blocked worldwide — and by what means.
* 🚏 **Proxy Rule Test**: Verifies that your proxy software's rule configuration works the way you intended.
### 🔦 Lookup & Infrastructure
* 📟 **DNS Resolver**: Resolves a domain through multiple resolvers at once, grouped by country — an easy way to spot hijacking or contamination.
* 📓 **Whois Search**: Performs Whois lookups for domain names, IP addresses, and AS numbers.
* 🗄️ **MAC Lookup**: Identifies the vendor and details behind a physical address.
* 🧮 **IP Calculator**: Subnet math, notation conversions and IPv6 interface details for any IP, prefix, range or list, computed locally.
* 🛰️ **ASN Info & Upstream Topology**: Right on each IP card — AS details, historical announcements for the IP's prefix, and the upstream paths from its ASN to the Tier 1 backbone.
* 🛂 **ASN Profile**: The full picture of any AS — registration with the raw Whois record, global rank and size, traffic and connection quality, where its IPv4 space is used, every announced prefix with its RPKI status, its upstreams, peers and customers, and the internet exchanges and data centers where it is present.
* 📶 **Service Status**: Live availability of well-known services — Claude, OpenAI, GitHub, Cloudflare, and more — from their official status pages, with recent incidents.
### ✨ Platform
* 📤 **Shareable Reports**: Turn your test results into a diagnostic report — a read-only link with auto-expiry, AI-ready Markdown, or JSON.
* ⌨️ **Curl API**: Get your IP from the terminal with a single `curl` command.
* 🌍 **Earth Online**: A panel broadcasting global internet outage events as they happen.
* 🌗 **Dark Mode**: Follows your system automatically, with a manual toggle.
* 📲 **PWA**: Installable as an app on your phone and as a Chrome app on your desktop.
* ⚡ **Keyboard Shortcuts**: Every function has one — press `?` to see the list.
* 🔤 **Multiple Languages**: The UI ships in 6 languages, and adding yours takes one locale pack.
## 📕 How to Use
### Using Docker
One command and you're up:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Or click the "Deploy with Docker" button at the top of this page.
### Deploying in a Node Environment
Make sure you have Node.js installed.
Clone the code:
Make sure you have Node.js installed, then clone the code:
```bash
git clone https://github.com/jason5ng32/MyIP.git
@@ -84,14 +111,26 @@ pnpm start
The program will run on port 18966.
### Using Docker
## ⚙️ Configuration
Click the 'Deploy to Docker' button at the top to complete the deployment. Or, use the following shell:
> [!IMPORTANT]
> **MaxMind GeoLite2 credentials are required.** They power IP geolocation and ASN lookups — without them, the MaxMind source returns 503. They're free: → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **`ALLOWED_DOMAINS` is required on a real domain.** It's the hostname allowlist for the backend API — without it, every request from a non-localhost domain gets 403. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Everything else — optional API keys, security & rate limiting, logging, Sentry, the curl API domains — is documented in the [Environment Variables reference](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Documentation
Full guides live in the MyIP Docs Center: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
@@ -99,28 +138,19 @@ Full guides live in the MyIP Docs Center: **[docs.ipcheck.ing](https://docs.ipch
* [Developer Guide](https://docs.ipcheck.ing/developer) — deployment, configuration, architecture, and contributing
* [Knowledge Base](https://docs.ipcheck.ing/knowledge-base) — how to use every tool, step-by-step network diagnosis, and networking concepts
## ⚙️ Configuration
## 🤝 Contributing
Two settings matter before anything else:
* **MaxMind GeoLite2 (required)** — free credentials that power IP geolocation and ASN lookups. Without them, the MaxMind source returns 503. → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **`ALLOWED_DOMAINS` (required on a real domain)** — hostname allowlist for the backend API. Without it, every request from a non-localhost domain gets 403. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e MAXMIND_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Everything else — optional API keys, security & rate limiting, logging, Sentry, the curl API domains — is documented in the [Environment Variables reference](https://docs.ipcheck.ing/developer/reference/environment-variables).
Contributions are welcome! We keep a curated set of beginner-friendly tasks — each with exact file paths, acceptance criteria, and tests that guide you to a green build:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — add a DNS resolver from your country, add curated site lists, translate the README into your language, polish translations, and more
* 🌐 [TRANSLATING.md](TRANSLATING.md) — bring the UI to your language: a locale pack plus one registry line, and a **partial translation is a welcome first PR**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — setup, conventions, and how PRs flow (target the `dev` branch)
## 👩🏻‍💻 Advanced Usage
<details>
<summary>Proxy rules for checking your real IP and your proxy IP at the same time</summary>
If you're using a proxy for internet access, consider adding this rule to your proxy configuration (modify it according to your client). This setup lets you check both your real IP and the IP when using the proxy:
```ini
@@ -138,20 +168,43 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
## 💖 Sponsors
</details>
As a open source project, I'm very grateful to the following sponsors for their support:
## 💖 Sponsors & Supporters
<a href="https://www.digitalocean.com/?refcode=fd2634a3981b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge"><img src="https://res.ipcheck.ing/img/digitalocean_logo.png" width="240px" title="DigitalOcean" /></a>
As an open source project, I'm very grateful to the following sponsors for their support:
<a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" title="1Password" width="240px" /></a>
**Sponsors**
<a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" title="Greptile" width="240px" /></a>
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" title="Sentry" width="240px" /></a>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — offers private residential, mobile (4G/5G/LTE), ISP, and datacenter IPv4/IPv6 proxies with HTTP(S) and SOCKS5 support, 47M+ residential IPs in 220+ locations, precise country/city/ISP targeting, 99.7% uptime, and 24/7 support. MyIP users can use promo code `CHECKINGIP15` to get 15% off.
</sub>
<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=en"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=en">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
----
**Supporters**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Interested in sponsoring MyIP? [Become a sponsor](https://github.com/sponsors/jason5ng32)
## 📄 License
[AGPL-3.0](LICENSE) © Jason Ng
+119 -63
View File
@@ -1,5 +1,8 @@
# 🧰 MyIP - Une meilleure boîte à outils IP
> [!NOTE]
> Ceci est une traduction maintenue par la communauté ; le README anglais fait foi et cette version peut être en retard sur celui-ci.
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
@@ -17,66 +20,93 @@
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
🇺🇸 [English](README.md) | 🇨🇳 [简体中文](README_ZH.md) | 🇷🇺 [Русский](README_RU.md) | 🇫🇷 [Français](README_FR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
👉 Démo: [https://ipcheck.ing](https://ipcheck.ing)
Une boîte à outils IP tout-en-un et open source : recherche d'IP depuis plusieurs sources, tests de connectivité, détection WebRTC et de fuites DNS, test de vitesse, MTR, vérification de la censure, Whois et plus encore — auto-hébergeable en une seule commande Docker.
Notes: Vous pouvez utiliser ma démo gratuitement et vous pouvez également la déployer vous-même.
👉 Démo : [https://ipcheck.ing](https://ipcheck.ing)
N'hésitez pas à mettre la démo en favori ou à déployer votre propre instance.
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 Principales fonctionnalités
## 👀 Fonctionnalités
* 🛜 **Afficher vos adresses IP** : Détecte et affiche votre adresse IP locale, provenant de plusieurs fournisseurs IPv4 et IPv6.
* 🔍 **Recherche d'informations sur l'adresse IP** : Fournit un outil pour interroger des informations sur n'importe quelle adresse IP.
* 🕵️ **Informations sur l'adresse IP** : Présente des informations détaillées pour toutes les adresses IP, y compris le pays, la région, l'ASN, la localisation géographique, et plus encore.
* 🛰️ **Historique ASN et topologie amont** : Consultez l'historique des annonces AS pour un préfixe IP, et visualisez les chemins amont d'un ASN vers les réseaux dorsaux Tier 1.
* 🚦 **Vérification de disponibilité** : Teste l'accessibilité de différents sites web, tels que Google, GitHub, YouTube, ChatGPT, et d'autres.
* 📡 **État des services** : Affiche la disponibilité en temps réel de services populaires (Claude, OpenAI, GitHub, Cloudflare, etc.) depuis leurs pages d'état officielles, avec l'état de chaque sous-service et les incidents récents.
* 🚥 **Détection WebRTC** : Identifie l'adresse IP utilisée lors des connexions WebRTC.
* 🛑 **Test de fuite DNS** : Affiche les données de point de terminaison DNS pour évaluer le risque de fuites DNS lors de l'utilisation de VPN ou de proxies.
* 🚀 **Test de vitesse** : Testez la vitesse de votre réseau avec des réseaux de pointe.
* 🚏 **Test de règles** : Teste si les paramètres de règles fonctionnent correctement avec le logiciel de proxy.
* ⏱️ **Test de latence mondiale** : Effectue des tests de latence sur des serveurs situés dans différentes régions du monde.
* 🚉 **Test MTR** : Effectue des tests MTR sur des serveurs situés dans différentes régions du monde.
* 🔦 **Résolveur DNS** : effectue la résolution DNS d'un nom de domaine à partir de plusieurs sources, obtient les résultats de la résolution en temps réel et peut être utilisé pour la détermination de la contamination.
* 🚧 **Test de Censorship**: Vérifier si un site est bloqué dans certains pays.
* 📓 **Recherche Whois** : Effectuer une recherche d'informations Whois pour les noms de domaine ou les adresses IP
* 📀 **Recherche MAC** : Requête d'informations d'une adresse physique
* 🖥️ **Empreinte digitale du navigateur**: Plusieurs façons de visualiser l'empreinte digitale de votre navigateur
* 📋 **Liste de contrôle de cybersécurité**:: Une liste de contrôle complète de la cybersécurité avec un total de 258 éléments
### 🪪 Votre IP et votre identité
## 💪Également
* 🛜 **Cartes IP** : Détecte vos adresses IPv4 et IPv6 depuis plusieurs sources indépendantes, présentées côte à côte — pays, région, ville, ASN, organisation et fuseau horaire local de l'IP.
* 🔍 **Recherche d'IP** : Affiche les mêmes informations détaillées pour n'importe quelle adresse IP qui vous intrigue.
* 🧾 **Historique des IP** : Conserve un enregistrement local des IP sous lesquelles vous avez été vu, filtrable par type et par pays — stocké uniquement dans votre navigateur.
* 🖥️ **Empreinte du navigateur** : Calcule l'empreinte de votre navigateur de plusieurs manières et montre ce qui vous rend identifiable.
* 🌗 **Mode sombre** : Bascule automatiquement entre les modes sombre et clair en fonction des paramètres du système, avec une option de basculement manuel.
* 📲 **Prise en charge de PWA** : Peut être ajouté en tant qu'application de bureau sur votre téléphone ainsi qu'en tant qu'application Chrome sur votre ordinateur.
* ⌨️ **Raccourcis clavier** : Prend en charge les raccourcis clavier pour toutes les fonctions, appuyez sur `?` pour afficher la liste des raccourcis.
* 🌍 Basé sur les résultats des tests de disponibilité, il indique si l'accès Internet mondial est actuellement réalisable.
* 🇺🇸 🇨🇳 🇷🇺 🇫🇷 Prise en charge de l'anglais, du chinois, du russe et du français.
### 🕵️ Fuites et vie privée
* 🚥 **Détection WebRTC** : Révèle l'adresse IP exposée lors des connexions WebRTC — y compris si les protections de confidentialité de votre navigateur sont actives.
* 🛑 **Test de fuite DNS** : Montre quels points de terminaison DNS résolvent vos requêtes, afin d'évaluer le risque de fuite DNS lors de l'utilisation d'un VPN ou d'un proxy.
* 🛡️ **Test de blocage des pubs et traceurs** : Vérifie ce que votre bloqueur de pubs, navigateur, filtre DNS, VPN ou routeur bloque vraiment — régies publicitaires, statistiques, traceurs sociaux, suivi des erreurs, télémétrie des fabricants d'appareils, scripts de pistage et masquage d'éléments.
* 📋 **Liste de contrôle de cybersécurité** : Une liste personnelle de 258 points de cybersécurité répartis en 12 domaines, avec progression sauvegardée dans votre navigateur.
### 📡 Tests réseau
* 🚦 **Test de connectivité** : Teste l'accessibilité de jusqu'à 60 sites de votre choix, avec la latence minimale mesurée sur plusieurs tours — plus des listes d'import sélectionnées, des packs par pays jusqu'à l'IA, les réseaux sociaux, le streaming, le jeu, le développement et plus encore. D'après les résultats, il indique si l'accès à l'Internet mondial est actuellement possible pour vous.
* 🚀 **Test de vitesse** : Mesure vos débits descendant et montant ainsi que votre latence face à des réseaux edge.
* ⏱️ **Test de latence mondiale** : Mesure le ping vers votre cible depuis des sondes du monde entier — choisissez les pays parmi toutes les sondes Globalping disponibles, regroupées par continent.
* 🚉 **Test MTR** : Lance MTR depuis des sondes réparties dans le monde pour voir la route réellement empruntée par les paquets.
* 🚧 **Test de censure** : Montre où un site web est bloqué dans le monde — et par quels moyens.
* 🚏 **Test de règles de proxy** : Vérifie que la configuration des règles de votre logiciel de proxy fonctionne comme prévu.
### 🔦 Recherches et infrastructure
* 📟 **Résolveur DNS** : Résout un domaine via plusieurs résolveurs à la fois, regroupés par pays — un moyen simple de repérer un détournement ou une contamination.
* 📓 **Recherche Whois** : Effectue des recherches Whois pour les noms de domaine, les adresses IP et les numéros d'AS.
* 🗄️ **Recherche MAC** : Identifie le fabricant et les détails derrière une adresse physique.
* 🧮 **Calculateur IP** : Calcul de sous-réseau, conversions de notation et détails d'interface IPv6 pour toute IP, préfixe, plage ou liste, le tout en local.
* 🛰️ **Infos ASN et topologie amont** : Directement depuis chaque carte IP — les détails de l'AS, l'historique des annonces du préfixe de l'IP et les chemins amont de son ASN vers la dorsale Tier 1.
* 🛂 **Profil ASN** : Le portrait complet de n'importe quel AS — enregistrement avec la fiche Whois brute, rang mondial et taille, trafic et qualité de connexion, pays où son espace IPv4 est utilisé, chaque préfixe annoncé avec son statut RPKI, ses fournisseurs amont, pairs et clients, ainsi que les points d'échange Internet et centres de données où il est présent.
* 📶 **État des services** : Disponibilité en direct de services connus — Claude, OpenAI, GitHub, Cloudflare et d'autres — depuis leurs pages d'état officielles, avec les incidents récents.
### ✨ Plateforme
* 📤 **Rapports partageables** : Transformez vos résultats de test en rapport de diagnostic — un lien en lecture seule à expiration automatique, du Markdown prêt pour l'IA ou du JSON.
* ⌨️ **API curl** : Obtenez votre IP depuis le terminal avec une simple commande `curl`.
* 🌍 **La Terre en ligne** : Un panneau qui diffuse les pannes d'Internet mondiales au moment où elles se produisent.
* 🌗 **Mode sombre** : Suit automatiquement votre système, avec un basculement manuel.
* 📲 **PWA** : Installable comme application sur votre téléphone et comme application Chrome sur votre ordinateur.
* ⚡ **Raccourcis clavier** : Chaque fonction a le sien — appuyez sur `?` pour afficher la liste.
* 🔤 **Multilingue** : L'interface est disponible en 6 langues, et ajouter la vôtre ne demande qu'un paquet de langue.
## 📕 Comment utiliser
### Avec Docker
Une seule commande et c'est parti :
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Ou cliquez sur le bouton « Deploy with Docker » en haut de cette page.
### Déploiement dans un environnement Node
Assurez-vous d'avoir Node.js installé.
Clonez le code :
Assurez-vous d'avoir Node.js installé, puis clonez le code :
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
Installer & Construire. Ce projet utilise pnpm — si vous ne l'avez pas, installez-le d'abord (npm est fourni avec Node, donc cette commande fonctionne toujours) :
Installez et compilez. Ce projet utilise pnpm — si vous ne l'avez pas, installez-le d'abord (npm est fourni avec Node, donc cette commande fonctionne toujours) :
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
Exécuter:
Lancez :
```bash
pnpm start
@@ -84,47 +114,50 @@ pnpm start
Le programme s'exécutera sur le port 18966.
### Using Docker
Cliquez sur le bouton 'Déployer sur Docker' en haut pour terminer le déploiement. Ou utilisez le shell suivant :
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
## 📖 Documentation
Les guides complets se trouvent dans le centre de documentation MyIP : **[docs.ipcheck.ing](https://docs.ipcheck.ing)** (sélecteur de langue en haut à droite)
* [Guide du développeur](https://docs.ipcheck.ing/developer/fr) — déploiement, configuration, architecture et contribution
* [Base de connaissances](https://docs.ipcheck.ing/knowledge-base/fr) — utilisation de chaque outil, diagnostic réseau pas à pas, concepts réseau
## ⚙️ Configuration
Deux réglages comptent avant tout :
> [!IMPORTANT]
> **Les identifiants MaxMind GeoLite2 sont requis.** Ils alimentent la géolocalisation IP et les recherches ASN — sans eux, la source MaxMind renvoie une erreur 503. Ils sont gratuits : → [Configuration MaxMind](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **MaxMind GeoLite2 (requis)** — identifiants gratuits pour la géolocalisation IP et les recherches ASN. Sans eux, la source MaxMind renvoie 503. → [Configuration MaxMind](https://docs.ipcheck.ing/developer/getting-started/fr/maxmind-setup)
* **`ALLOWED_DOMAINS` (requis sur un vrai domaine)** — liste blanche de noms d'hôte pour l'API backend. Sans elle, toute requête venant d'un domaine autre que localhost reçoit un 403. → [Reverse proxy et domaines](https://docs.ipcheck.ing/developer/getting-started/fr/reverse-proxy-and-domains)
> [!WARNING]
> **`ALLOWED_DOMAINS` est requis sur un vrai domaine.** C'est la liste blanche de noms d'hôte pour l'API backend — sans elle, toute requête venant d'un domaine autre que localhost reçoit un 403. → [Reverse proxy et domaines](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e MAXMIND_AUTO_UPDATE="true" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Tout le reste — clés API optionnelles, sécurité et limitation de débit, journalisation, Sentry, domaines de l'API curl — est documenté dans la [référence des variables d'environnement](https://docs.ipcheck.ing/developer/reference/fr/environment-variables).
Tout le reste — clés API optionnelles, sécurité et limitation de débit, journalisation, Sentry, domaines de l'API curl — est documenté dans la [référence des variables d'environnement](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Documentation
Les guides complets se trouvent dans le centre de documentation MyIP : **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Guide du développeur](https://docs.ipcheck.ing/developer) — déploiement, configuration, architecture et contribution
* [Base de connaissances](https://docs.ipcheck.ing/knowledge-base) — comment utiliser chaque outil, diagnostic réseau pas à pas et concepts réseau
## 🤝 Contribuer
Les contributions sont les bienvenues ! Nous maintenons une sélection de tâches adaptées aux débutants — chacune avec les chemins de fichiers exacts, des critères d'acceptation et des tests qui vous guident jusqu'à un build vert :
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — ajouter un résolveur DNS de votre pays, enrichir les listes de sites sélectionnées, traduire le README dans votre langue, peaufiner les traductions, et plus encore
* 🌐 [TRANSLATING.md](TRANSLATING.md) — mettez l'interface dans votre langue : un paquet de langue plus une ligne dans le registre, et **une traduction partielle est une première PR bienvenue**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — installation, conventions et cheminement des PR (ciblez la branche `dev`)
## 👩🏻‍💻 Utilisation avancée
Si vous utilisez un proxy pour accéder à Internet, envisagez d'ajouter cette règle à votre configuration de proxy (modifiez-la en fonction de votre client). Cette configuration vous permet de vérifier à la fois votre véritable adresse IP et l'adresse IP lorsque vous utilisez le proxy :
<details>
<summary>Règles de proxy pour vérifier en même temps votre IP réelle et votre IP de proxy</summary>
Si vous utilisez un proxy pour accéder à Internet, envisagez d'ajouter cette règle à votre configuration de proxy (adaptez-la à votre client). Cette configuration vous permet de vérifier à la fois votre véritable adresse IP et celle utilisée via le proxy :
```ini
# Test d'adresse IP
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
@@ -138,20 +171,43 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
## 💖 Sponsors
</details>
En tant que projet open source, je suis très reconnaissant aux sponsors suivants pour leur soutien :
## 💖 Sponsors et soutiens
<a href="https://www.digitalocean.com/?refcode=fd2634a3981b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge"><img src="https://res.ipcheck.ing/img/digitalocean_logo.png" width="240px" title="DigitalOcean" /></a>
En tant que projet open source, je suis très reconnaissant aux sponsors suivants pour leur soutien :
<a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" title="1Password" width="240px" /></a>
**Sponsors**
<a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" title="Greptile" width="240px" /></a>
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" title="Sentry" width="240px" /></a>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — propose des proxys privés résidentiels, mobiles (4G/5G/LTE), ISP et datacenter IPv4/IPv6, compatibles HTTP(S) et SOCKS5, avec plus de 47 M d'IP résidentielles dans plus de 220 localisations, un ciblage précis par pays, ville et FAI, 99,7 % de disponibilité et un support 24h/24 et 7j/7. Utilisateurs de MyIP : profitez de 15 % de réduction avec le code promo `CHECKINGIP15`.
</sub>
<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=fr"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=fr">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
----
**Soutiens**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Envie de soutenir MyIP ? [Devenir sponsor](https://github.com/sponsors/jason5ng32)
## 📄 Licence
[AGPL-3.0](LICENSE) © Jason Ng
+213
View File
@@ -0,0 +1,213 @@
# 🧰 MyIP - एक बेहतर IP टूलबॉक्स
> [!NOTE]
> यह अनुवाद समुदाय के लोग संभालते हैं. आधिकारिक स्रोत अंग्रेज़ी README है, और हो सकता है यह वर्शन उससे पीछे रह जाए.
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
एक ओपन-सोर्स, ऑल-इन-वन IP टूलबॉक्स: कई स्रोतों से IP की जानकारी, कनेक्टिविटी जांच, WebRTC और DNS लीक की पहचान, स्पीड टेस्ट, MTR, सेंसरशिप जांच, Whois और बहुत कुछ — Docker की एक कमांड से अपने सर्वर पर चलाएं.
👉 डेमो: [https://ipcheck.ing](https://ipcheck.ing)
डेमो को बुकमार्क करें या अपना खुद का वर्शन डिप्लॉय करें.
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 सुविधाएं
### 🪪 आपका IP और पहचान
* 🛜 **IP कार्ड**: कई अलग-अलग स्रोतों से आपका IPv4 और IPv6 एक साथ पता करता है — देश, क्षेत्र, शहर, ASN, संगठन और उस IP का स्थानीय समय क्षेत्र.
* 🔍 **IP खोजें**: किसी भी IP पते की यही पूरी जानकारी देखें.
* 🧾 **IP इतिहास**: आपके इस्तेमाल किए गए IP का लोकल रिकॉर्ड रखता है, जिसे प्रकार और देश के हिसाब से फ़िल्टर किया जा सकता है — यह सिर्फ़ आपके ब्राउज़र में सेव रहता है.
* 🖥️ **ब्राउज़र फ़िंगरप्रिंट**: कई तरीकों से आपके ब्राउज़र का फ़िंगरप्रिंट निकालता है और दिखाता है कि किन चीज़ों से आपकी पहचान हो सकती है.
### 🕵️ लीक और निजता
* 🚥 **WebRTC जांच**: WebRTC कनेक्शन के दौरान दिखने वाला IP पता बताता है — साथ ही यह भी कि आपके ब्राउज़र की निजता सुरक्षा चालू है या नहीं.
* 🛑 **DNS लीक जांच**: दिखाता है कि आपकी DNS क्वेरी कौन-से DNS सर्वर हल करते हैं, ताकि VPN या प्रॉक्सी इस्तेमाल करते समय DNS लीक का खतरा समझा जा सके.
* 🛡️ **ऐड और ट्रैकर ब्लॉकिंग जांच**: जांचता है कि आपका ऐड ब्लॉकर, ब्राउज़र, DNS फ़िल्टर, VPN या राउटर असल में क्या ब्लॉक करता है — ऐड नेटवर्क, एनालिटिक्स, सोशल ट्रैकर, एरर मॉनिटरिंग, डिवाइस बनाने वाली कंपनियों की टेलीमेट्री, ट्रैकिंग स्क्रिप्ट और एलिमेंट छिपाना.
* 📋 **सुरक्षा चेकलिस्ट**: 12 क्षेत्रों में 258 बातों वाली निजी साइबर सुरक्षा चेकलिस्ट, जिसकी प्रगति आपके ब्राउज़र में सेव रहती है.
### 📡 नेटवर्क जांच
* 🚦 **कनेक्टिविटी जांच**: आपकी चुनी हुई 60 तक साइटें खुल रही हैं या नहीं, यह जांचता है और कई राउंड में सबसे कम इंतज़ार का समय दिखाता है — साथ में तैयार सूचियां भी हैं, देशों की सूचियों से लेकर AI, सोशल, स्ट्रीमिंग, गेमिंग, डेवलपर और बहुत कुछ. नतीजों के आधार पर यह बताता है कि अभी आप दुनिया भर का इंटरनेट इस्तेमाल कर सकते हैं या नहीं.
* 🚀 **स्पीड टेस्ट**: एज नेटवर्क से आपकी डाउनलोड, अपलोड स्पीड और इंतज़ार का समय मापता है.
* ⏱️ **दुनिया भर से पिंग जांच**: दुनिया भर के प्रोब से आपके टारगेट को पिंग करता है — सभी उपलब्ध Globalping प्रोब में से, महाद्वीप के हिसाब से देश चुनें.
* 🚉 **MTR जांच**: दुनिया भर में फैले प्रोब से MTR चलाकर दिखाता है कि पैकेट असल में किस रास्ते से जाते हैं.
* 🚧 **सेंसरशिप जांच**: दिखाता है कि कोई वेबसाइट दुनिया में कहां-कहां ब्लॉक है — और किस तरीके से.
* 🚏 **प्रॉक्सी नियम जांच**: पक्का करता है कि आपके प्रॉक्सी सॉफ़्टवेयर के नियम वैसे ही काम कर रहे हैं जैसा आपने सोचा था.
### 🔦 लुकअप और इन्फ़्रास्ट्रक्चर
* 📟 **DNS रिज़ॉल्वर**: एक डोमेन को एक साथ कई रिज़ॉल्वर से हल करता है, देश के हिसाब से ग्रुप करके — DNS हाइजैकिंग या गड़बड़ी पकड़ने का आसान तरीका.
* 📓 **Whois खोज**: डोमेन नाम, IP पते और AS नंबर के लिए Whois लुकअप करता है.
* 🗄️ **MAC लुकअप**: किसी हार्डवेयर (MAC) पते के पीछे की कंपनी और जानकारी बताता है.
* 🧮 **IP कैलकुलेटर**: किसी भी IP, प्रीफ़िक्स, रेंज या सूची के लिए सबनेट का हिसाब, फ़ॉर्मैट बदलना और IPv6 इंटरफ़ेस की जानकारी — सब कुछ आपके डिवाइस पर ही.
* 🛰️ **ASN जानकारी और अपस्ट्रीम टोपोलॉजी**: हर IP कार्ड पर ही — AS की जानकारी, IP के प्रीफ़िक्स की पिछली घोषणाएं, और उसके ASN से Tier 1 बैकबोन तक के अपस्ट्रीम रास्ते.
* 🛂 **ASN प्रोफ़ाइल**: किसी भी AS की पूरी तस्वीर — मूल Whois रिकॉर्ड के साथ रजिस्ट्रेशन, दुनिया में रैंक और आकार, ट्रैफ़िक और कनेक्शन की गुणवत्ता, उसका IPv4 स्पेस कहां इस्तेमाल होता है, RPKI स्थिति के साथ हर घोषित प्रीफ़िक्स, उसके अपस्ट्रीम, पीयर और ग्राहक, और वे इंटरनेट एक्सचेंज और डेटा सेंटर जहां वह मौजूद है.
* 📶 **सेवा की स्थिति**: जानी-मानी सेवाओं — Claude, OpenAI, GitHub, Cloudflare और दूसरी — की लाइव स्थिति, उनके आधिकारिक स्टेटस पेज से, हाल की घटनाओं के साथ.
### ✨ प्लैटफ़ॉर्म
* 📤 **शेयर करने लायक रिपोर्ट**: अपनी जांच के नतीजों से एक जांच रिपोर्ट बनाएं — अपने-आप खत्म होने वाला सिर्फ़ पढ़ने लायक लिंक, AI के लिए तैयार Markdown, या JSON.
* ⌨️ **Curl API**: टर्मिनल से एक `curl` कमांड में अपना IP जानें.
* 🌍 **Earth Online**: एक पैनल जो दुनिया भर में इंटरनेट ठप होने की घटनाएं होते ही दिखाता है.
* 🌗 **डार्क मोड**: अपने-आप आपके सिस्टम की सेटिंग अपनाता है, और हाथ से बदलने का विकल्प भी है.
* 📲 **PWA**: फ़ोन पर ऐप की तरह और डेस्कटॉप पर Chrome ऐप की तरह इंस्टॉल किया जा सकता है.
* ⚡ **कीबोर्ड शॉर्टकट**: हर सुविधा का एक शॉर्टकट है — सूची देखने के लिए `?` दबाएं.
* 🔤 **कई भाषाएं**: UI 6 भाषाओं में उपलब्ध है, और अपनी भाषा जोड़ने के लिए बस एक लोकेल पैक चाहिए.
## 📕 इस्तेमाल कैसे करें
### Docker से
बस एक कमांड और यह चालू:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
या इस पेज के ऊपर दिए "Deploy with Docker" बटन पर क्लिक करें.
### Node के साथ डिप्लॉय करना
पक्का करें कि Node.js इंस्टॉल है, फिर कोड क्लोन करें:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
इंस्टॉल और बिल्ड करें. यह प्रोजेक्ट pnpm इस्तेमाल करता है — अगर आपके पास नहीं है, तो पहले इसे इंस्टॉल करें (npm, Node के साथ आता है, इसलिए यह कमांड हमेशा काम करती है):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
चलाएं:
```bash
pnpm start
```
प्रोग्राम पोर्ट 18966 पर चलेगा.
## ⚙️ कॉन्फ़िगरेशन
> [!IMPORTANT]
> **MaxMind GeoLite2 क्रेडेंशियल ज़रूरी हैं.** IP की जगह और ASN की जानकारी इन्हीं से मिलती है — इनके बिना MaxMind स्रोत 503 लौटाता है. ये मुफ़्त हैं: → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **असली डोमेन पर `ALLOWED_DOMAINS` ज़रूरी है.** यह बैकएंड API के लिए अनुमति वाले होस्टनेम की सूची है — इसके बिना localhost के अलावा किसी भी डोमेन से आने वाले हर अनुरोध को 403 मिलता है. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
बाकी सब कुछ — वैकल्पिक API कुंजियां, सुरक्षा और रेट लिमिटिंग, लॉगिंग, Sentry, curl API के डोमेन — [Environment Variables reference](https://docs.ipcheck.ing/developer/reference/environment-variables) में लिखा है.
## 📖 दस्तावेज़
पूरी गाइड MyIP Docs Center में हैं: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Developer Guide](https://docs.ipcheck.ing/developer) — डिप्लॉयमेंट, कॉन्फ़िगरेशन, आर्किटेक्चर और योगदान
* [Knowledge Base](https://docs.ipcheck.ing/knowledge-base) — हर टूल का इस्तेमाल, नेटवर्क की गड़बड़ी को कदम-दर-कदम समझना, और नेटवर्किंग की बुनियादी बातें
## 🤝 योगदान
योगदान का स्वागत है! हम नए लोगों के लिए आसान कामों की एक चुनी हुई सूची रखते हैं — हर काम में सही फ़ाइल पाथ, पूरा होने की शर्तें और टेस्ट हैं, जो आपको ग्रीन बिल्ड तक ले जाते हैं:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — अपने देश का DNS रिज़ॉल्वर जोड़ें, साइटों की सूचियां जोड़ें, README का अपनी भाषा में अनुवाद करें, अनुवाद बेहतर बनाएं, और भी बहुत कुछ
* 🌐 [TRANSLATING.md](TRANSLATING.md) — UI को अपनी भाषा में लाएं: एक लोकेल पैक और रजिस्ट्री की एक लाइन, और **अधूरा अनुवाद भी पहले PR के रूप में स्वागत योग्य है**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — सेटअप, नियम, और PR कैसे आगे बढ़ते हैं (`dev` ब्रांच को टारगेट करें)
## 👩🏻‍💻 ऐडवांस इस्तेमाल
<details>
<summary>असली IP और प्रॉक्सी IP दोनों एक साथ जांचने के लिए प्रॉक्सी नियम</summary>
अगर आप इंटरनेट के लिए प्रॉक्सी इस्तेमाल करते हैं, तो यह नियम अपनी प्रॉक्सी कॉन्फ़िगरेशन में जोड़ें (अपने क्लाइंट के हिसाब से बदल लें). इससे आप अपना असली IP और प्रॉक्सी वाला IP, दोनों देख सकते हैं:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 स्पॉन्सर और सपोर्टर
एक ओपन-सोर्स प्रोजेक्ट के तौर पर, मैं इन स्पॉन्सर का उनके सहयोग के लिए बहुत आभारी हूं:
**स्पॉन्सर**
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — offers private residential, mobile (4G/5G/LTE), ISP, and datacenter IPv4/IPv6 proxies with HTTP(S) and SOCKS5 support, 47M+ residential IPs in 220+ locations, precise country/city/ISP targeting, 99.7% uptime, and 24/7 support. MyIP users can use promo code `CHECKINGIP15` to get 15% off.
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=hi"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=hi">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
----
**सपोर्टर**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
MyIP को स्पॉन्सर करना चाहते हैं? [स्पॉन्सर बनें](https://github.com/sponsors/jason5ng32)
## 📄 लाइसेंस
[AGPL-3.0](LICENSE) © Jason Ng
+214
View File
@@ -0,0 +1,214 @@
# 🧰 MyIP - Uma caixa de ferramentas de IP aprimorada
> [!NOTE]
> Esta é uma tradução mantida pela comunidade; o README em inglês é a fonte canônica e esta versão pode ficar desatualizada.
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
Uma caixa de ferramentas de IP completa e de código aberto: consulta de IP a partir de múltiplas fontes, testes de conectividade, detecção de WebRTC e de vazamento de DNS, teste de velocidade, MTR, verificação de censura, Whois e muito mais — auto-hospedável com um único comando Docker.
👉 Demonstração: [https://ipcheck.ing](https://ipcheck.ing)
Adicione a demonstração aos favoritos ou faça sua própria implantação.
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 Recursos
### 🪪 Seu IP e identidade
* 🛜 **Cartões de IP**: Detecta seus IPv4 e IPv6 a partir de várias fontes independentes, lado a lado — país, região, cidade, ASN, organização e o fuso horário local do IP.
* 🔍 **Verificação de IP**: Consulta as mesmas informações detalhadas de qualquer endereço IP que despertar sua curiosidade.
* 🧾 **Histórico de IP**: Mantém um registro local dos IPs com os quais você já foi visto, filtrável por tipo e país — armazenado apenas no seu navegador.
* 🖥️ **Impressão digital do navegador**: Calcula a impressão digital do seu navegador de várias formas e mostra o que torna você identificável.
### 🕵️ Vazamentos e privacidade
* 🚥 **Detecção de WebRTC**: Revela o endereço IP exposto durante conexões WebRTC — inclusive se as proteções de privacidade do seu navegador estão ativas.
* 🛑 **Teste de vazamento de DNS**: Mostra quais endpoints DNS resolvem suas consultas, para avaliar o risco de vazamentos de DNS ao usar VPNs ou proxies.
* 🛡️ **Teste de bloqueio de anúncios e rastreadores**: Verifica o que seu bloqueador de anúncios, navegador, filtro de DNS, VPN ou roteador realmente bloqueia — redes de anúncios, análise, rastreadores sociais, monitoramento de erros, telemetria de fabricantes de dispositivos, scripts de rastreamento e ocultação de elementos.
* 📋 **Checklist de segurança**: Um checklist pessoal de cibersegurança com 258 itens em 12 áreas, com progresso salvo no seu navegador.
### 📡 Testes de rede
* 🚦 **Conectividade de rede**: Testa a acessibilidade de até 60 sites da sua escolha, com resultados de latência mínima em múltiplas rodadas — além de listas de importação selecionadas, de pacotes por país a IA, redes sociais, streaming, jogos, desenvolvimento e mais. Com base nos resultados, indica se o acesso global à Internet está viável para você no momento.
* 🚀 **Teste de velocidade**: Mede seu download, upload e latência em redes de borda.
* ⏱️ **Teste de latência global**: Faz ping no seu alvo a partir de sondas espalhadas pelo mundo — escolha países entre todas as sondas Globalping disponíveis, agrupadas por continente.
* 🚉 **Teste MTR**: Executa MTR a partir de sondas distribuídas globalmente para ver a rota que os pacotes realmente percorrem.
* 🚧 **Verificação de censura**: Mostra onde um site está bloqueado no mundo — e por quais meios.
* 🚏 **Teste de regras de proxy**: Verifica se a configuração de regras do seu software de proxy funciona do jeito que você pretendia.
### 🔦 Consultas e infraestrutura
* 📟 **Resolução DNS**: Resolve um domínio por vários resolvedores de uma só vez, agrupados por país — um jeito fácil de detectar sequestro ou contaminação.
* 📓 **Pesquisa Whois**: Realiza consultas Whois para nomes de domínio, endereços IP e números de AS.
* 🗄️ **Consulta de MAC**: Identifica o fabricante e os detalhes por trás de um endereço físico.
* 🧮 **Calculadora de IP**: Cálculo de sub-rede, conversões de notação e detalhes de interface IPv6 para qualquer IP, prefixo, intervalo ou lista, tudo localmente.
* 🛰️ **Informações de ASN e topologia de upstream**: Direto em cada cartão de IP — detalhes do AS, anúncios históricos do prefixo do IP e os caminhos de upstream do seu ASN até o backbone Tier 1.
* 🛂 **Perfil de ASN**: O retrato completo de qualquer AS — dados de registro com o Whois bruto, ranking global e porte, tráfego e qualidade de conexão, os países onde seu espaço IPv4 é usado, cada prefixo anunciado com seu status RPKI, seus upstreams, peers e clientes, e os pontos de troca de tráfego e data centers em que está presente.
* 📶 **Status dos serviços**: Disponibilidade em tempo real de serviços conhecidos — Claude, OpenAI, GitHub, Cloudflare e outros — a partir de suas páginas oficiais de status, com incidentes recentes.
### ✨ Plataforma
* 📤 **Relatórios compartilháveis**: Transforme seus resultados de teste em um relatório diagnóstico — um link somente leitura com expiração automática, Markdown pronto para IA ou JSON.
* ⌨️ **API de linha de comando**: Obtenha seu IP no terminal com um único comando `curl`.
* 🌍 **Earth Online**: Um painel que transmite eventos globais de indisponibilidade da Internet em tempo real.
* 🌗 **Modo escuro**: Acompanha automaticamente o sistema, com alternância manual.
* 📲 **PWA**: Instalável como aplicativo no celular e como app do Chrome no computador.
* ⚡ **Atalhos de teclado**: Toda função tem o seu — pressione `?` para ver a lista.
* 🔤 **Vários idiomas**: A interface é distribuída em 6 idiomas, e adicionar o seu exige apenas um pacote de locale.
## 📕 Como usar
### Usando Docker
Um comando e pronto:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Ou clique no botão "Deploy with Docker" no topo desta página.
### Implantação em um ambiente Node
Certifique-se de que o Node.js esteja instalado e, em seguida, clone o código:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
Instale e compile. Este projeto usa pnpm — se você ainda não o tiver, instale-o primeiro (o npm acompanha o Node; portanto, este comando sempre funciona):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
Execute:
```bash
pnpm start
```
O programa será executado na porta 18966.
## ⚙️ Configuração
> [!IMPORTANT]
> **As credenciais do MaxMind GeoLite2 são obrigatórias.** Elas viabilizam a geolocalização de IP e as consultas ASN — sem elas, a fonte MaxMind retorna 503. Elas são gratuitas: → [Configuração do MaxMind](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **`ALLOWED_DOMAINS` é obrigatório em um domínio real.** É a lista de nomes de host permitidos para a API de backend — sem ela, toda solicitação proveniente de um domínio que não seja localhost recebe 403. → [Proxy reverso e domínios](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Todo o restante — chaves de API opcionais, segurança e limitação de taxa, registros, Sentry e os domínios da API curl — está documentado na [referência de variáveis de ambiente](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Documentação
Os guias completos estão no Centro de Documentação do MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Guia do desenvolvedor](https://docs.ipcheck.ing/developer) — implantação, configuração, arquitetura e contribuição
* [Base de conhecimento](https://docs.ipcheck.ing/knowledge-base) — como usar cada ferramenta, diagnóstico de rede passo a passo e conceitos de redes
## 🤝 Contribuições
As contribuições são bem-vindas! Mantemos uma seleção de tarefas adequadas para iniciantes — cada uma com caminhos de arquivo exatos, critérios de aceitação e testes que conduzem a uma compilação bem-sucedida:
* 🏷️ [Issues para iniciantes](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — adicione um resolvedor DNS do seu país, inclua listas de sites selecionadas, traduza o README para seu idioma, aprimore traduções e muito mais
* 🌐 [TRANSLATING.md](TRANSLATING.md) — leve a UI ao seu idioma: um pacote de locale e uma linha no registro; uma **tradução parcial é bem-vinda em uma primeira PR**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — instalação, convenções e o fluxo das PRs (direcione-as para a branch `dev`)
## 👩🏻‍💻 Uso avançado
<details>
<summary>Regras de proxy para verificar seu IP real e seu IP de proxy ao mesmo tempo</summary>
Se você usa um proxy para acessar a Internet, considere adicionar esta regra à configuração do proxy (adapte-a ao seu cliente). Essa configuração permite verificar tanto seu IP real quanto o IP usado com o proxy:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 Patrocinadores e apoiadores
Como projeto de código aberto, sou muito grato aos seguintes patrocinadores pelo apoio:
**Patrocinadores**
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — oferece proxies privados residenciais, móveis (4G/5G/LTE), ISP e datacenter IPv4/IPv6, com suporte a HTTP(S) e SOCKS5, mais de 47 milhões de IPs residenciais em mais de 220 localizações, segmentação precisa por país, cidade e provedor, 99,7% de disponibilidade e suporte 24/7. Usuários do MyIP podem usar o código promocional `CHECKINGIP15` para ganhar 15% de desconto.
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=pt-BR"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=pt-BR">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
----
**Apoiadores**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Quer patrocinar o MyIP? [Seja um patrocinador](https://github.com/sponsors/jason5ng32)
## 📄 Licença
[AGPL-3.0](LICENSE) © Jason Ng
+126 -70
View File
@@ -1,127 +1,160 @@
# 🧰 MyIP — удобный набор инструментов для работы с IP
> [!NOTE]
> Это перевод, поддерживаемый сообществом; английская версия README является основной, и данная версия может отставать от неё.
<div align="center">
![Баннер IPCheck.ing](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Упоминается в Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![Звёзды репозитория GitHub](https://img.shields.io/github/stars/jason5ng32/MyIP)
![Форки GitHub](https://img.shields.io/github/forks/jason5ng32/myip)
![Загрузки Docker](https://img.shields.io/docker/pulls/jason5ng32/myip)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Сайт](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Сборка и публикация Docker](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
🇺🇸 [English](README.md) | 🇨🇳 [简体中文](README_ZH.md) | 🇷🇺 [Русский](README_RU.md) | 🇫🇷 [Français](README_FR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
Универсальный набор IP-инструментов с открытым исходным кодом: определение IP по нескольким источникам, проверка доступности сайтов, обнаружение утечек WebRTC и DNS, тест скорости, MTR, проверка цензуры, Whois и многое другое — разворачивается на своём сервере одной командой Docker.
👉 Демо: [https://ipcheck.ing](https://ipcheck.ing)
Добавьте демо-сайт в закладки или разверните собственный экземпляр.
Добавьте демо в закладки или разверните собственный экземпляр.
[![Развернуть с помощью Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 Основные возможности
## 👀 Возможности
* 🛜 **Просмотр ваших IP-адресов**: обнаруживает и показывает локальные IP-адреса, используя несколько источников IPv4 и IPv6.
* 🔍 **Поиск информации об IP**: позволяет получить сведения о любом IP-адресе.
* 🕵️ **Информация об IP**: показывает подробные сведения обо всех IP-адресах, включая страну, регион, ASN, географическое положение и многое другое.
* 🛰️ **История ASN и топология вышестоящих сетей**: показывает историю анонсов AS для IP-префикса и визуализирует пути от ASN к магистральным сетям Tier 1.
* 🚦 **Проверка доступности**: проверяет доступность различных сайтов, например Google, GitHub, YouTube, ChatGPT и других.
* 📡 **Состояние сервисов**: показывает текущую доступность известных сервисов (Claude, OpenAI, GitHub, Cloudflare и других) по данным их официальных страниц состояния, включая состояние отдельных компонентов и недавние инциденты.
* 🚥 **Проверка WebRTC**: определяет IP-адрес, используемый при подключениях WebRTC.
* 🛑 **Тест утечки DNS**: показывает данные конечных точек DNS, чтобы оценить риск утечки DNS при использовании VPN или прокси.
* 🚀 **Тест скорости**: проверяет скорость сети с помощью пограничной инфраструктуры.
* 🚏 **Проверка правил прокси**: проверяет корректность правил маршрутизации в прокси-программах.
* ⏱️ **Глобальный тест задержки**: измеряет задержку с серверов, расположенных в разных регионах мира.
* 🚉 **Тест MTR**: выполняет MTR-тесты с серверов, расположенных в разных регионах мира.
* 🔦 **DNS-резолвер**: выполняет разрешение доменного имени через несколько источников и получает актуальные результаты, которые можно использовать для выявления подмены DNS.
* 🚧 **Проверка цензуры**: проверяет, заблокирован ли сайт в некоторых странах.
* 📓 **Поиск Whois**: получает регистрационные сведения о доменных именах и IP-адресах.
* 📀 **Поиск MAC-адреса**: получает сведения о физическом адресе устройства.
* 🖥️ **Цифровой отпечаток браузера**: позволяет рассчитать отпечаток браузера несколькими способами.
* 📋 **Контрольный список кибербезопасности**: содержит 258 рекомендаций по комплексной защите цифровой жизни.
### 🪪 Ваш IP и идентичность
## 💪 Дополнительно
* 🛜 **Карточки IP**: определяют ваши IPv4 и IPv6 по нескольким независимым источникам одновременно — страна, регион, город, ASN, организация и местный часовой пояс IP-адреса.
* 🔍 **Проверка IP**: показывает те же подробные сведения о любом интересующем вас IP-адресе.
* 🧾 **История IP**: ведёт локальный журнал IP-адресов, с которых вас видели в сети, с фильтрами по типу и стране — данные хранятся только в вашем браузере.
* 🖥️ **Цифровой отпечаток браузера**: рассчитывает отпечаток браузера несколькими способами и показывает, что именно делает вас узнаваемым.
* 🌗 **Тёмная тема**: автоматически переключается между светлой и тёмной темой в соответствии с настройками системы; также доступно ручное переключение.
* 📲 **Поддержка PWA**: сайт можно установить как приложение на телефон или компьютер через Chrome.
* ⌨️ **Сочетания клавиш**: все функции поддерживают горячие клавиши; нажмите `?`, чтобы открыть их список.
* 🌍 По результатам проверки доступности определяется, возможен ли полноценный доступ к глобальному интернету.
* 🇺🇸 🇨🇳 🇷🇺 🇫🇷 Поддерживаются английский, китайский, русский и французский языки.
### 🕵️ Утечки и приватность
* 🚥 **Обнаружение WebRTC**: показывает IP-адрес, раскрываемый при подключениях WebRTC, — в том числе включена ли в вашем браузере усиленная защита приватности.
* 🛑 **Тест утечки DNS**: показывает, какие конечные точки DNS обрабатывают ваши запросы, чтобы оценить риск утечки DNS при использовании VPN или прокси.
* 🛡️ **Проверка блокировки рекламы и трекеров**: показывает, что на самом деле блокирует ваш блокировщик рекламы, браузер, DNS-фильтр, VPN или роутер, — рекламные сети, аналитику, трекеры соцсетей, мониторинг ошибок, телеметрию производителей устройств, скрипты отслеживания и скрытие элементов.
* 📋 **Контрольный список безопасности**: 258 рекомендаций по личной кибербезопасности в 12 разделах; прогресс сохраняется в браузере.
### 📡 Сетевые тесты
* 🚦 **Проверка доступности**: проверяет доступность до 60 сайтов на ваш выбор, показывая минимальную задержку по итогам нескольких раундов, а готовые списки для импорта охватывают подборки по странам, ИИ-сервисы, соцсети, стриминг, игры, инструменты разработчика и многое другое. По результатам инструмент показывает, доступен ли вам сейчас глобальный интернет.
* 🚀 **Тест скорости**: измеряет скорость загрузки, отдачи и задержку через периферийные сети.
* ⏱️ **Глобальный тест задержки**: пингует вашу цель с зондов по всему миру — выбирайте страны из всех доступных зондов Globalping, сгруппированных по континентам.
* 🚉 **Тест MTR**: запускает MTR с распределённых по миру зондов, чтобы увидеть, каким маршрутом на самом деле идут пакеты.
* 🚧 **Проверка цензуры**: показывает, где в мире сайт заблокирован и какими методами.
* 🚏 **Проверка правил прокси**: проверяет, что правила в вашей прокси-программе работают именно так, как вы задумали.
### 🔦 Поиск и инфраструктура
* 📟 **Разрешение DNS**: разрешает домен сразу через несколько резолверов, сгруппированных по странам, — простой способ заметить перехват или загрязнение DNS.
* 📓 **Поиск Whois**: выполняет Whois-запросы для доменных имён, IP-адресов и номеров AS.
* 🗄️ **Поиск MAC-адреса**: определяет производителя и другие сведения по физическому адресу.
* 🧮 **IP-калькулятор**: расчёт подсетей, преобразование записей и сведения об интерфейсе IPv6 для любого IP, префикса, диапазона или списка — всё локально.
* 🛰️ **Сведения об ASN и топология вышестоящих сетей**: прямо в каждой карточке IP — данные автономной системы, история анонсов префикса этого IP и пути от её ASN к магистральным сетям Tier 1.
* 🛂 **Профиль ASN**: полная картина любой AS — регистрационные данные с исходной записью Whois, место в мировом рейтинге и масштаб, состав трафика и качество соединения, страны, где используются её IPv4-адреса, все анонсируемые префиксы со статусом RPKI, вышестоящие сети, пиры и клиенты, а также точки обмена трафиком и дата-центры, где она присутствует.
* 📶 **Состояние сервисов**: актуальная доступность известных сервисов — Claude, OpenAI, GitHub, Cloudflare и других — по данным их официальных страниц состояния, включая недавние инциденты.
### ✨ Платформа
* 📤 **Диагностические отчёты**: превращают результаты тестов в отчёт, которым можно поделиться, — ссылка только для чтения с автоматическим истечением срока, Markdown для ИИ или JSON.
* ⌨️ **Curl API**: узнайте свой IP прямо из терминала одной командой `curl`.
* 🌍 **Земля онлайн**: панель, транслирующая глобальные сбои интернета в момент их возникновения.
* 🌗 **Тёмная тема**: автоматически следует настройкам системы, с ручным переключателем.
* 📲 **PWA**: устанавливается как приложение на телефон и как приложение Chrome на компьютер.
* ⚡ **Сочетания клавиш**: есть у каждой функции — нажмите `?`, чтобы увидеть список.
* 🔤 **Несколько языков**: интерфейс доступен на 6 языках, а чтобы добавить свой, достаточно одного языкового пакета.
## 📕 Использование
### Развёртывание в среде Node.js
### Использование Docker
Убедитесь, что Node.js установлен.
Одна команда — и всё готово:
Клонируйте репозиторий:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Или нажмите кнопку «Deploy with Docker» в верхней части этой страницы.
### Развёртывание в среде Node
Убедитесь, что Node.js установлен, затем клонируйте репозиторий:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
Установите зависимости и соберите проект. Проект использует pnpm. Если pnpm ещё не установлен, сначала установите его (npm входит в состав Node.js, поэтому эта команда всегда доступна):
Установите зависимости и соберите проект. Проект использует pnpm — если он ещё не установлен, сначала установите его (npm входит в состав Node, поэтому эта команда всегда доступна):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
Запустите проект:
Запустите:
```bash
pnpm start
```
Приложение будет доступно на порту 18966.
### Использование Docker
Нажмите кнопку «Развернуть с помощью Docker» в верхней части страницы или выполните следующую команду:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
## 📖 Документация
Полные руководства — в центре документации MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)** (переключатель языка в правом верхнем углу)
* [Руководство разработчика](https://docs.ipcheck.ing/developer/ru) — развёртывание, настройка, архитектура и участие в разработке
* [База знаний](https://docs.ipcheck.ing/knowledge-base/ru) — как пользоваться каждым инструментом, пошаговая диагностика сети, сетевые концепции
Приложение будет работать на порту 18966.
## ⚙️ Конфигурация
Прежде всего важны две настройки:
> [!IMPORTANT]
> **Учётные данные MaxMind GeoLite2 обязательны.** Они обеспечивают геолокацию IP и запросы ASN — без них источник MaxMind возвращает 503. Они бесплатны: → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **MaxMind GeoLite2 (обязательно)** — бесплатные учётные данные для геолокации IP и запросов ASN. Без них источник MaxMind возвращает 503. → [Настройка MaxMind](https://docs.ipcheck.ing/developer/getting-started/ru/maxmind-setup)
* **`ALLOWED_DOMAINS` (обязательно на реальном домене)** — белый список хостов для backend API. Без него любой запрос с домена, отличного от localhost, получает 403. → [Обратный прокси и домены](https://docs.ipcheck.ing/developer/getting-started/ru/reverse-proxy-and-domains)
> [!WARNING]
> **`ALLOWED_DOMAINS` обязателен на реальном домене.** Это список разрешённых хостов для backend API — без него любой запрос с домена, отличного от localhost, получает 403. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e MAXMIND_AUTO_UPDATE="true" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Всё остальное — необязательные ключи API, безопасность и ограничение частоты запросов, логирование, Sentry, домены curl API — описано в [справочнике переменных окружения](https://docs.ipcheck.ing/developer/reference/ru/environment-variables).
Всё остальное — необязательные ключи API, безопасность и ограничение частоты запросов, логирование, Sentry, домены curl API — описано в [справочнике переменных окружения](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Документация
Полные руководства — в центре документации MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Руководство разработчика](https://docs.ipcheck.ing/developer) — развёртывание, настройка, архитектура и участие в разработке
* [База знаний](https://docs.ipcheck.ing/knowledge-base) — как пользоваться каждым инструментом, пошаговая диагностика сети и сетевые концепции
## 🤝 Участие в проекте
Мы рады вашему участию! У нас есть подборка задач для новичков — с точными путями к файлам, критериями приёмки и тестами, которые ведут вас к зелёной сборке:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — добавьте DNS-резолвер своей страны, расширьте подборки сайтов, переведите README на свой язык, улучшите переводы и не только
* 🌐 [TRANSLATING.md](TRANSLATING.md) — переведите интерфейс на свой язык: языковой пакет плюс одна строка в реестре, причём **частичный перевод — отличный первый PR**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — настройка окружения, соглашения и процесс работы с PR (направляйте их в ветку `dev`)
## 👩🏻‍💻 Расширенное использование
Если для доступа в интернет используется прокси, добавьте следующее правило в конфигурацию прокси-клиента, изменив его под своё приложение. Это позволит проверять как настоящий IP-адрес, так и адрес, используемый через прокси:
<details>
<summary>Правила прокси для одновременной проверки реального IP и IP через прокси</summary>
Если вы выходите в интернет через прокси, добавьте это правило в конфигурацию прокси (адаптировав его под свой клиент). Такая настройка позволяет проверять и ваш реальный IP, и IP при использовании прокси:
```ini
# IP Testing
@@ -138,20 +171,43 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
## 💖 Спонсоры
</details>
Я очень благодарен следующим спонсорам за поддержку проекта с открытым исходным кодом:
## 💖 Спонсоры и поддержка
<a href="https://www.digitalocean.com/?refcode=fd2634a3981b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge"><img src="https://res.ipcheck.ing/img/digitalocean_logo.png" width="240px" title="DigitalOcean" /></a>
Как автор проекта с открытым исходным кодом, я очень благодарен следующим спонсорам за поддержку:
<a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" title="1Password" width="240px" /></a>
**Спонсоры**
<a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" title="Greptile" width="240px" /></a>
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" title="Sentry" width="240px" /></a>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — приватные резидентские, мобильные (4G/5G/LTE), ISP и датацентр IPv4/IPv6 прокси с поддержкой HTTP(S) и SOCKS5. Более 47 млн резидентских IP в 220+ локациях, точный таргетинг по стране, городу и провайдеру, аптайм 99,7% и поддержка 24/7. Пользователям MyIP — промокод `CHECKINGIP15` со скидкой 15%.
</sub>
<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=ru"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=ru">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
----
**Поддержка**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Хотите поддержать MyIP? [Стать спонсором](https://github.com/sponsors/jason5ng32)
## 📄 Лицензия
[AGPL-3.0](LICENSE) © Jason Ng
+213
View File
@@ -0,0 +1,213 @@
# 🧰 MyIP - Një kuti më e mirë mjetesh IP
> [!NOTE]
> Ky është një përkthim i mirëmbajtur nga komuniteti; README-ja në anglisht është burimi zyrtar dhe ky version mund të mbetet prapa.
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
Një kuti mjetesh IP gjithëpërfshirëse dhe me burim të hapur: kërkim IP nga shumë burime, teste lidhshmërie, zbulim i rrjedhjeve WebRTC dhe DNS, test shpejtësie, MTR, kontrolle censure, Whois e shumë të tjera — e vetë-strehueshme me një komandë të vetme Docker.
👉 Demo: [https://ipcheck.ing](https://ipcheck.ing)
Mund ta ruani demon te faqeshënuesit ose të vendosni instancën tuaj.
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 Veçoritë
### 🪪 IP-ja dhe identiteti juaj
* 🛜 **Kartat e IP-së**: Zbulojnë IPv4 dhe IPv6 tuaj nga disa burime të pavarura krah për krah — shtetin, rajonin, qytetin, ASN-në, organizatën dhe zonën kohore lokale të IP-së.
* 🔍 **Kërkimi i IP-së**: Kërkon të njëjtin informacion të detajuar për çdo adresë IP për të cilën jeni kureshtarë.
* 🧾 **Historiku i IP-ve**: Mban një regjistër lokal të IP-ve me të cilat jeni parë, të filtrueshëm sipas llojit dhe shtetit — i ruajtur vetëm në shfletuesin tuaj.
* 🖥️ **Gjurma e shfletuesit**: Llogarit gjurmën e shfletuesit tuaj në disa mënyra dhe tregon çfarë ju bën të identifikueshëm.
### 🕵️ Rrjedhjet dhe privatësia
* 🚥 **Zbulimi WebRTC**: Zbulon adresën IP që ekspozohet gjatë lidhjeve WebRTC — përfshirë nëse mbrojtja e privatësisë së shfletuesit tuaj është aktive.
* 🛑 **Testi i rrjedhjes DNS**: Tregon cilat pika fundore DNS i zgjidhin kërkesat tuaja, për të vlerësuar rrezikun e rrjedhjeve DNS kur përdorni VPN ose proxy.
* 🛡️ **Testi i bllokimit të reklamave dhe gjurmuesve**: Kontrollon çfarë bllokojnë vërtet bllokuesi i reklamave, shfletuesi, filtri DNS, VPN-ja ose router-i juaj — rrjetet e reklamave, analitikën, gjurmuesit socialë, monitorimin e gabimeve, telemetrinë e prodhuesve të pajisjeve, skriptet e gjurmimit dhe fshehjen e elementeve.
* 📋 **Lista e sigurisë**: Një listë personale kontrolli për sigurinë kibernetike me 258 pika në 12 fusha, me progresin të ruajtur në shfletuesin tuaj.
### 📡 Testet e rrjetit
* 🚦 **Kontrolli i lidhshmërisë**: Teston arritshmërinë e deri në 60 faqeve sipas zgjedhjes suaj, me rezultate të vonesës minimale në disa raunde — plus lista të kuruara për import, nga paketat për shtete te AI, rrjetet sociale, transmetimi, lojërat, zhvilluesit e të tjera. Bazuar në rezultatet, tregon nëse aksesi në internetin global është aktualisht i mundur për ju.
* 🚀 **Testi i shpejtësisë**: Mat shkarkimin, ngarkimin dhe vonesën tuaj kundrejt rrjeteve edge.
* ⏱️ **Testi global i vonesës**: Bën ping objektivit tuaj nga sonda në mbarë botën — zgjidhni shtetet nga të gjitha sondat e disponueshme të Globalping, të grupuara sipas kontinentit.
* 🚉 **Testi MTR**: Ekzekuton MTR nga sonda të shpërndara globalisht për të parë rrugën që ndjekin realisht paketat.
* 🚧 **Kontrolli i censurës**: Tregon ku është bllokuar një faqe interneti në mbarë botën — dhe me çfarë mjetesh.
* 🚏 **Testi i rregullave të proxy-t**: Verifikon që konfigurimi i rregullave të softuerit tuaj proxy funksionon ashtu siç e keni menduar.
### 🔦 Kërkimi dhe infrastruktura
* 📟 **Zgjidhësi DNS**: Zgjidh një domen përmes disa zgjidhësve njëkohësisht, të grupuar sipas shtetit — një mënyrë e thjeshtë për të vënë re rrëmbimin ose ndotjen.
* 📓 **Kërkimi Whois**: Kryen kërkime Whois për emra domenesh, adresa IP dhe numra AS.
* 🗄️ **Kërkimi MAC**: Identifikon prodhuesin dhe detajet pas një adrese fizike.
* 🧮 **Kalkulatori IP**: Llogaritje subnetesh, konvertime shënimesh dhe detaje të ndërfaqes IPv6 për çdo IP, prefiks, varg ose listë, të llogaritura lokalisht.
* 🛰️ **Informacioni ASN dhe topologjia drejt furnizuesve**: Direkt në çdo kartë IP — detajet e AS-së, shpalljet historike për prefiksin e IP-së dhe shtigjet nga ASN-ja e saj drejt shtyllës kurrizore Tier 1.
* 🛂 **Profili ASN**: Tabloja e plotë e çdo AS — regjistrimi me të dhënat e papërpunuara Whois, renditja dhe madhësia globale, trafiku dhe cilësia e lidhjes, ku përdoret hapësira e saj IPv4, çdo prefiks i shpallur me gjendjen RPKI, furnizuesit, peer-at dhe klientët e saj, si dhe pikat e shkëmbimit të internetit dhe qendrat e të dhënave ku është e pranishme.
* 📶 **Gjendja e shërbimeve**: Disponueshmëria live e shërbimeve të njohura — Claude, OpenAI, GitHub, Cloudflare e të tjera — nga faqet e tyre zyrtare të gjendjes, me incidentet e fundit.
### ✨ Platforma
* 📤 **Raporte për t'u ndarë**: Ktheni rezultatet e testeve tuaja në një raport diagnostikues — një lidhje vetëm për lexim me skadim automatik, Markdown gati për AI ose JSON.
* ⌨️ **API Curl**: Merrni IP-në tuaj nga terminali me një komandë të vetme `curl`.
* 🌍 **Toka Online**: Një panel që transmeton ngjarjet globale të ndërprerjeve të internetit në kohën kur ndodhin.
* 🌗 **Modaliteti i errët**: Ndjek automatikisht sistemin tuaj, me mundësi ndërrimi manual.
* 📲 **PWA**: Mund të instalohet si aplikacion në telefon dhe si aplikacion Chrome në desktop.
* ⚡ **Shkurtoret e tastierës**: Çdo funksion ka një të tillë — shtypni `?` për të parë listën.
* 🔤 **Shumë gjuhë**: Ndërfaqja vjen në 6 gjuhë, dhe shtimi i gjuhës suaj kërkon vetëm një paketë lokalizimi.
## 📕 Si ta përdorni
### Me Docker
Një komandë dhe jeni gati:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Ose klikoni butonin "Deploy with Docker" në krye të kësaj faqeje.
### Vendosja në një mjedis Node
Sigurohuni që keni të instaluar Node.js, pastaj klononi kodin:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
Instaloni dhe ndërtoni. Ky projekt përdor pnpm — nëse nuk e keni, instalojeni së pari (npm vjen me Node, ndaj kjo komandë funksionon gjithmonë):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
Ekzekutoni:
```bash
pnpm start
```
Programi do të ekzekutohet në portën 18966.
## ⚙️ Konfigurimi
> [!IMPORTANT]
> **Kredencialet e MaxMind GeoLite2 janë të detyrueshme.** Ato mundësojnë gjeolokacionin e IP-ve dhe kërkimet ASN — pa to, burimi MaxMind kthen 503. Janë falas: → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **`ALLOWED_DOMAINS` është i detyrueshëm në një domen real.** Është lista e lejuar e emrave të hosteve për API-në e backend-it — pa të, çdo kërkesë nga një domen që nuk është localhost merr 403. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Gjithçka tjetër — çelësat opsionalë API, siguria dhe kufizimi i shpejtësisë, regjistrimi, Sentry, domenet e API-së curl — është dokumentuar te [Environment Variables reference](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Dokumentacioni
Udhëzuesit e plotë ndodhen në Qendrën e Dokumentacionit të MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Developer Guide](https://docs.ipcheck.ing/developer) — vendosja, konfigurimi, arkitektura dhe kontributi
* [Knowledge Base](https://docs.ipcheck.ing/knowledge-base) — si të përdorni çdo mjet, diagnostikimi i rrjetit hap pas hapi dhe konceptet e rrjetit
## 🤝 Kontributi
Kontributet janë të mirëpritura! Mbajmë një grup të kuruar detyrash të përshtatshme për fillestarët — secila me shtigje të sakta skedarësh, kritere pranimi dhe teste që ju udhëheqin drejt një ndërtimi të suksesshëm:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — shtoni një zgjidhës DNS nga shteti juaj, shtoni lista të kuruara faqesh, përktheni README-në në gjuhën tuaj, përmirësoni përkthimet e të tjera
* 🌐 [TRANSLATING.md](TRANSLATING.md) — sillni ndërfaqen në gjuhën tuaj: një paketë lokalizimi plus një rresht në regjistër, dhe **një përkthim i pjesshëm është një PR i parë i mirëpritur**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — konfigurimi, konventat dhe si rrjedhin PR-të (synoni degën `dev`)
## 👩🏻‍💻 Përdorimi i avancuar
<details>
<summary>Rregulla proxy për të kontrolluar njëkohësisht IP-në tuaj të vërtetë dhe IP-në e proxy-t</summary>
Nëse përdorni një proxy për t'u lidhur me internetin, mendoni të shtoni këtë rregull në konfigurimin e proxy-t tuaj (përshtateni sipas klientit tuaj). Ky konfigurim ju lejon të kontrolloni si IP-në tuaj të vërtetë, ashtu edhe IP-në kur përdorni proxy-n:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 Sponsorët dhe mbështetësit
Si projekt me burim të hapur, jam shumë mirënjohës për mbështetjen e sponsorëve të mëposhtëm:
**Sponsorët**
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — offers private residential, mobile (4G/5G/LTE), ISP, and datacenter IPv4/IPv6 proxies with HTTP(S) and SOCKS5 support, 47M+ residential IPs in 220+ locations, precise country/city/ISP targeting, 99.7% uptime, and 24/7 support. MyIP users can use promo code `CHECKINGIP15` to get 15% off.
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=sq"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=sq">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
----
**Mbështetësit**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Jeni të interesuar të sponsorizoni MyIP? [Bëhu sponsor](https://github.com/sponsors/jason5ng32)
## 📄 Licenca
[AGPL-3.0](LICENSE) © Jason Ng
+213
View File
@@ -0,0 +1,213 @@
# 🧰 MyIP - กล่องเครื่องมือ IP ที่ดีกว่าเดิม
> [!NOTE]
> นี่คือคำแปลที่ดูแลโดยชุมชน README ภาษาอังกฤษคือต้นฉบับหลัก ฉบับนี้อาจอัปเดตไม่ทันต้นฉบับ
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
กล่องเครื่องมือ IP แบบโอเพนซอร์สที่รวมทุกอย่างไว้ในที่เดียว: ตรวจ IP จากหลายแหล่ง, ทดสอบการเชื่อมต่อ, ตรวจจับ WebRTC และ DNS รั่ว, ทดสอบความเร็ว, MTR, ตรวจการเซ็นเซอร์, Whois และอีกมากมาย ติดตั้งบนเซิร์ฟเวอร์ของคุณเองได้ด้วยคำสั่ง Docker เพียงคำสั่งเดียว
👉 เดโม: [https://ipcheck.ing](https://ipcheck.ing)
จะบุ๊กมาร์กเว็บเดโมไว้ใช้ หรือติดตั้งเองก็ได้ตามสะดวก
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 ฟีเจอร์
### 🪪 IP และตัวตนของคุณ
* 🛜 **IP Cards**: ตรวจ IPv4 และ IPv6 ของคุณจากหลายแหล่งที่เป็นอิสระต่อกันแล้วแสดงเทียบกัน พร้อมประเทศ ภูมิภาค เมือง ASN องค์กร และเขตเวลาท้องถิ่นของ IP นั้น
* 🔍 **ค้นหา IP**: ดูข้อมูลละเอียดแบบเดียวกันของ IP ใดก็ได้ที่คุณอยากรู้
* 🧾 **ประวัติ IP**: บันทึก IP ที่คุณเคยใช้ไว้ในเครื่อง กรองตามประเภทและประเทศได้ ข้อมูลเก็บไว้ในเบราว์เซอร์ของคุณเท่านั้น
* 🖥️ **Browser Fingerprint**: คำนวณลายนิ้วมือเบราว์เซอร์ของคุณด้วยหลายวิธี และแสดงว่าอะไรบ้างที่ทำให้คุณถูกระบุตัวตนได้
### 🕵️ การรั่วไหลและความเป็นส่วนตัว
* 🚥 **ตรวจ WebRTC**: เผย IP ที่หลุดออกไประหว่างการเชื่อมต่อ WebRTC รวมถึงบอกว่าเบราว์เซอร์ของคุณเปิดการป้องกันความเป็นส่วนตัวไว้หรือไม่
* 🛑 **ทดสอบ DNS รั่ว**: แสดงว่าเซิร์ฟเวอร์ DNS ตัวไหนบ้างที่ตอบคำขอของคุณ เพื่อประเมินความเสี่ยง DNS รั่วเวลาใช้ VPN หรือพร็อกซี
* 🛡️ **ทดสอบการบล็อกโฆษณาและตัวติดตาม**: ตรวจว่าตัวบล็อกโฆษณา เบราว์เซอร์ ตัวกรอง DNS VPN หรือเราเตอร์ของคุณบล็อกอะไรได้จริง ทั้งเครือข่ายโฆษณา ระบบวิเคราะห์ ตัวติดตามโซเชียล ระบบมอนิเตอร์ข้อผิดพลาด การส่งข้อมูลกลับของผู้ผลิตอุปกรณ์ สคริปต์ติดตาม และการซ่อนองค์ประกอบบนหน้าเว็บ
* 📋 **เช็กลิสต์ความปลอดภัย**: เช็กลิสต์ความปลอดภัยไซเบอร์ส่วนบุคคล 258 ข้อใน 12 หมวด บันทึกความคืบหน้าไว้ในเบราว์เซอร์
### 📡 ทดสอบเครือข่าย
* 🚦 **ตรวจการเชื่อมต่อ**: ทดสอบว่าเข้าถึงเว็บที่คุณเลือกได้หรือไม่ สูงสุด 60 เว็บ วัดหลายรอบแล้วเอาค่าหน่วงต่ำสุด มีรายการเว็บสำเร็จรูปให้นำเข้า ตั้งแต่ชุดประจำประเทศไปจนถึง AI โซเชียล สตรีมมิง เกม นักพัฒนา และอื่น ๆ แล้วสรุปจากผลว่าตอนนี้คุณเข้าถึงอินเทอร์เน็ตทั่วโลกได้หรือไม่
* 🚀 **ทดสอบความเร็ว**: วัดความเร็วดาวน์โหลด อัปโหลด และค่าหน่วงกับเครือข่าย edge
* ⏱️ **ทดสอบค่าหน่วงทั่วโลก**: ping เป้าหมายของคุณจากจุดวัดทั่วโลก เลือกประเทศได้จากจุดวัดของ Globalping ทั้งหมด จัดกลุ่มตามทวีป
* 🚉 **ทดสอบ MTR**: รัน MTR จากจุดวัดที่กระจายอยู่ทั่วโลก เพื่อดูเส้นทางที่แพ็กเก็ตวิ่งจริง
* 🚧 **ตรวจการเซ็นเซอร์**: แสดงว่าเว็บไซต์ถูกบล็อกที่ไหนบ้างทั่วโลก และถูกบล็อกด้วยวิธีใด
* 🚏 **ทดสอบกฎพร็อกซี**: ตรวจว่ากฎในโปรแกรมพร็อกซีของคุณทำงานตามที่ตั้งใจไว้จริง
### 🔦 ค้นหาข้อมูลและโครงสร้างพื้นฐาน
* 📟 **DNS Resolver**: แปลงโดเมนผ่าน resolver หลายตัวพร้อมกัน จัดกลุ่มตามประเทศ ช่วยให้เห็นได้ง่ายว่า DNS ถูกดักแก้ (hijack) หรือถูกปนเปื้อนหรือไม่
* 📓 **ค้นหา Whois**: ค้น Whois ของชื่อโดเมน IP และหมายเลข AS
* 🗄️ **ค้นหา MAC**: ระบุผู้ผลิตและรายละเอียดของ MAC address
* 🧮 **IP Calculator**: คำนวณ subnet แปลงรูปแบบการเขียน และดูรายละเอียด interface ของ IPv6 สำหรับ IP, prefix, ช่วง หรือรายการ IP ใดก็ได้ คำนวณในเครื่องทั้งหมด
* 🛰️ **ข้อมูล ASN และโทโพโลยีต้นทาง**: อยู่บน IP Card ทุกใบ มีรายละเอียด AS ประวัติการประกาศ prefix ของ IP นั้น และเส้นทางต้นทางจาก ASN ไปจนถึงแกนหลัก Tier 1
* 🛂 **โปรไฟล์ ASN**: ภาพรวมครบถ้วนของ AS ใดก็ได้ ทั้งข้อมูลการจดทะเบียนพร้อม Whois ดิบ อันดับและขนาดระดับโลก ปริมาณทราฟฟิกและคุณภาพการเชื่อมต่อ พื้นที่ใช้งาน IPv4 ทุก prefix ที่ประกาศพร้อมสถานะ RPKI ผู้ให้บริการต้นทาง peer และลูกค้า รวมถึงจุดแลกเปลี่ยนอินเทอร์เน็ตและดาต้าเซ็นเตอร์ที่ AS นั้นตั้งอยู่
* 📶 **สถานะบริการ**: สถานะการใช้งานแบบเรียลไทม์ของบริการดัง ๆ เช่น Claude, OpenAI, GitHub, Cloudflare และอื่น ๆ ดึงจากหน้าสถานะทางการ พร้อมเหตุขัดข้องล่าสุด
### ✨ แพลตฟอร์ม
* 📤 **รายงานที่แชร์ได้**: เปลี่ยนผลทดสอบเป็นรายงานวินิจฉัย ในรูปแบบลิงก์อ่านอย่างเดียวที่หมดอายุเอง, Markdown ที่พร้อมส่งให้ AI หรือ JSON
* ⌨️ **Curl API**: ดู IP ของคุณจาก terminal ด้วยคำสั่ง `curl` คำสั่งเดียว
* 🌍 **Earth Online**: แผงที่รายงานเหตุอินเทอร์เน็ตล่มทั่วโลกแบบทันที
* 🌗 **โหมดมืด**: เปลี่ยนตามระบบอัตโนมัติ และสลับเองได้
* 📲 **PWA**: ติดตั้งเป็นแอปบนมือถือ และเป็นแอป Chrome บนเดสก์ท็อปได้
* ⚡ **คีย์ลัด**: ทุกฟังก์ชันมีคีย์ลัด กด `?` เพื่อดูรายการทั้งหมด
* 🔤 **หลายภาษา**: หน้าเว็บมีให้ 6 ภาษา และเพิ่มภาษาของคุณได้ด้วยไฟล์ภาษาเพียงชุดเดียว
## 📕 วิธีใช้งาน
### ใช้ Docker
คำสั่งเดียวก็พร้อมใช้งาน:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
หรือกดปุ่ม "Deploy with Docker" ที่ด้านบนของหน้านี้
### ติดตั้งในสภาพแวดล้อม Node
ตรวจให้แน่ใจว่าติดตั้ง Node.js แล้ว จากนั้น clone โค้ด:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
ติดตั้งและ build โปรเจกต์นี้ใช้ pnpm ถ้ายังไม่มี ให้ติดตั้งก่อน (npm มากับ Node อยู่แล้ว คำสั่งนี้จึงใช้ได้เสมอ):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
รัน:
```bash
pnpm start
```
โปรแกรมจะทำงานที่พอร์ต 18966
## ⚙️ การตั้งค่า
> [!IMPORTANT]
> **ต้องมีข้อมูลบัญชี MaxMind GeoLite2** ใช้สำหรับระบุตำแหน่ง IP และค้นหา ASN ถ้าไม่มี แหล่งข้อมูล MaxMind จะตอบกลับเป็น 503 สมัครได้ฟรี → [ตั้งค่า MaxMind](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **ต้องตั้ง `ALLOWED_DOMAINS` เมื่อใช้กับโดเมนจริง** เป็นรายการชื่อโฮสต์ที่อนุญาตให้เรียก API หลังบ้าน ถ้าไม่ตั้ง ทุกคำขอจากโดเมนที่ไม่ใช่ localhost จะได้ 403 → [Reverse Proxy และโดเมน](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
การตั้งค่าอื่นทั้งหมด เช่น API key เสริม ความปลอดภัยและการจำกัดอัตราคำขอ การเก็บ log, Sentry และโดเมนสำหรับ curl API มีอธิบายไว้ใน[เอกสารอ้างอิงตัวแปรสภาพแวดล้อม](https://docs.ipcheck.ing/developer/reference/environment-variables)
## 📖 เอกสาร
คู่มือฉบับเต็มอยู่ที่ MyIP Docs Center: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [คู่มือนักพัฒนา](https://docs.ipcheck.ing/developer) — การติดตั้ง การตั้งค่า สถาปัตยกรรม และการร่วมพัฒนา
* [ฐานความรู้](https://docs.ipcheck.ing/knowledge-base) — วิธีใช้เครื่องมือทุกตัว การวินิจฉัยเครือข่ายทีละขั้น และแนวคิดด้านเครือข่าย
## 🤝 ร่วมพัฒนา
ยินดีต้อนรับทุกการมีส่วนร่วม! เราคัดงานที่เหมาะกับมือใหม่ไว้ให้ แต่ละงานระบุไฟล์ที่ต้องแก้ เกณฑ์การรับงาน และมีเทสต์คอยนำทางจนกว่าจะ build ผ่าน:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — เพิ่ม DNS resolver จากประเทศของคุณ เพิ่มรายการเว็บ แปล README เป็นภาษาของคุณ ขัดเกลาคำแปล และอื่น ๆ
* 🌐 [TRANSLATING.md](TRANSLATING.md) — แปลหน้าเว็บเป็นภาษาของคุณ ใช้ไฟล์ภาษาหนึ่งชุดกับการลงทะเบียนหนึ่งบรรทัด และ**แปลแค่บางส่วนก็ส่งเป็น PR แรกได้**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — การเตรียมเครื่อง ข้อตกลงต่าง ๆ และขั้นตอนของ PR (ส่งเข้า branch `dev`)
## 👩🏻‍💻 การใช้งานขั้นสูง
<details>
<summary>กฎพร็อกซีสำหรับดู IP จริงกับ IP ของพร็อกซีพร้อมกัน</summary>
ถ้าคุณใช้พร็อกซีในการเข้าอินเทอร์เน็ต ลองเพิ่มกฎนี้ในการตั้งค่าพร็อกซี (ปรับตามโปรแกรมที่คุณใช้) จะช่วยให้ดูได้ทั้ง IP จริงและ IP ตอนใช้พร็อกซี:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 ผู้สนับสนุน
ในฐานะโปรเจกต์โอเพนซอร์ส ขอขอบคุณผู้สนับสนุนต่อไปนี้เป็นอย่างยิ่ง:
**Sponsors**
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — พร็อกซี residential คุณภาพสูงทั้งแบบหมุนเวียนและแบบคงที่ มี IP residential มากกว่า 90 ล้าน IP ในกว่า 220 พื้นที่ทั่วโลก รองรับ HTTP(S), SOCKS5, session แบบหมุนเวียนและแบบคงที่ แพ็กเกจ residential เริ่มต้นเพียง $0.7/GB ใช้โค้ด `PROXY90` รับส่วนลดเพิ่มอีก 10%
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — ให้บริการพร็อกซีส่วนตัวแบบ residential, mobile (4G/5G/LTE), ISP และดาต้าเซ็นเตอร์ทั้ง IPv4/IPv6 รองรับ HTTP(S) และ SOCKS5 มี IP residential มากกว่า 47 ล้าน IP ในกว่า 220 พื้นที่ เลือกประเทศ/เมือง/ISP ได้แม่นยำ uptime 99.7% และซัพพอร์ตตลอด 24 ชั่วโมง ผู้ใช้ MyIP ใช้โค้ด `CHECKINGIP15` รับส่วนลด 15%
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=th"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=th">V.PS</a>** — VPS แบบ KVM ประสิทธิภาพสูง มีหลายพื้นที่ทั่วโลก เชื่อมต่อเร็ว ให้ทั้ง IPv4 และ IPv6</sub>
----
**Supporters**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
สนใจสนับสนุน MyIP? [มาเป็นผู้สนับสนุน](https://github.com/sponsors/jason5ng32)
## 📄 สัญญาอนุญาต
[AGPL-3.0](LICENSE) © Jason Ng
+213
View File
@@ -0,0 +1,213 @@
# 🧰 MyIP - 更好的 IP 工具箱
> [!NOTE]
> 這是由社群維護的翻譯版本;英文版 README 為唯一權威來源,本頁內容可能落後於英文版。
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
一個開源的多合一 IP 工具箱:多來源 IP 查詢、連通性測試、WebRTC 與 DNS 洩漏偵測、網速測試、MTR、審查封鎖檢查、Whois 等——一條 Docker 指令即可自行架設。
👉 在這裡體驗:[https://ipcheck.ing](https://ipcheck.ing)
歡迎把展示站加入書籤,也可以自行部署一套。
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 主要功能
### 🪪 你的 IP 與身分
* 🛜 **IP 卡片**:從多個獨立來源並列偵測你的 IPv4 與 IPv6——包括國家、地區、城市、ASN、組織,以及該 IP 所在地的當地時區。
* 🔍 **IP 查詢**:查詢任何一個你感興趣的 IP 位址的同樣詳細資訊。
* 🧾 **IP 歷史記錄**:在本機記錄你曾被偵測到的 IP,可依類型與國家篩選——只儲存在你的瀏覽器裡。
* 🖥️ **瀏覽器指紋**:以多種方式計算你的瀏覽器指紋,並顯示是哪些特徵讓你可被識別。
### 🕵️ 洩漏與隱私
* 🚥 **WebRTC 洩漏測試**:揭示 WebRTC 連線過程中暴露的 IP 位址——包括你的瀏覽器隱私強化設定是否已開啟。
* 🛑 **DNS 洩漏測試**:顯示是哪些 DNS 端點在解析你的查詢,以評估使用 VPN 或代理時 DNS 洩漏的風險。
* 🛡️ **廣告與追蹤封鎖檢測**:檢查你的廣告阻擋器、瀏覽器、DNS 過濾、VPN 或路由器實際擋下了什麼——涵蓋廣告網路、統計分析、社群平台追蹤、錯誤監控、裝置廠商遙測、追蹤腳本與元素隱藏。
* 📋 **安全檢查清單**:一份涵蓋 12 個領域、共 258 個項目的個人網路安全檢查清單,進度儲存在你的瀏覽器裡。
### 📡 網路測試
* 🚦 **網路連通性**:測試最多 60 個你自選網站的可達性,多輪測試取最低延遲——並提供精選匯入清單,從各國組合包到 AI、社群、串流、遊戲、開發者等應有盡有。根據測試結果,提示你目前是否能順暢存取全球網路。
* 🚀 **網速測試**:對邊緣網路測量你的下載、上傳與延遲。
* ⏱️ **全球延遲測試**:從遍布全球的探測節點 ping 你的目標——可從所有可用的 Globalping 探測節點中依洲別挑選國家。
* 🚉 **MTR 測試**:從分布全球的探測節點執行 MTR,查看封包實際行經的路徑。
* 🚧 **封鎖測試**:顯示一個網站在全球哪些地方被封鎖——以及封鎖的手段。
* 🚏 **分流測試**:驗證你的代理軟體規則設定是否如你預期般運作。
### 🔦 查詢與基礎設施
* 📟 **DNS 解析**:同時透過多個解析器解析網域,並依國家分組——輕鬆看出是否存在劫持或污染。
* 📓 **Whois 查詢**:對網域名稱、IP 位址與 AS 編號進行 Whois 查詢。
* 🗄️ **MAC 位址查詢**:識別實體位址背後的廠商與詳細資訊。
* 🧮 **IP 計算器**:對任意 IP、前綴、範圍或清單做子網路計算、進位轉換與 IPv6 介面解讀,全部本機完成。
* 🛰️ **ASN 資訊與上游拓撲**:直接在每張 IP 卡片中顯示 AS 詳細資訊、該 IP 前綴的歷史宣告記錄,以及其 ASN 到 Tier 1 骨幹網路的上游路徑。
* 🛂 **ASN 檔案**:任意 AS 的完整檔案——註冊資訊與原始 Whois 記錄、全球排名與規模、流量組成與連線品質、IPv4 位址分布在哪些國家、所有宣告前綴及其 RPKI 狀態、上游/對等/客戶網路,以及它進駐的網際網路交換中心與資料中心。
* 📶 **服務可用性**:知名服務(Claude、OpenAI、GitHub、Cloudflare 等)的即時可用狀態,資料來自它們的官方狀態頁,並附最近的事故。
### ✨ 平台功能
* 📤 **診斷報告**:把你的測試結果轉成可分享的診斷報告——附自動過期的唯讀連結、AI 可直接使用的 Markdown,或 JSON。
* ⌨️ **命令列 API**:在終端機裡用一條 `curl` 指令取得你的 IP。
* 🌍 **地球線上**:一個即時播報全球網路斷網事件的面板。
* 🌗 **深色模式**:自動跟隨系統設定,也可手動切換。
* 📲 **PWA**:可安裝為手機上的應用程式,以及電腦上的 Chrome 應用程式。
* ⚡ **鍵盤快速鍵**:每個功能都有對應快速鍵——按下 `?` 查看完整清單。
* 🔤 **多語言**:UI 內建 6 種語言,新增你的語言只需一個語言包。
## 📕 如何使用
### 使用 Docker
一條指令即可啟動:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
也可以點選本頁頂部的「Deploy with Docker」按鈕。
### 在 Node 環境部署
確保你已安裝 Node.js,然後複製程式碼:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
安裝與建置。本專案使用 pnpm——如果你還沒有,請先安裝(npm 隨 Node 一起提供,這條指令一定能用):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
執行:
```bash
pnpm start
```
程式會執行在 18966 連接埠。
## ⚙️ 設定
> [!IMPORTANT]
> **必須設定 MaxMind GeoLite2 憑證。** 它為 IP 地理位置與 ASN 查詢提供資料——未設定時,MaxMind 來源會回傳 503。憑證是免費的:→ [MaxMind 設定指南](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **使用真實網域時必須設定 `ALLOWED_DOMAINS`。** 它是後端 API 的主機名稱允許清單——未設定時,來自非 localhost 網域的每個請求都會收到 403。→ [反向代理與網域](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
其餘一切——選用的 API Key、安全與流量限制、日誌、Sentry、curl API 網域——都記錄在[環境變數參考](https://docs.ipcheck.ing/developer/reference/environment-variables)中。
## 📖 官方說明文件
完整指南在 MyIP 文件中心:**[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [開發者指南](https://docs.ipcheck.ing/developer) —— 部署、設定、架構說明與參與貢獻
* [知識庫](https://docs.ipcheck.ing/knowledge-base) —— 每個工具的使用說明、逐步的網路問題診斷,以及網路概念入門
## 🤝 參與貢獻
歡迎參與貢獻!我們維護著一批對新手友善的任務,每條都寫明了具體檔案路徑與驗收標準,並有測試引導你完成:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) —— 新增你所在國家的 DNS 解析服務、新增精選網站清單、把 README 翻譯成你的語言、潤飾翻譯等
* 🌐 [TRANSLATING.md](TRANSLATING.md) —— 把 UI 帶到你的語言:一個語言包加上一行註冊表,而且 **部分翻譯也是歡迎的首次 PR**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) —— 環境架設、開發約定與 PR 流程(請將 PR 提交到 `dev` 分支)
## 👩🏻‍💻 進階用法
<details>
<summary>同時查詢真實 IP 與代理 IP 的代理規則</summary>
如果你在透過代理上網,可以考慮在你的代理設定裡加入下面的規則(請根據你使用的用戶端進行修改)。這樣設定後,就可以同時查詢真實 IP 和使用代理時的 IP:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 贊助商與支持者
作為一個開源專案,我非常感謝以下贊助者對我的支持:
**贊助商**
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — 提供私有住宅、行動(4G/5G/LTE)、靜態 ISP 以及資料中心 IPv4/IPv6 代理,支援 HTTP(S) 與 SOCKS5 協定,擁有 4700 萬+ 住宅 IP,涵蓋全球 220+ 地區,可精準定位國家、城市及電信業者,具備 99.7% 可用性與 24/7 技術支援。MyIP 使用者使用優惠碼 `CHECKINGIP15` 即可享 15% 折扣。
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=zh-TW"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=zh-TW">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
----
**支持者**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
有意贊助 MyIP?[成為贊助者](https://github.com/sponsors/jason5ng32)
## 📄 授權條款
[AGPL-3.0](LICENSE) © Jason Ng
+116 -60
View File
@@ -1,4 +1,7 @@
# 🧰 IP 工具箱
# 🧰 MyIP - 更好的 IP 工具箱
> [!NOTE]
> 这是由社区维护的翻译版本;英文版 README 为唯一权威来源,本页面内容可能滞后于英文版。
<div align="center">
@@ -17,12 +20,15 @@
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
🇺🇸 [English](README.md) | 🇨🇳 [简体中文](README_ZH.md) | 🇷🇺 [Русский](README_RU.md) | 🇫🇷 [Français](README_FR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md) | [ไทย](README_TH.md) | [हिन्दी](README_HI.md)
一个开源的一站式 IP 工具箱:多来源 IP 查询、连通性测试、WebRTC 与 DNS 泄露检测、网速测试、MTR、封锁检测、Whois 等等 —— 一条 Docker 命令即可自行部署。
👉 在这里体验:[https://ipcheck.ing](https://ipcheck.ing)
你可以直接用我已经搭建好的服务,也可以自行搭建。
欢迎收藏在线版本,也可以自行部署。
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
@@ -30,53 +36,77 @@
## 👀 主要功能
* 🛜 **看自己的 IP**:从多个 IPv4 和 IPv6 来源检测显示本机的 IP
* 🔍 **查任意 IP 信息**:可以通过小工具查询任意 IP 的信息
* 🕵️ **看 IP 信息**:显示所有 IP 的相关信息,包括国家、地区、ASN、地理位置等
* 🛰️ **ASN 历史与上游拓扑**:查看 IP 前缀的历史 AS 宣告记录,以及该 ASN 到 Tier 1 骨干网的上游路径图
* 🚦 **可用性检测**:检测一些网站的可用性:Google, Github, Youtube, 网易, 百度等
* 📡 **服务可用性**:展示一些知名服务(Claude、OpenAI、GitHub、Cloudflare 等)的实时可用状态,数据来自它们的官方状态页,可查看各子服务状态与最近的事故
* 🚥 **WebRTC 检测**:查看使用 WebRTC 连接时使用的 IP
* 🛑 **DNS 泄露检测**:查看 DNS 出口信息,以便查看在 VPN/代理的情况下,是否存在 DNS 泄露隐私的风险
* 🚀 **网速测试**:利用边缘网络进行网速测试
* 🚏 **代理规则测试**:配合代理软件的规则设置,测试规则设置是否正常
* ⏱️ **全球延迟测试**:从分布在全球的多个服务器进行延迟测试,了解你与全球网络的连接速度
* 🚉 **MTR 测试**:从分布在全球的多个服务器进行 MTR 测试,了解你与全球的连接路径
* 🔦 **DNS 解析器**:从多个渠道对域名进行 DNS 解析,获取实时的解析结果,可用于污染判断
* 🚧 **封锁测试**:检查特定的网站在部分国家是否被封锁
* 📓 **Whois 查询**:对域名或 IP 进行 whois 信息查询
* 📀 **MAC 地址查询**:查询物理地址的归属信息
* 🖥️ **浏览器指纹**:多种方式查看浏览器指纹
* 📋 **网络安全检查清单**:一共有 258 项的,全面的网络安全检查清单
### 🪪 你的 IP 与身份
## 💪 同时还支持
* 🛜 **IP 卡片**:从多个相互独立的来源并列检测你的 IPv4 和 IPv6 —— 国家、地区、城市、ASN、组织,以及该 IP 所在地的时区。
* 🔍 **IP 查询**:查询任意一个你感兴趣的 IP 地址的同样详细的信息。
* 🧾 **IP 历史记录**:在本地记录你使用过的 IP,可按类型和国家筛选 —— 数据只存在你的浏览器里。
* 🖥️ **浏览器指纹**:用多种方式计算你的浏览器指纹,并展示是什么让你变得可被识别。
* 🌗 **暗黑模式**:根据系统设置自动切换暗黑/白天模式,也可以手动切换
* 📲 **支持 PWA**:可以添加为手机应用以及电脑里的桌面应用,方便使用
* ⌨️ **支持快捷键**:可以随时输入 `?` 查看快捷键菜单
* 🌍 根据可用性检测结果,返回目前是否可以访问全世界网络的提示
* 🇺🇸 🇨🇳 🇷🇺 🇫🇷 支持中文、英文、俄文、法文
### 🕵️ 泄露与隐私
* 🚥 **WebRTC 检测**:揭示 WebRTC 连接过程中暴露的 IP 地址 —— 包括你的浏览器是否开启了隐私强化。
* 🛑 **DNS 泄露测试**:展示是哪些 DNS 出口在解析你的请求,以便评估使用 VPN 或代理时 DNS 泄露的风险。
* 🛡️ **广告与跟踪屏蔽检测**:检查你的广告拦截器、浏览器、DNS 过滤、VPN 或路由器实际拦住了什么——涵盖广告网络、统计分析、社交平台跟踪、错误监控、设备厂商遥测、跟踪脚本与元素隐藏。
* 📋 **安全检查清单**:一份覆盖 12 个领域、共 258 项的个人网络安全检查清单,进度保存在你的浏览器里。
### 📡 网络测试
* 🚦 **网络连通性**:检测最多 60 个你自选网站的可达性,多轮测试取最低延迟 —— 还提供精选导入列表,从国家套装到 AI、社交、流媒体、游戏、开发者等应有尽有。并根据检测结果,提示你当前能否访问全球互联网。
* 🚀 **网速测试**:基于边缘网络测量你的下载、上传速度与延迟。
* ⏱️ **全球延迟测试**:从遍布全球的探针 ping 你的目标 —— 可从所有可用的 Globalping 探针中按大洲分组选择国家。
* 🚉 **MTR 测试**:从分布在全球的探针发起 MTR,查看数据包实际经过的路由。
* 🚧 **封锁测试**:展示一个网站在全球哪些地方被封锁 —— 以及是通过什么手段封锁的。
* 🚏 **分流测试**:验证你的代理软件的规则配置是否按你的预期工作。
### 🔦 查询与基础设施
* 📟 **DNS 解析**:同时通过多个解析器解析一个域名,并按国家分组 —— 轻松发现劫持或污染。
* 📓 **Whois 查询**:对域名、IP 地址和 AS 号进行 Whois 查询。
* 🗄️ **MAC 地址查询**:识别一个物理地址背后的厂商与详细信息。
* 🧮 **IP 计算器**:对任意 IP、前缀、范围或列表做子网计算、进制转换与 IPv6 接口解读,全部本地完成。
* 🛰️ **ASN 信息与上游拓扑**:在每张 IP 卡片里直接展示 AS 详情、该 IP 前缀的历史宣告记录,以及其 ASN 到 Tier 1 骨干网的上游路径。
* 🛂 **ASN 档案**:任意 AS 的完整档案 —— 注册信息与原始 Whois 记录、全球排名与规模、流量构成与连接质量、IPv4 地址分布在哪些国家、全部宣告前缀及其 RPKI 状态、上游 / 对等 / 客户网络,以及它接入的互联网交换中心和数据中心。
* 📶 **服务可用性**:知名服务的实时可用状态 —— Claude、OpenAI、GitHub、Cloudflare 等 —— 数据来自它们的官方状态页,并附最近的事故。
### ✨ 平台能力
* 📤 **诊断报告分享**:把你的测试结果生成一份诊断报告 —— 可选自动过期的只读链接、适合喂给 AI 的 Markdown,或 JSON。
* ⌨️ **命令行 API**:在终端里用一条 `curl` 命令获取你的 IP。
* 🌍 **地球在线**:一个实时播报全球断网事件的面板。
* 🌗 **暗黑模式**:自动跟随系统,也可以手动切换。
* 📲 **PWA**:可以安装为手机应用,也可以安装为桌面上的 Chrome 应用。
* ⚡ **快捷键**:每个功能都有对应的快捷键 —— 按 `?` 查看列表。
* 🔤 **多语言**:界面内置 6 种语言,添加你的语言只需一个语言包。
## 📕 如何使用
### 使用 Docker
一条命令即可运行:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
或者点击本页顶部的「Deploy with Docker」按钮。
### 在 Node 环境部署
确保你系统里已经有 Node.js 环境。
克隆代码:
确保你已经安装了 Node.js,然后克隆代码:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
安装与编译。本项目使用 pnpm,如果你还没有,请先安装(npm 随 Node 一起提供,这条命令一定能用):
安装与构建。本项目使用 pnpm —— 如果你还没有,请先安装(npm 随 Node 一起提供,这条命令一定能用):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
运行:
运行:
```bash
pnpm start
@@ -84,44 +114,47 @@ pnpm start
程序会运行在 18966 端口。
### 使用 Docker
点击顶部的部署到 Docker 按钮,即可完成部署,又或者,直接输入下面的命令:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
## 📖 官方文档
完整文档在 MyIP 文档中心:**[docs.ipcheck.ing](https://docs.ipcheck.ing)**(右上角可切换中文)
* [开发者指南](https://docs.ipcheck.ing/developer/zh) —— 部署、配置、架构说明与参与贡献
* [知识库](https://docs.ipcheck.ing/knowledge-base/zh) —— 每个工具的使用说明、网络问题排查指南、网络概念科普
## ⚙️ 配置
开始之前,有两项配置最重要:
> [!IMPORTANT]
> **MaxMind GeoLite2 凭证是必需的。** 它为 IP 地理位置与 ASN 查询提供数据 —— 不配置时,MaxMind 数据源会返回 503。凭证是免费的:→ [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
* **MaxMind GeoLite2(必须)** —— 免费凭证,为 IP 地理位置与 ASN 查询提供数据。不配置时 MaxMind 数据源会返回 503。→ [MaxMind 配置指南](https://docs.ipcheck.ing/developer/getting-started/zh/maxmind-setup)
* **`ALLOWED_DOMAINS`(使用真实域名时必须)** —— 后端 API 的域名白名单。不配置时,来自非 localhost 域名的请求都会收到 403。→ [反向代理与域名](https://docs.ipcheck.ing/developer/getting-started/zh/reverse-proxy-and-domains)
> [!WARNING]
> **在真实域名上部署时,`ALLOWED_DOMAINS` 是必需的。** 它是后端 API 的主机名白名单 —— 不配置时,来自非 localhost 域名的每个请求都会收到 403。→ [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e MAXMIND_AUTO_UPDATE="true" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
其余全部为可选配置 —— 第三方 API Key、安全与限流、日志、Sentry、CURL API 域名等,详见[环境变量参考](https://docs.ipcheck.ing/developer/reference/zh/environment-variables)。
其余全部是可选配置 —— 第三方 API Key、安全与限流、日志、Sentry、curl API 域名等,详见[环境变量参考](https://docs.ipcheck.ing/developer/reference/environment-variables)。
## 📖 官方文档
完整指南在 MyIP 文档中心:**[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [开发者指南](https://docs.ipcheck.ing/developer) —— 部署、配置、架构说明与参与贡献
* [知识库](https://docs.ipcheck.ing/knowledge-base) —— 每个工具的使用说明、网络问题排查步骤、网络概念科普
## 🤝 参与贡献
欢迎参与贡献!我们维护着一批对新手友好的任务,每条都写明了具体文件路径、验收标准,并有测试引导你完成一次绿色构建:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) —— 添加你所在国家的 DNS 解析服务、添加精选网站列表、把 README 翻译成你的语言、润色翻译等
* 🌐 [TRANSLATING.md](TRANSLATING.md) —— 把 UI 带到你的语言:一个语言包加上一行注册表,而且**部分翻译也是欢迎的首次 PR**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) —— 环境搭建、开发约定与 PR 流程(请将 PR 提交到 `dev` 分支)
## 👩🏻‍💻 高级用法
如果你在通过代理上网,可以考虑在你的代理配置里,增加下面的规则(请根据你使用的客户端进行修改),这样就可以实现同时查询真实 IP 和代理后的 IP:
<details>
<summary>同时查询真实 IP 与代理 IP 的代理规则</summary>
如果你在通过代理上网,可以考虑在你的代理配置里增加下面的规则(请根据你使用的客户端进行修改)。这样就可以同时查询你的真实 IP 和使用代理时的 IP:
```ini
# IP Testing
@@ -138,20 +171,43 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
## 💖 赞助者
</details>
## 💖 赞助商与支持者
作为一个开源项目,我非常感谢以下赞助者对我的支持:
<a href="https://www.digitalocean.com/?refcode=fd2634a3981b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge"><img src="https://res.ipcheck.ing/img/digitalocean_logo.png" width="240px" title="DigitalOcean" /></a>
**赞助商**
<a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" title="1Password" width="240px" /></a>
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — 提供高质量动态和静态住宅代理,拥有 9000 万+住宅 IP,覆盖全球 220+ 个地区,支持 HTTP(S)、SOCKS5 协议及动态轮换和粘性会话。动态住宅代理低至 $0.7/GB,使用优惠码 `PROXY90` 再享九折优惠。
</sub>
<a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" title="Greptile" width="240px" /></a>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — 提供私有住宅、移动(4G/5G/LTE)、静态 ISP 以及数据中心 IPv4/IPv6 代理,支持 HTTP(S) 与 SOCKS5 协议,拥有 4700 万+ 住宅 IP,覆盖全球 220+ 地区,可精准定位国家、城市及运营商,具备 99.7% 可用性与 24/7 技术支持。MyIP 用户使用优惠码 `CHECKINGIP15` 可享 15% 折扣。
</sub>
<a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" title="Sentry" width="240px" /></a>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=zh"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=zh">V.PS</a>** — 高性能 KVM VPS,覆盖全球多个地区。日本与新加坡线路更有三网优化,提供极高速网络连接。支持 IPv4 与 IPv6。</sub>
<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>
----
<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
**支持者**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
有意赞助 MyIP?[成为赞助者](https://github.com/sponsors/jason5ng32)
## 📄 开源协议
[AGPL-3.0](LICENSE) © Jason Ng
+35 -15
View File
@@ -1,26 +1,46 @@
## Reporting a Vulnerability
# Security Policy
The security of our project is a top priority. If you discover a security vulnerability, we encourage you to report it directly through our GitHub Issues.
## Supported versions
### How to Report a Vulnerability
Only the latest release gets security fixes. Older tags are not patched — if you
self-host, upgrade before reporting.
1. **Report Through GitHub Issues**: Please create a new issue in our [GitHub Issues page](https://github.com/jason5ng32/MyIP/issues). Provide a clear and detailed description of the potential vulnerability. Include steps to reproduce it, if possible.
## Reporting a vulnerability
2. **Avoid Public Disclosure**: To protect the community, please do not disclose the vulnerability publicly until we have had a chance to address it.
**Report privately, not through a public issue.** Use GitHub's private reporting form:
3. **Response Time**: We aim to respond to security issue reports within 48 hours, acknowledging receipt.
**[Report a vulnerability →](https://github.com/jason5ng32/MyIP/security/advisories/new)**
4. **Updates**: We will keep you informed about our progress on the issue through the GitHub Issues thread.
It's visible only to the maintainer, and it's the right channel even if you're unsure
whether what you found is a real issue.
5. **Credit**: We value your contribution to the security of this project. We will acknowledge your efforts in the GitHub Issue thread and in our release notes, if a fix is implemented.
Helpful things to include:
## Security Update Process
- What an attacker can do with it, and what they'd need to start (a session? just a URL?).
- Steps to reproduce — a request, a payload, or a short script.
- Where the problem lives: front-end (`frontend/`), API handler (`api/`), backend
code (`server/`), or shared code (`common/`).
- Whether you hit it on [ipcheck.ing](https://ipcheck.ing) or your own deployment, and
the version if self-hosted.
Upon receiving a report of a vulnerability, our team will:
## What to expect
- Promptly investigate the issue.
- Develop a fix and test it thoroughly.
- Release an updated version that addresses the issue.
- Provide updates and patches as needed.
- An acknowledgement within a few days.
- A fix in the next release once it's confirmed, or an explanation if it turns out to be
out of scope.
- Credit in the release notes and the advisory, unless you'd rather stay anonymous.
Thank you for helping us keep our project safe and secure.
Please hold off on public disclosure until a fix ships.
## Out of scope
- **Findings against [ipcheck.ing](https://ipcheck.ing) infrastructure** rather than this
codebase — rate limits, TLS configuration, DNS, hosting. Report those the same way, but
they aren't code issues.
- **Third-party data providers.** MyIP queries external IP-geolocation and network APIs;
their vulnerabilities belong to them.
- **Automated scanner output** with no working proof of concept.
- **Missing optional hardening in a self-hosted instance.** `ALLOWED_DOMAINS` and the
other environment settings are documented in the
[Developer Guide](https://docs.ipcheck.ing/developer) — an instance running without them
is misconfigured, not vulnerable.
+41
View File
@@ -0,0 +1,41 @@
# Getting help
Start with the docs, then pick the channel that matches what you need.
## Documentation first
The **[MyIP Docs Center](https://docs.ipcheck.ing)** covers most questions, in English,
中文, français and русский:
- **[Developer Guide](https://docs.ipcheck.ing/developer)** — deployment (Node, Docker,
Vercel), MaxMind setup, reverse proxies and `ALLOWED_DOMAINS`, environment variables,
architecture.
- **[Knowledge Base](https://docs.ipcheck.ing/knowledge-base)** — what each tool measures
and how to read its results.
Two settings account for most self-hosting problems: MaxMind credentials (without them
the MaxMind source returns 503) and `ALLOWED_DOMAINS` (without it every request from a
non-localhost domain gets 403).
## Where to ask
| You want to… | Go to |
|---|---|
| Ask how to do something, or why a result looks odd | [Discussions → Q&A](https://github.com/jason5ng32/MyIP/discussions/categories/q-a) |
| Report a bug you can reproduce | [New issue → Bug report](https://github.com/jason5ng32/MyIP/issues/new?template=bug_report.md) |
| Suggest a feature | [New issue → Feature request](https://github.com/jason5ng32/MyIP/issues/new?template=feature_request.md) |
| Report a security vulnerability | [Private advisory](https://github.com/jason5ng32/MyIP/security/advisories/new) — see [SECURITY.md](SECURITY.md) |
| Contribute code or a translation | [CONTRIBUTING.md](CONTRIBUTING.md) · [TRANSLATING.md](TRANSLATING.md) |
There are no chat channels — everything happens on GitHub, in the open, so the next
person with the same question can find the answer.
## Making your question answerable
Deployment questions need: how you're running it (Docker / Node / Vercel), the version,
what you set for `ALLOWED_DOMAINS` and MaxMind, and the backend terminal output.
Page questions need: browser, what you saw versus what you expected, and any errors from
the browser console.
MyIP is maintained by one person in their spare time. Replies aren't instant, and a
question that already contains the details above gets answered a lot sooner.
+247
View File
@@ -0,0 +1,247 @@
# Translating MyIP
MyIP (demo website: [IPCheck.ing](https://ipcheck.ing)) ships its UI in English, 简体中文, 繁體中文,
Français, Русский and Português (Brasil). Adding a language is a two-file change, and **a
partial translation is a welcome first PR**. You do not need to translate everything, and you
do not need to know Vue.
Anything you leave untranslated falls back to English, so a pack with a hundred strings in
it is genuinely useful on day one and can grow over several PRs. Untranslated strings stay
in your pack as `""`, which is what keeps a translation PR readable: every diff line is a
`""` becoming a sentence.
General contribution rules (fork, branch from `dev`, `pnpm check` before you open the PR)
live in [CONTRIBUTING.md](CONTRIBUTING.md).
## Which languages we take
**New languages are added by visitor demand, not on request.** Every pack has to follow
every UI change — the English pack grew by roughly 45% over two months in 2026, and each
shipped language followed in the same commits — and each language is one more variant of
the docs site to look after. So a language opens only once it has a meaningful share of
IPCheck.ing's visitors. The open **Translations wanted** issue lists the languages
currently up for claiming; a pack for a language that isn't on it won't be merged, however
good it is. Right-to-left languages also need layout work the app doesn't have yet.
If your language isn't on the list, a README translation is welcome in any language — that
is a single file and costs nothing to keep (see CONTRIBUTING.md).
---
## The smallest useful PR
```bash
pnpm i18n-new es-MX # your language's code — see "Naming the code" below
```
That writes the two files a language needs and tells you what to do next:
1. **`frontend/locales/<code>.json`** — every key `en.json` has, every value `""`. Fill in
what you like and leave the rest empty; starting with just the `page` and `nav` sections
is fine.
2. **One line in `common/locale-registry.js`** with `status: 'beta'`. Read it over — the
script guesses `nativeName`, `flag` and `apiTag` and can be wrong.
That's it. No backend change, no build config, no component edits. Everything else — the
language picker, `<html lang>`, the fallback chain, lazy-loading your pack — is derived
from that registry line.
**Leave the keys you skip in place with their `""`.** Deleting them fails the gate, and
keeping them is the whole point: `pnpm i18n-status` can count what's left, and a reviewer
can see at a glance which strings you actually wrote.
## Where the copy lives
| File | What it is | Required? |
|---|---|---|
| `frontend/locales/<code>.json` | The main pack — every string in the app UI (~1,650 keys) | **Yes** — all keys, values may be `""` |
| `frontend/locales/privacy/<code>.json` | Privacy policy copy (~50 keys) | No — but whole or not at all |
| `frontend/locales/security-checklist/<code>.json` | The Cybersecurity Checklist dataset (~1,080 keys, 258 items) | No — but whole or not at all |
| `frontend/data/changelog.json` | Release history, one string per language per entry | No — beta languages are exempt |
`en.json` is the reference for all of them: a translation may lag behind English, never
contradict it.
**The two optional files ship whole or not at all.** Unlike the main pack, they load as one
file each with no per-key fallback inside, so a half-finished one renders a half-English
page. The gate rejects a `""` in either.
- **Privacy copy** is short — translate it in one sitting, or leave it out. Partial privacy
files don't ship.
- **The checklist** is long (258 items). Same rule: showing the full English checklist beats
showing gaps, so leave the file out until it's done.
When you're ready to do one, scaffold it the same way you scaffolded the main pack:
```bash
pnpm i18n-new es-MX --privacy --checklist # either flag, or both
```
Both need the language registered already (the plain `pnpm i18n-new <code>` run). The
checklist skeleton keeps `slug` and `priority` filled in — those are data, not copy. Until
every other value is filled in, `pnpm test` stays red: finish the file before you commit
it, or delete it again.
## The registry line
`pnpm i18n-new` writes this for you; this is what to check it against.
`common/locale-registry.js`:
```js
{ code: 'es-MX', nativeName: 'Español (México)', flag: 'mx', apiTag: 'es', htmlLang: 'es-MX', status: 'beta' },
```
| Column | What to put in it |
|---|---|
| `code` | The UI code **and** your JSON file name. See the naming rule below. |
| `nativeName` | The language's name as its own speakers write it (`Français`, not `French`). |
| `flag` | A lowercase two-letter [circle-flags](https://github.com/HatScripts/circle-flags) code for the picker icon. |
| `apiTag` | The tag sent to upstream data sources. See below. |
| `htmlLang` | The BCP-47 tag written to `<html lang>`. Be precise where script matters: `zh` declares `zh-CN` so Han glyphs stay Simplified on Japanese systems. |
| `status` | `'beta'` for a new language. Maintainers flip it to `'full'` — see [From beta to full](#from-beta-to-full). |
Registry order is also the order the language picker renders, so add your entry where it
reads best rather than always at the end.
### Naming the code
- The **default variant of a language uses the bare code**: `zh` is Simplified Chinese,
`pt` would be European Portuguese.
- **Variants that arrive later use the full region code**: `zh-TW`, `pt-BR`, `es-MX`.
- If you are translating a variant whose base language is already registered, use the
region code — your pack then inherits the base as a fallback (see below).
### Picking `apiTag`
Geographic names (city, region names) come from upstream IP databases, which localize into
a fixed set of tags: `en`, `de`, `es`, `fr`, `ja`, `pt-BR`, `ru`, `zh-CN`. Put the closest
one of those in `apiTag`. If none is close, just repeat your own code — place names will
render in English, which is expected and not a bug.
Country names are unaffected: they come from the browser's own `Intl` data and localize
automatically for any language. Dates and times do too.
## What users see for keys you haven't translated
An untranslated string — `""` or a key that isn't there — walks a chain: **your locale →
its base language (if registered) → English**.
- `fr` → `en`
- `zh-TW` → `zh` → `en`
It applies per key in the main pack and per entry in the changelog; the privacy copy and
the checklist walk the same chain, but a whole file at a time. The app never renders a blank
or a raw key path, and it logs no warnings — an incomplete beta pack is a supported state,
not an error.
In the language picker, a `beta` language carries a small **Beta** badge (deliberately the
English word, in every language).
## Checking your work locally
```bash
pnpm install
pnpm test # the hard gate — must be green
pnpm i18n-status # progress report — never fails, just tells you where you are
pnpm dev # then pick your language in Preferences
```
`?hl=<code>` also selects a language, but only before you've saved one in Preferences — a
stored preference wins over the query param.
### What `pnpm test` enforces
`tests/locale-packs.test.js` is the gate. Against English, your pack must:
- **Have exactly English's keys** — no more (a typo'd path is the usual cause), no fewer
(`pnpm i18n-sync` re-adds any you dropped). Untranslated ones stay as `""`.
- **Use no placeholder English doesn't provide.** `{count}`, `{name}` and friends must be a
subset of what the English string uses; keep them spelled exactly as-is, and move them
around the sentence as your grammar needs. Empty values are skipped, naturally.
- **Keep `slug` and `priority` untranslated** in the security checklist — those are data
(URLs and badge colors), not copy.
- **Contain no `""` in the two optional files.** The privacy copy and the checklist ship
finished or not at all; only the main pack may carry untranslated values.
- **Translate everything, if the language is `full`.** A `""` left in a `full` locale's main
pack fails the build, and all three files have to be there. `beta` locales are free to
leave as many `""` as they like — in the main pack.
`tests/locale-registry.test.js` checks your registry line's shape, and
`tests/changelog.test.js` only requires changelog history for `full` languages.
### What `pnpm i18n-status` shows
Per language and per dataset: percentage translated, and the next keys to do. It also
counts values that are byte-identical to English — sometimes right (`MTR`, product names),
sometimes a copy-paste that never got translated.
```bash
pnpm i18n-status --locale pt-BR --limit 30
```
### When English changes under you
New keys appear in `en.json` all the time. Realign every pack with one command:
```bash
pnpm i18n-sync
```
It adds English's new keys to your main pack as `""`, drops the ones English no longer has,
and puts everything back in English's order. Run it whenever `pnpm test` says keys are
missing, and after a rebase. It never overwrites a translation.
For the privacy copy and the checklist it only *reports* what moved — writing `""` into
them would fail the gate on your behalf. Translate the new keys, or drop the file.
## Improving an existing language
Fixes and better phrasings for `en` / `zh` / `zh-TW` / `fr` / `ru` / `pt-BR` are just as
welcome as new languages — edit the JSON and open the PR. Two things to keep in mind:
- Prefer natural phrasing over literal translation; this is a networking tool, and the
jargon most users know is often the English term.
- Free-form input fields are `autocomplete`-hardened, and their placeholder copy avoids the
words "address / 地址 / adresse / adresi" on purpose — iOS QuickType keys on them and
offers to autofill a postal address into an IP field. When translating a placeholder,
pick a wording that avoids your language's equivalent word too.
## From beta to full
A `full` language is one every future copy change is required to land in, so promotion is a
maintainer decision, made when the language is actually complete:
- Main pack, privacy copy and security checklist all at 100% against `en` — not a single
`""` left (`pnpm i18n-status` shows this).
- Changelog history back-filled — every entry in `frontend/data/changelog.json`.
- Enough of a track record that copy changes will keep landing in it.
Once `status` flips to `'full'`, `tests/locale-packs.test.js` and `tests/changelog.test.js`
start failing on any gap, which is exactly the point. Until then, no pressure: a beta
language never breaks a build.
## Known limits
Things that will look "untranslated" no matter how complete your pack is. None of them are
bugs, and none of them block a PR:
- **Place names** (city, region) come from upstream IP databases and only exist in `de`,
`en`, `es`, `fr`, `ja`, `pt-BR`, `ru`, `zh-CN`. Other languages get English place names
next to a fully translated UI.
- **The pre-Vue loading screen** in `index.html` carries its own tiny set of strings —
it runs before the app and its bundle exist. Beta languages fall back to English there;
maintainers handle it at promotion time.
- **Some tool output is upstream data**, not UI copy: Whois records, DNS answers, ASN
organization names, service-status incident text. Those arrive in whatever language the
source publishes.
- **The backend needs no change for a new language** — it resolves whatever tag the UI
sends onto the closest one its data sources actually have, so a translation PR never
touches back-end code.
- **Documentation** at [docs.ipcheck.ing](https://docs.ipcheck.ing) and the README
translations are separate efforts — see CONTRIBUTING.md for README translations.
## Questions
Open an issue. If you're planning a large translation, saying so up front is useful — it
avoids two people translating the same 1,000 keys in parallel.
+154 -89
View File
@@ -1,136 +1,201 @@
# api/AGENTS.md
Conventions for Express 5 handlers under `api/` and shared back-end code
under `common/`. Universal rules live in ../AGENTS.md.
Conventions for Express 5 handlers under `api/` and the back-end code under
`server/` and `common/`. Universal rules live in ../AGENTS.md.
## Overview
The Express app lives in `backend-server.js` at the repo root — every route
is wired there and delegated to one handler module under `api/`. `common/`
holds shared back-end code (guards, logger, fetch helper, MaxMind / CAIDA
services, service-status poller), parts of which the frontend also imports
(`valid-ip.js`, `fetch-with-timeout.js`).
is wired there and delegated to one handler module under `api/`. Code the
handlers share is split by who imports it:
- **`server/`** — backend-only (guards, logger, upstream clients, service-status
poller, …), flat; `server/datasets/` is the offline dataset engine and its
readers (see "Offline datasets").
- **`common/`** — only what the frontend or the build imports too
(`valid-ip.js`, `fetch-with-timeout.js`, `report-schema.js`, …); it never
imports from `server/`. It also holds the dataset directories (`*-db/`),
which self-hosting docs and Docker volumes mount — they stay put.
Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` /
`ipapi-com` / `ipapi-is` / `ip2location-io` / `ip-sb` / `ipcheck-ing` /
`maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` /
`cf-radar` / `net-outages` / `asn-history` / `asn-connectivity` /
`cf-radar` / `asn-history` / `asn-connectivity` / `asn-profile` /
`ooni-blocking` / `globalping-probes` / `service-status` / `google-map` /
`github-stars` / `invisibility-test` / `dns-leak-test`), user
proxies (`get-user-info` / `update-user-achievement`), platform
(`configs` / `sentry-tunnel` / `share-report`). Each file's header comment
states its route and purpose — read those for specifics.
`github-stars` / `invisibility-test` / `dns-leak-test` / `persona` /
`ip-blocklist`), user
proxies (`get-user-info` / `update-user-achievement`), platform (`configs` /
`sentry-tunnel` / `share-report`). Each file's header comment states its route
and purpose — read those for specifics.
The exception to one-file-per-route is Cloudflare Radar: all Radar data rides
`/api/cfradar`, dispatched by `?view=` over `RADAR_VIEWS` in
`server/cf-radar.js`, where each view declares its guards, edge-cache TTL and
fetch function — new Radar data is a view function plus a registry row, never
a new route. `api/data/` holds contributor-editable static config consumed by
handlers — currently `dns-resolvers.js`, the country-annotated resolver list
behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
### ASN data
- **`/api/asn-profile`** (`server/asn-profile.js`) runs each ASN source's core
under a deadline above the upstream timeouts it wraps (`INNER_TIMEOUTS`); a
failed or late one is just absent. Always 200 `{ asn, status, incomplete,
…sections }` (`ok` / `empty` / `error` / `disabled`; an upstream fault is
`error`, never `empty`; `incomplete` = `ok` but partial); 502 if all fail.
- **Relationship counts follow the graph:** `asn-connectivity` neighbour
lists come from local CAIDA (`server/as-relationships.js`), dropping the
providers the graph walk distrusts. The Radar `asn` view (ASN Info) carries
no prefix or relationship counts — the ASN Profile has both.
- **`/api/whois` `?q=`:** IPs (RDAP, whoiser fallback), domains (whoiser, RDAP
fallback), `AS<n>` (RDAP autnum); all carry a `__raw` block.
- **Offline datasets** (MaxMind, CAIDA, PeeringDB, IEEE MAC registries) are rows in
`server/datasets/datasets.js`, run by one engine,
`server/datasets/dataset-updater.js` (lock, atomic state, publish, boot download,
daily schedule). A row holds only its own logic — find the newest remote
version, fetch / distill it, validate, reload; a new dataset is a new row,
never a new updater. Fetched at boot when its reader has nothing usable
(missing, or a file it refuses) or a publish was cut short; refreshed daily at
`DATASET_UPDATE_CRON` (default 04:30 local) unless `DATASET_AUTO_UPDATE=false`
(a row's pre-engine flag, e.g. `CAIDA_AUTO_UPDATE`, still applies when that
is unset), with a catch-up run at boot for a check a downtime skipped. Files
changed on disk by anyone else (another process, a hand-placed `.mmdb`) are
reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together);
PeeringDB only with a Cloudflare key; its 116 MB dump is
stream-distilled (never parsed whole) to a ~6 MB per-ASN index without
contact data (`server/datasets/peeringdb-distill.js`). The IEEE row (MAC Lookup)
needs no key: five registry CSVs published as downloaded, answered by the
longest covering assignment (`server/datasets/oui-db.js`).
## Conventions
- **Handler shape.** Single default export `async (req, res) => …`: read
`req.query` / `req.body`, call upstream, write one response.
- **Every upstream call uses `fetchUpstream`** from
`common/fetch-with-timeout.js` (8s timeout). Never a bare `fetch()` /
`https.get()` — a hanging provider must time out, not pin the connection.
It also injects a default `User-Agent` of `MyIP/v<version>/<VITE_SITE_URL>`
(registered at boot by `common/upstream-ua.js` — some upstream WAFs block
undici's default `node` UA); caller-supplied `User-Agent` headers, including
the private-API `{ ...req.headers }` pass-through, always win.
- **Error shape.** `res.status(500).json({ error: error.message })` on
upstream failure, `400` on bad input. Terse — the frontend doesn't display
these verbatim.
- **Every upstream call uses `fetchUpstream`** (`common/fetch-with-timeout.js`,
8s timeout), never a bare `fetch()` / `https.get()` — a hanging provider must
time out. It injects a default `User-Agent` `MyIP/v<version>/<VITE_SITE_URL>`
(`server/upstream-ua.js`; some WAFs block undici's `node` UA); caller-supplied
ones, incl. the private-API `{ ...req.headers }` pass-through, win.
- **Error shape.** `res.status(500).json({ error: error.message })` on upstream
failure, `400` on bad input. Terse — the frontend doesn't show them verbatim.
- **Response shape.** IP-geolocation handlers normalize to the canonical
frontend shape (`ip` / `country_code` / `latitude` / `asn` / `org` / …);
new sources match it. `timezone` is the exception — no handler produces it;
see "Response enrichment" below.
- **Logging.** Shared logger only, `logger.error({ err, ...ctx }, 'msg')`;
no `console.*`, no "received request" lines (`pino-http` covers those when
enabled).
frontend shape (`ip` / `country_code` / `latitude` / `asn` / `org` / …); new
sources match it. `timezone` is the exception (see "Response enrichment").
- **`?lang` is never validated in a handler.** Pass the raw tag through:
`lookupMaxMind` normalizes it onto the languages its data carries
(`SUPPORTED_LANGS` in `server/datasets/maxmind-service.js`); the private-API proxies
forward it for the upstream's own resolution. No allow-lists; `/api/map`'s
`language` only gets a tag-shape check, as each value is a year-cached
Static Maps call.
- **Logging.** Shared logger only, `logger.error({ err, ...ctx }, 'msg')`; no
`console.*`, no "received request" lines (`pino-http` covers those).
- **Error monitoring (Sentry) is env-gated and invisible to handlers.**
Root-level `sentry-instrument.js` (loaded via `node --import` *before*
express, so ESM loader hooks can auto-instrument route tracing) does the
init; `backend-server.js` attaches `setupExpressErrorHandler` after the
routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads. Handlers
never import Sentry or capture manually: uncaught throws and 5xx traces
are automatic; caught failures stay on the logger — a hook in
`common/logger.js` mirrors warn+ to Sentry Logs and elevates error+ to
grouped, alertable Issues. Periodic jobs wrap their tick in
`common/sentry-cron.js` for Crons check-ins. API-key query params
(`key` / `token` / …) are redacted from telemetry URLs
(`common/sentry-scrub.js`, wired as `beforeBreadcrumb` / `beforeSendSpan`
/ `beforeSend` hooks).
`sentry-instrument.js` (via `node --import`, *before* express, for ESM
auto-instrumentation) inits; `backend-server.js` adds
`setupExpressErrorHandler` after the routes. No `SENTRY_DSN_BACKEND` → never
loaded; `SENTRY_ENVIRONMENT=development` skips the init. Handlers never import
Sentry: throws and 5xx traces are automatic; caught failures stay on the
logger (its hook mirrors warn+ to Sentry Logs, error+ to Issues). Periodic
jobs wrap their tick in `server/sentry-cron.js`; `key` / `token` query params
are redacted (`server/sentry-scrub.js`).
## Security & Boundaries
### Guards live in middleware, not handlers
`common/guards.js`, attached in `backend-server.js` — handlers never repeat
these checks:
`server/guards.js`, attached in `backend-server.js`; handlers never repeat them:
- `requireReferer` — global on `/api/*` (ALLOWED_DOMAINS + localhost).
- `requirePublicIP()` — per-route for `?ip=`; handler sees a well-formed,
publicly routable IP. Reserved space (RFC 1918, loopback, CGNAT, link-local,
documentation, …) is rejected here, so no geo source is ever asked about an
address it can't answer for — `isUsablePublicIP` in `common/valid-ip.js` is
the single definition, shared with the front-end IP forms.
- `requireValidDomain()` — `?domain=`, lowercases in place so the edge cache
sees one canonical key.
- `requireValidPrefix()` — `?prefix=` (CIDR); lets the frontend quantize to
the BGP DFZ floor (/24 v4, /48 v6) for maximal CF edge-cache reuse.
- `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric
(`cf-radar` predates it and still validates inline).
- `requireValidProviderId()` — whitelists `?id=` against service-status slugs.
- `requirePublicIP()` — per-route `?ip=`: well-formed and publicly routable;
reserved space (RFC 1918, loopback, CGNAT, link-local, documentation, …) never
reaches a geo source. `isUsablePublicIP` (`common/valid-ip.js`) is the one
definition, shared with the front-end IP forms.
- `requireValidDomain()` — `?domain=`, lowercased in place (one cache key); a
leading `_` is allowed below the TLD (RFC 8552 `_dmarc.…`).
- `requireValidPrefix()` — `?prefix=` (CIDR); the frontend quantizes to the BGP
DFZ floor (/24 v4, /48 v6) for edge-cache reuse.
- `requireValidASN()` — `?asn=` in AS1…AS4294967295, rewritten as bare digits.
`normalizeAsnQuery()` — `/api/whois` `?q=`: `AS<n>` (prefix required) is
canonicalized; anything else passes through. Both use `common/asn-input.js`.
- `requireValidCountry()` — `?country=` (alpha-2), uppercased, syntactic only.
`requireValidProviderId()` — `?id=` ∈ service-status slugs.
- `requireValidRecordType()` — whitelists and uppercases `?type=` against
`DNS_RECORD_TYPES` (`common/dns-record-types.js`), the list the DnsResolver
picker and `resolveDns` also read; otherwise the DoH branch would forward any
string to four third-party endpoints.
- `requireValidReportId()` — `/api/report/:id` route param (22-char base64url).
New param shape → new guard in `common/guards.js`, attached in
`backend-server.js`; never open-coded in the handler.
New param shape → new guard there, attached in `backend-server.js`.
Rewrites go through `setQueryParam()` there: Express 5's `req.query` re-parses
on every read, so a direct write is lost.
### Response enrichment lives in middleware too
`withTimeZone()` (`common/ip-timezone.js`), attached to all seven geo routes,
derives `timezone` (IANA name) from the `latitude` / `longitude` the handler
just returned and adds it on the way out — 2xx only, same res.json hook and
same rule as `cacheable`. No handler computes or forwards a timezone, not even
the private-API pass-throughs.
`withTimeZone()` (`server/ip-timezone.js`), on all seven geo routes, derives
`timezone` (IANA name) from the `latitude` / `longitude` the handler returned
(2xx only, the `cacheable` hook); no handler computes or forwards one. Own
coordinates keep the zone consistent with the city beside it; only the name
ships — the frontend renders the offset, as a 24h-cached offset breaks at every
DST switch. A new geo source adds the middleware.
Deriving it from the response's own coordinates is what keeps the zone from
contradicting the city beside it; a second database asked about the same IP
would eventually disagree. Only the zone name ships — the frontend renders the
UTC offset from it, because these routes sit behind a 24h edge cache and a
cached offset goes an hour wrong at every DST switch.
### Offline data gates boot, not the listener
A new geo source inherits the field by adding the middleware to its route.
The backend listens before any missing offline dataset or the service-status
snapshot is fetched; snapshots already on disk load first, so only a first
boot waits on downloads. During that window `requireOfflineData([...probes])`
(`server/offline-data.js`) answers 503 on a route whose data is still missing
— never edge-cached — and is a no-op after boot, when a missing dataset means
a failed download served degraded. A route reading local data declares its
probes (`isMaxMindReady`, `isAsRelLoaded`, …) in `backend-server.js`. Answers
assembled from parts don't gate: an ASN Profile section whose data is still
loading (`isStillLoading`) answers `error`, leaving the others to answer.
### Private-API header pass-through (intentional exception)
Handlers proxying our private IPCheck.ing API (`ipcheck-ing`,
`invisibility-test`, `update-user-achievement`, `get-user-info`,
`dns-leak-test`) forward the caller's headers upstream
(`headers: { ...req.headers }`) — the upstream needs caller context
(Accept-Language, auth tokens). Do **not** replicate for third-party
`dns-leak-test`, `persona`, `ip-blocklist`, and `asn-profile`'s reputation via
`server/asn-reputation.js`) forward the caller's headers upstream (`headers: {
...req.headers }`) — the upstream needs caller context (Accept-Language, auth
tokens). `persona` strips the framing headers (`host` / `content-length` / …)
first, as it re-serializes the body. Do **not** replicate for third-party
upstreams; those get only what's explicitly needed.
### Defensive method gates
### Firebase Auth handler proxy
Some handlers keep a `req.method !== 'GET'` branch although the route
already gates the method — smoke tests assert on that branch directly.
Leave the gate in place when a test covers it.
`frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy
`/__/auth/*` to `<projectId>.firebaseapp.com` (`common/firebase-auth-proxy.js`)
when `VITE_FIREBASE_PROJECT_ID` is set, so `VITE_FIREBASE_AUTH_DOMAIN` can be
the deployment's own host and the sign-in handler runs first-party — inside
the installed PWA's webview and Safari's storage partition, where
`getRedirectResult` finds the result. Every OAuth provider must accept
`https://<host>/__/auth/handler` per environment. Not an `/api` route: no
guards, caching or per-request logs.
## Edge caching
Every `/api/*` response defaults to `Cache-Control: no-store`; slowly-changing
public routes opt in via the `cacheable(maxAgeSeconds)` middleware in
`backend-server.js` — e.g. `app.get('/api/cfradar', cacheable(60 * 60), …)`.
Write TTLs as multiplied expressions (`24 * 60 * 60`), not raw seconds.
The middleware only sets `public, max-age=N` on status < 400, so CF never
caches error pages; handlers themselves never touch `Cache-Control`.
**Auth'd / per-user endpoints must not be wrapped** — their caching belongs
to the upstream that owns the auth context.
public routes opt in via `cacheable(maxAge)` (`server/cache-control.js`) in
`backend-server.js`, e.g. `cacheable(24 * 60 * 60)` (TTLs written that way, not
raw seconds). `maxAge` may be a `(req) => seconds` resolver (cfradar: its view's
TTL; falsy = no-store). `{ cacheIf }` vetoes the full TTL for a 2xx body,
`{ degradedMaxAge }` gives a vetoed one a shorter TTL instead of no-store:
`/api/asn-profile` caches a complete answer (no section `error` / `incomplete`;
reputation is not per-user) for 7 days and a degraded one for 1 day — some
upstreams fail for some ASes every time — except inside the boot window
(`isOfflineBootstrapping`); cfradar caches no partial view answer
(`isCompleteRadarAnswer`). The ASN family (profile, Radar `asn` /
`bgp-prefixes`) shares 7 days. Only status < 400 is cached; handlers
never touch `Cache-Control`. **Auth'd / per-user endpoints must not be
wrapped** — their caching belongs to the upstream owning the auth context.
## Testing
- Handlers get smoke tests in `tests/api-handlers.test.js`: method gating,
param branches, "API key missing" early returns. Most are covered; a new
or touched handler ships its block in the same change.
- Never hit real upstreams — assert on branches that return before the first
`fetchUpstream`, or stub `globalThis.fetch` when the behavior under test
lives past it (google-map stream tests; restored in the shared `afterEach`).
- Middleware is covered by `tests/guards.test.js`; don't duplicate its
assertions per-handler. Fetch timeout/abort behavior:
`tests/fetch-with-timeout.test.js`.
- Handlers get smoke tests in `tests/api-handlers.test.js` (method gating, param
branches, "API key missing" early returns); a new or touched handler ships its
block in the same change. A `req.method !== 'GET'` branch the route already
gates stays when a test asserts on it.
- Never hit real upstreams: assert before the first `fetchUpstream`, or stub
`globalThis.fetch` (restored in the shared `afterEach`).
- Middleware is covered by `tests/guards.test.js` (don't duplicate per handler);
fetch timeout / abort by `tests/fetch-with-timeout.test.js`.
+118 -52
View File
@@ -1,42 +1,72 @@
// /api/asn-connectivity — BFS over CAIDA AS-Relationships starting at the
// origin AS, halting at Tier 1s or MAX_DEPTH. Returns a graph the frontend
// /api/asn-connectivity — layered BFS over CAIDA AS-Relationships starting at
// the origin AS, halting at Tier 1s or MAX_DEPTH. Returns a graph the frontend
// renders dagre-style: origin (left) → intermediates → Tier 1s (right).
// Inspired by bgp.tools' /as/<N>#connectivity view.
//
// Data is fully local (common/as-rel-db.js + common/as-org-db.js), so the
// Edges carry a kind: 'transit' (p2c, customer → provider) or 'peering' (p2p
// with a Tier 1). Peering matters because hypergiants buy no transit — their
// backbone connectivity is settlement-free peering with the Tier 1 clique,
// invisible to a p2c-only walk.
//
// Data is fully local (server/datasets/as-rel-db.js + server/datasets/as-org-db.js), so the
// whole BFS is synchronous. We only hit RIPEstat for as-overview as a
// rare fallback when as2org doesn't have an ASN's org name.
//
// Alongside the graph, `neighbours` lists the origin's direct providers,
// peers and customers from the same snapshot, counted by the walk's own
// misinference rule (server/as-relationships.js) so the counts never show an
// upstream the graph drops. List names come from as2org, gaps filled from the
// graph's resolved names.
import { resolveAsnOrgName } from '../common/ripestat.js';
import { providersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js';
import logger from '../common/logger.js';
import { resolveAsnOrgName } from '../server/ripestat.js';
import { providersOf, peersOf, customersOf, customerCountOf, isTier1 } from '../server/datasets/as-rel-db.js';
import { lookupAsOrgName } from '../server/datasets/as-org-db.js';
import { TIER1_PEERING_TRUSTED, TIER1_ADJACENCY_TRUSTED, countedRelationships } from '../server/as-relationships.js';
import logger from '../server/logger.js';
// How deep to recurse from the origin. 3 covers regional networks reaching
// Tier 1s through 1-2 intermediates; deeper just adds noise at the periphery.
const MAX_DEPTH = 3;
// Per-node cap on non-Tier-1 providers to recurse into. Matters mainly for
// hyperscalers (Cloudflare-class has 100+ providers); ranked by customerCount
// as a proxy for "primary transit".
// Per-node cap on non-Tier-1 providers to recurse into, ranked by
// customerCount as a proxy for "primary transit".
const MAX_INTERMEDIATE_BRANCH = 3;
// Two-tier org name resolver lives in common/ripestat.js. No onError hook
// Past either bar (TIER1_PEERING_TRUSTED / TIER1_ADJACENCY_TRUSTED, defined
// in server/as-relationships.js so the neighbour counts use the same ones), a
// node's non-Tier-1 "providers" are treated as CAIDA misinference and dropped.
// Two-tier org name resolver lives in server/ripestat.js. No onError hook
// here — connectivity stays silent on as-overview fallback failures (a node
// just keeps name=null); asn-history is the one that warns.
const resolveOrgName = (asn) => resolveAsnOrgName(asn);
async function buildGraph(origin) {
const nodes = new Map();
const edgeSet = new Set();
const orgPromises = new Map();
// Per-kind cap on the neighbour name lists; counts stay uncapped.
const NEIGHBOUR_LIST_CAP = 50;
// If the queried AS is itself a Tier 1, mark it 'origin-tier1' so the
// frontend can render the combined origin+Tier1 styling; the rest of
// the BFS is a no-op (Tier 1s have no providers) and the graph is
// legitimately a single node.
const originType = isTier1(origin) ? 'origin-tier1' : 'origin';
nodes.set(origin, { asn: origin, type: originType, name: null });
orgPromises.set(origin, resolveOrgName(origin));
// Default adjacency API for buildTopology / buildNeighbours; tests inject a
// fixture instead.
const asRelApi = { providersOf, peersOf, customersOf, isTier1, customerCountOf };
// Pure synchronous topology walk — no org names, no I/O. Exported for tests.
export const buildTopology = (origin, rel = asRelApi) => {
const nodes = new Map();
const edges = [];
const edgeKeys = new Set();
const addNode = (asn, type) => {
if (!nodes.has(asn)) nodes.set(asn, { asn, type, name: null });
};
// First kind wins on a duplicate pair; transit is always added first.
const addEdge = (from, to, kind) => {
const key = `${from}->${to}`;
if (edgeKeys.has(key)) return;
edgeKeys.add(key);
edges.push({ from, to, kind });
};
// A Tier-1 origin's graph is its peering edges into the rest of the clique.
addNode(origin, rel.isTier1(origin) ? 'origin-tier1' : 'origin');
let currentLayer = [origin];
@@ -45,31 +75,33 @@ async function buildGraph(origin) {
const nextLayer = [];
for (const asn of currentLayer) {
const providers = providersOf(asn);
if (providers.length === 0) continue;
const providers = rel.providersOf(asn);
const tier1Providers = providers.filter(p => rel.isTier1(p));
const tier1Peers = rel.peersOf(asn).filter(p => rel.isTier1(p));
// Tier 1 hits are terminal — record the edge + node, no recursion.
for (const p of providers) {
if (!isTier1(p)) continue;
edgeSet.add(`${asn}->${p}`);
if (!nodes.has(p)) {
nodes.set(p, { asn: p, type: 'tier1', name: null });
orgPromises.set(p, resolveOrgName(p));
}
for (const p of tier1Providers) {
addEdge(asn, p, 'transit');
addNode(p, 'tier1');
}
for (const p of tier1Peers) {
addEdge(asn, p, 'peering');
addNode(p, 'tier1');
}
// Non-Tier-1 providers: pick top-N by customerCount so when we
// truncate, the displayed ones are the more meaningful transits.
// Clique-adjacent node: non-Tier-1 providers are noise.
if (tier1Peers.length >= TIER1_PEERING_TRUSTED
|| tier1Providers.length + tier1Peers.length >= TIER1_ADJACENCY_TRUSTED) continue;
const intermediates = providers
.filter(p => !isTier1(p))
.sort((a, b) => customerCountOf(b) - customerCountOf(a))
.filter(p => !rel.isTier1(p))
.sort((a, b) => rel.customerCountOf(b) - rel.customerCountOf(a))
.slice(0, MAX_INTERMEDIATE_BRANCH);
for (const p of intermediates) {
edgeSet.add(`${asn}->${p}`);
addEdge(asn, p, 'transit');
if (!nodes.has(p)) {
nodes.set(p, { asn: p, type: 'intermediate', name: null });
orgPromises.set(p, resolveOrgName(p));
addNode(p, 'intermediate');
nextLayer.push(p);
}
}
@@ -78,24 +110,23 @@ async function buildGraph(origin) {
currentLayer = nextLayer;
}
// Await all org lookups. They've been running in the background since
// each node was discovered, so most are already settled.
for (const [asn, promise] of orgPromises) {
return pruneLeafIntermediates([...nodes.values()], edges);
};
async function buildGraph(origin) {
const { nodes, edges } = buildTopology(origin);
// Org lookups run concurrently; a failed one just keeps name=null.
await Promise.all(nodes.map(async (node) => {
try {
const name = await promise;
const node = nodes.get(asn);
if (node && name) node.name = name;
const name = await resolveOrgName(node.asn);
if (name) node.name = name;
} catch {
// node keeps name=null
}
}
}));
const allNodes = [...nodes.values()];
const allEdges = [...edgeSet].map(s => {
const [from, to] = s.split('->').map(Number);
return { from, to };
});
return pruneLeafIntermediates(allNodes, allEdges);
return { nodes, edges };
}
// Iteratively drop intermediate nodes with no outgoing edge — visual
@@ -121,12 +152,47 @@ function pruneLeafIntermediates(nodes, edges) {
}
}
// Direct neighbours of `origin` as countedRelationships reads them (a
// distrusted non-Tier-1 provider is not an upstream), each kind ranked by
// customer count (bigger networks first, ASN as tie-break) and capped.
// Exported for tests.
export const buildNeighbours = (origin, rel = asRelApi, lookupName = lookupAsOrgName) => {
const { providers, peers, customers } = countedRelationships(origin, rel);
const toList = (asns) => [...asns]
.sort((a, b) => rel.customerCountOf(b) - rel.customerCountOf(a) || a - b)
.slice(0, NEIGHBOUR_LIST_CAP)
.map((asn) => ({ asn, name: lookupName(asn) || null }));
return {
counts: { providers: providers.length, peers: peers.length, customers: customers.length },
providers: toList(providers),
peers: toList(peers),
customers: toList(customers),
};
};
// Fill list names as2org lacks from the graph's resolved names, so an AS
// carries the same label in the graph and in the lists. Exported for tests.
export const fillNeighbourNames = (neighbours, nodes) => {
const names = new Map(nodes.filter((n) => n.name).map((n) => [n.asn, n.name]));
for (const kind of ['providers', 'peers', 'customers']) {
for (const item of neighbours[kind]) item.name = item.name || names.get(item.asn) || null;
}
return neighbours;
};
// The full response body for one ASN — also composed into /api/asn-profile.
export const getAsnConnectivity = async (asn) => {
const graph = await buildGraph(asn);
return { origin: asn, ...graph, neighbours: fillNeighbourNames(buildNeighbours(asn), graph.nodes) };
};
export default async (req, res) => {
// ASN presence + numeric validity guaranteed by requireValidASN middleware.
const asn = parseInt(req.query.asn, 10);
try {
const graph = await buildGraph(asn);
res.json({ origin: asn, ...graph });
res.json(await getAsnConnectivity(asn));
} catch (error) {
logger.error({ err: error, asn }, 'asn-connectivity handler failed');
res.status(500).json({ error: error.message });
+3 -3
View File
@@ -13,8 +13,8 @@
import {
fetchRoutingHistory,
resolveAsnOrgName,
} from '../common/ripestat.js';
import logger from '../common/logger.js';
} from '../server/ripestat.js';
import logger from '../server/logger.js';
const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10);
@@ -56,7 +56,7 @@ function summarizeOrigin(entry, minLen) {
};
}
// Two-tier resolver lives in common/ripestat.js. Here we pass a warn hook so
// Two-tier resolver lives in server/ripestat.js. Here we pass a warn hook so
// a failed as-overview fallback stays observable (asn-connectivity omits it
// and stays silent — keep that difference).
const resolveOrgName = (asn) =>
+69
View File
@@ -0,0 +1,69 @@
// /api/asn-profile?asn=<n> — everything the ASN Profile page shows, in one
// answer: Radar ASN summary + prefix list (server/cf-radar.js), CAIDA
// topology and neighbours (./asn-connectivity.js), RDAP autnum
// (server/rdap.js), ASRank (server/asrank.js), the local PeeringDB index
// (server/datasets/peeringdb-db.js) and, when the private API is configured,
// reputation (server/asn-reputation.js). Composition, deadlines and statuses
// live in server/asn-profile.js. `asn` arrives as a canonical number string
// (requireValidASN). 200 unless every configured source failed (502).
import { RADAR_VIEWS, hasRadarApiKey, loadAsnSummary, isCompleteRadarAnswer } from '../server/cf-radar.js';
import { rdapAutnum, isAutnumMissing } from '../server/rdap.js';
import { queryAsRank } from '../server/asrank.js';
import { requestAsnReputation } from '../server/asn-reputation.js';
import { isPeeringdbLoaded, lookupPeeringdb } from '../server/datasets/peeringdb-db.js';
import { isAsRelLoaded } from '../server/datasets/as-rel-db.js';
import { isAsOrgLoaded } from '../server/datasets/as-org-db.js';
import { isStillLoading } from '../server/offline-data.js';
import {
SOURCE_TIMEOUTS, buildSectionLoaders, composeAsnProfile, allSourcesFailed,
} from '../server/asn-profile.js';
import { getAsnConnectivity } from './asn-connectivity.js';
// A section whose local data isn't there fails (status 'error'): left out
// of the answer and keeping it out of the week-long cache, while every other
// section answers as usual — never an 'empty' / 'disabled' that would be
// cached for a week.
// Connectivity needs the as-rel snapshot (without it every graph is empty);
// as2org only names the nodes, RIPEstat covers a failed download, so it
// counts only while still downloading. Exported for tests.
export const connectivityReadiness = ({ relLoaded = isAsRelLoaded, orgLoaded = isAsOrgLoaded, fetch = getAsnConnectivity } = {}) =>
(...args) => {
if (!relLoaded()) throw new Error('CAIDA as-rel snapshot not loaded');
if (isStillLoading(orgLoaded)) throw new Error('CAIDA as2org snapshot still loading');
return fetch(...args);
};
// PeeringDB is 'disabled' only where it isn't configured (no Cloudflare key,
// so never downloaded). Configured but not loaded — still downloading, or
// the download failed — is an error. Exported for tests.
export const peeringdbReadiness = ({ configured = hasRadarApiKey, loaded = isPeeringdbLoaded } = {}) => () => {
if (!configured()) return false;
if (!loaded()) throw new Error('PeeringDB index not loaded');
return true;
};
export default async (req, res) => {
const asn = Number(req.query.asn);
const loaders = buildSectionLoaders({
hasRadarKey: hasRadarApiKey,
// The summary plus its failed segments, so a partial one isn't cached.
fetchRadarAsn: (n) => loadAsnSummary(String(n)),
fetchRadarPrefixes: (n) => RADAR_VIEWS['bgp-prefixes'].fetch({ asn: String(n) }),
isPartialPrefixes: (body) => !isCompleteRadarAnswer(body),
getConnectivity: connectivityReadiness(),
rdapAutnum: (n) => rdapAutnum(n, { timeoutMs: SOURCE_TIMEOUTS.autnum }),
isAutnumMissing,
queryAsRank: (n) => queryAsRank(n, { timeoutMs: SOURCE_TIMEOUTS.rank }),
// Private-API pass-through: the caller's headers go upstream.
requestReputation: (n) => requestAsnReputation(n, req.headers, { timeoutMs: SOURCE_TIMEOUTS.reputation }),
isPeeringdbLoaded: peeringdbReadiness(),
lookupPeeringdb,
});
const body = await composeAsnProfile(asn, loaders);
if (allSourcesFailed(body)) {
return res.status(502).json({ error: 'All sources failed', status: body.status });
}
return res.json(body);
};
+32 -133
View File
@@ -1,144 +1,43 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
// /api/cfradar — single dispatch route for all Cloudflare Radar data.
// `?view=` selects an entry in RADAR_VIEWS (server/cf-radar.js): the view's
// guards validate and normalize its params, its fetch builds the payload,
// and the route's cache middleware in backend-server.js reads the view's
// per-view TTL. Guards run before the API-key check so param errors stay
// 400s on keyless deployments (and tests never reach an upstream call).
// Common fetch request function.
// CLOUDFLARE_API is the pre-rename spelling — keep reading it so existing
// deployments don't lose the key on upgrade.
async function fetchFromCloudflare(endpoint) {
const url = `https://api.cloudflare.com/client/v4${endpoint}`;
const response = await fetchUpstream(url, {
headers: {
'Authorization': `Bearer ${process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API}`,
'Content-Type': 'application/json',
},
});
// Outage pages come back as HTML — fail on status instead of JSON.parse.
if (!response.ok) {
throw new Error(`Cloudflare Radar responded ${response.status}`);
}
return response.json();
}
import { RADAR_VIEWS, hasRadarApiKey } from '../server/cf-radar.js';
import logger from '../server/logger.js';
// The five Radar segments backing one /api/cfradar response, keyed by the
// field name cleanUpResponseData expects.
const SEGMENTS = {
asnInfo: (asn) => `/radar/entities/asns/${asn}`,
ipVersion: (asn) => `/radar/http/summary/ip_version?asn=${asn}&dateRange=7d`,
httpProtocol: (asn) => `/radar/http/summary/http_protocol?asn=${asn}&dateRange=7d`,
deviceType: (asn) => `/radar/http/summary/device_type?asn=${asn}&dateRange=7d`,
botType: (asn) => `/radar/http/summary/bot_class?asn=${asn}&dateRange=7d`,
};
// Run a view's guard middlewares outside an Express chain. Each guard either
// calls next() or writes its own 4xx response — so "didn't pass" means the
// response has already been sent and the dispatcher must stop.
const runGuards = (guards, req, res) => guards.every((guard) => {
let passed = false;
guard(req, res, () => { passed = true; });
return passed;
});
// Fetch all segments in parallel. A failed segment is dropped rather than
// failing the whole response — cleanUpResponseData / filterData already
// tolerate sparse data (small ASNs), so partial results degrade to missing
// fields. Logging is the handler's job: it warns on partial failure and
// errors only when every segment failed.
const getAllASNData = async (asn) => {
const names = Object.keys(SEGMENTS);
const settled = await Promise.allSettled(names.map((name) => fetchFromCloudflare(SEGMENTS[name](asn))));
const data = {};
const failed = [];
settled.forEach((result, i) => {
if (result.status === 'fulfilled') {
data[names[i]] = result.value;
} else {
failed.push({ name: names[i], reason: result.reason });
}
});
return { data, failed };
};
// Validate asn is valid
function isValidASN(asn) {
return /^[0-9]+$/.test(asn);
};
// Clean up Cloudflare Radar return data to uniform field names.
// Optional-chaining everywhere because CF Radar returns sparse data for
// small / private / new ASNs (e.g. AS64512 is in the RFC 6996 private range
// and has no info at all; many smaller ASNs have asn info but no traffic
// summaries). Missing fields fall through as undefined and get stripped
// downstream in filterData via the NaN check.
function cleanUpResponseData(data) {
return {
asnName: data.asnInfo?.result?.asn?.name,
asnCountryCode: data.asnInfo?.result?.asn?.country,
asnOrgName: data.asnInfo?.result?.asn?.orgName,
estimatedUsers: data.asnInfo?.result?.asn?.estimatedUsers?.estimatedUsers,
IPv4_Pct: data.ipVersion?.result?.summary_0?.IPv4,
IPv6_Pct: data.ipVersion?.result?.summary_0?.IPv6,
HTTP_Pct: data.httpProtocol?.result?.summary_0?.http,
HTTPS_Pct: data.httpProtocol?.result?.summary_0?.https,
Desktop_Pct: data.deviceType?.result?.summary_0?.desktop,
Mobile_Pct: data.deviceType?.result?.summary_0?.mobile,
Bot_Pct: data.botType?.result?.summary_0?.bot,
Human_Pct: data.botType?.result?.summary_0?.human
};
}
// Format output
function formatData(data) {
const { asnName, asnCountryCode, asnOrgName, estimatedUsers, IPv4_Pct, IPv6_Pct, HTTP_Pct, HTTPS_Pct, Desktop_Pct, Mobile_Pct, Bot_Pct, Human_Pct } = data;
const formattedData = {
asnName,
asnCountryCode,
asnOrgName,
estimatedUsers: parseFloat(estimatedUsers).toLocaleString(),
IPv4_Pct: `${parseFloat(IPv4_Pct).toFixed(2)}%`,
IPv6_Pct: `${parseFloat(IPv6_Pct).toFixed(2)}%`,
HTTP_Pct: `${parseFloat(HTTP_Pct).toFixed(2)}%`,
HTTPS_Pct: `${parseFloat(HTTPS_Pct).toFixed(2)}%`,
Desktop_Pct: `${parseFloat(Desktop_Pct).toFixed(2)}%`,
Mobile_Pct: `${parseFloat(Mobile_Pct).toFixed(2)}%`,
Bot_Pct: `${parseFloat(Bot_Pct).toFixed(2)}%`,
Human_Pct: `${parseFloat(Human_Pct).toFixed(2)}%`
};
return formattedData;
}
// Filter out non-existent fields
function filterData(data) {
for (const key in data) {
if (data[key] === 'NaN' || data[key] === 'NaN%') {
delete data[key];
}
}
return data;
}
// Export function
export default async (req, res) => {
const asn = req.query.asn;
if (!asn) {
return res.status(400).json({ error: 'No ASN provided' });
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
if (!isValidASN(asn)) {
return res.status(400).json({ error: 'Invalid ASN' });
const viewName = req.query.view;
if (!viewName) {
return res.status(400).json({ error: 'No view provided' });
}
const view = RADAR_VIEWS[viewName];
if (!view) {
return res.status(400).json({ error: 'Invalid view' });
}
if (!runGuards(view.guards, req, res)) return;
if (!hasRadarApiKey()) {
return res.status(500).json({ error: 'API key is missing' });
}
try {
const { data, failed } = await getAllASNData(asn);
// One log line per request: a full wipe-out is a real failure
if (failed.length === Object.keys(SEGMENTS).length) {
logger.error({ err: failed[0].reason, asn }, 'cf-radar: all Radar segments failed');
return res.status(500).json({ error: 'Internal server error' });
}
if (failed.length > 0) {
logger.warn({ err: failed[0].reason, asn, segments: failed.map((f) => f.name) }, 'cf-radar: partial Radar segment failure');
}
const cleanedResponse = cleanUpResponseData(data);
const finalResponse = formatData(cleanedResponse);
filterData(finalResponse);
res.json(finalResponse);
res.json(await view.fetch(req.query));
} catch (error) {
logger.error({ err: error, asn }, 'cf-radar handler failed');
logger.error({ err: error, view: viewName }, 'cf-radar view failed');
res.status(500).json({ error: 'Internal server error' });
}
}
};
+58
View File
@@ -0,0 +1,58 @@
// api/data/dns-resolvers.js — the curated resolver list behind /api/dnsresolver.
//
// 👋 Contributors: this file is THE place to add a public DNS resolver.
// Add one object to DNS_RESOLVERS below; no other backend change is needed
// (the frontend groups results by `country` automatically). Entry shape:
//
// {
// id: 'google', // unique lowercase slug, [a-z0-9-]
// name: 'Google', // display name shown in the UI
// country: 'US', // ISO 3166-1 alpha-2, UPPERCASE
// // ('EU' allowed for EU-wide services)
// udp: '8.8.8.8', // optional — public UDP resolver IPv4
// doh: 'https://dns.google/resolve?', // optional — DoH JSON endpoint prefix
// }
//
// Rules (enforced by tests/dns-resolvers-data.test.js — run `pnpm test`):
// - At least one of `udp` / `doh` per entry.
// - `doh` must be an https:// URL prefix ending in '?' or '&' — the handler
// appends `name=<host>&type=<type>` directly, so a prefix that already
// carries query params ends in '&' (see Cloudflare's `ct=…&`). The endpoint
// must speak the DNS-over-HTTPS JSON API (`Accept: application/dns-json`),
// not just RFC 8484 wire format.
// - `country` is where the operator is based (headquarters), not where the
// anycast nodes are.
// - VERIFY THE RESOLVER ACTUALLY ANSWERS before opening a PR — a documented
// IP is not enough. Query it from a machine outside the operator's country
// and confirm you get an answer with the `ra` flag set. Services get shut
// down and open resolvers get restricted to their own subscribers without
// the published docs ever being updated.
//
// ⚠️ Keep this list curated, not exhaustive: EVERY resolver here adds one
// parallel upstream query per protocol to EVERY /api/dnsresolver request.
// Prefer well-known, stable, globally reachable services — and resolvers
// from countries not yet represented over a fifth US entry.
//
// Result ordering is stable: data-file order, and for providers with both
// protocols the udp lookup comes right before the doh one.
export const DNS_RESOLVERS = [
{ id: 'google', name: 'Google', country: 'US', udp: '8.8.8.8', doh: 'https://dns.google/resolve?' },
{ id: 'cloudflare', name: 'Cloudflare', country: 'US', udp: '1.1.1.1', doh: 'https://cloudflare-dns.com/dns-query?ct=application/dns-json&' },
{ id: 'opendns', name: 'OpenDNS', country: 'US', udp: '208.67.222.222' },
{ id: 'controld', name: 'ControlD', country: 'CA', udp: '76.76.2.0' },
{ id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' },
{ id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' },
{ id: 'quad9', name: 'Quad9', country: 'CH', udp: '9.9.9.9' },
{ id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' },
{ id: 'adguard', name: 'AdGuard', country: 'CY', udp: '94.140.14.14', doh: 'https://dns.adguard.com/resolve?' },
{ id: 'yandex', name: 'Yandex.DNS', country: 'RU', udp: '77.88.8.8' },
{ id: 'skydns', name: 'SkyDNS', country: 'RU', udp: '193.58.251.251' },
{ id: 'alidns', name: 'AliDNS', country: 'CN', udp: '223.5.5.5', doh: 'https://dns.alidns.com/resolve?' },
{ id: 'dnspod', name: 'DNSPod', country: 'CN', udp: '119.29.29.29' },
{ id: '114dns', name: '114DNS', country: 'CN', udp: '114.114.114.114' },
{ id: 'hinet', name: 'HiNet', country: 'TW', udp: '168.95.1.1' },
{ id: 'giga', name: 'GIGA', country: 'TW', udp: '203.133.1.6' },
{ id: 'kt', name: 'KT', country: 'KR', udp: '168.126.63.1' },
{ id: 'thains', name: 'ThaiNS', country: 'TH', udp: '203.159.77.77' },
];
+7 -6
View File
@@ -2,15 +2,14 @@
//
// - validates the 32-hex token locally (fail fast before any network hop),
// - forwards request headers (notably Authorization: Bearer <Firebase ID>),
// - attaches the apikey query param,
// - attaches the apikey query param and forwards ?lang untouched,
// - passes the upstream status + JSON back to the caller verbatim so the
// frontend can surface "Sign in required" / "Invalid token" etc.
//
// GET /api/dnsleaktest/session/:token
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import { pickLang } from '../common/langs.js';
import logger from '../server/logger.js';
const TOKEN_RE = /^[0-9a-f]{32}$/;
@@ -30,11 +29,13 @@ export async function getSessionResult(req, res) {
return res.status(500).json({ error: 'API key is missing' });
}
const lang = pickLang(req.query.lang, 'zh-CN');
const url = new URL(`${apiEndpoint}/dnsleaktest/session/${token}`);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('lang', lang);
// The upstream resolves any tag onto the languages it has, so the caller's
// value rides along untouched; omitting it lets the upstream default apply.
if (typeof req.query.lang === 'string' && req.query.lang) {
url.searchParams.set('lang', req.query.lang);
}
try {
const apiResponse = await fetchUpstream(url, {
+124 -72
View File
@@ -1,8 +1,11 @@
// api/dnsresolver.js
import { Resolver } from 'dns';
import { promisify } from 'util';
// api/dns-resolver.js — GET /api/dnsresolver: resolve a hostname against every
// resolver in api/data/dns-resolvers.js (UDP DNS + DoH) in parallel and return
// one flat, country-annotated result list the frontend groups by country.
import { Resolver } from 'node:dns/promises';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
import { DNS_RESOLVERS } from './data/dns-resolvers.js';
import { NAME_VALUED_TYPES } from '../common/dns-record-types.js';
// Bound each upstream lookup so the slowest server doesn't pin the
// overall response. 3s for UDP DNS (`Resolver` rejects on first
@@ -10,98 +13,140 @@ import logger from '../common/logger.js';
// override.
const DNS_TIMEOUT_MS = 3000;
const DOH_TIMEOUT_MS = 5000;
const DNS_AVAILABILITY_ERRORS = new Set(['ETIMEOUT', 'ECONNREFUSED', 'EREFUSED']);
// Normal DNS server list
const dnsServers = {
'Google': '8.8.8.8',
'Cloudflare': '1.1.1.1',
'OpenDNS': '208.67.222.222',
'Quad9': '9.9.9.9',
'ControlD': '76.76.2.0',
'AdGuard': '94.140.14.14',
'AliDNS': '223.5.5.5',
'DNSPod': '119.29.29.29',
'114DNS': '114.114.114.114',
'DNS4EU': '86.54.11.1',
const logDnsFailure = (error, server, provider) => {
// warn+ mirrors to telemetry and a DNS err.message carries the queried
// hostname, so the availability branch logs the code alone; the local-only
// debug branch keeps the full error.
if (DNS_AVAILABILITY_ERRORS.has(error?.code)) {
logger.warn({ server, provider, code: error?.code }, 'DNS resolver: availability lookup failed, returning N/A');
return;
}
logger.debug({ err: error, server, provider, code: error?.code }, 'DNS resolver: lookup failed, returning N/A');
};
// DNS-over-HTTPS server list
const dohServers = {
'Google': 'https://dns.google/resolve?',
'Cloudflare': 'https://cloudflare-dns.com/dns-query?ct=application/dns-json&',
'AdGuard': 'https://dns.adguard.com/resolve?',
'AliDNS': 'https://dns.alidns.com/resolve?',
// Node's resolveSoa strips the trailing root dot from both names; the DoH JSON
// path returns them in presentation form. Re-add them so the two rows a single
// provider contributes read identically — the MX branch below does the same.
export const formatSoaRecord = (record) => [
`${record.nsname}.`,
`${record.hostmaster}.`,
record.serial,
record.refresh,
record.retry,
record.expire,
record.minttl,
].join(' ');
// Node returns each CAA record as { critical, type: 'CAA', <tag>: value }, so
// the tag is whichever key is neither piece of metadata. Reading it that way
// renders a provider-specific tag as itself instead of dropping it.
const CAA_META_KEYS = new Set(['critical', 'type']);
export const formatCaaRecords = (records) => records.flatMap((record) => {
const tagged = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key));
if (!tagged) return [];
const [tag, value] = tagged;
return `${record.critical ?? 0} ${tag} ${JSON.stringify(value)}`;
}).join(', ');
// Both transports run name-valued answers through this: Node's resolver
// returns `dns.google`, a DoH endpoint returns `dns.google.`, and that lone
// dot would read as two providers disagreeing.
export const withRootDot = (name) => (name.endsWith('.') ? name : `${name}.`);
// DNS numeric type for SOA, used to pick the zone's SOA out of a DoH authority
// section (see dohRecords).
const SOA_RECORD_TYPE = 6;
// The records a DoH envelope actually answers with. A SOA query for a name
// below the zone apex carries the zone's own SOA in the authority section
// instead, so fall back to it — otherwise any hostname that isn't itself a zone
// reports N/A on every DoH row. SOA answers are filtered by type because a
// CNAME name puts the chain in Answer with the SOA in Authority, and the CNAME
// target must not render as the SOA result.
export const dohRecords = (data, type) => {
if (type !== 'SOA') return data.Answer ?? [];
const answers = (data.Answer ?? []).filter((record) => record.type === SOA_RECORD_TYPE);
if (answers.length) return answers;
return (data.Authority ?? []).filter((record) => record.type === SOA_RECORD_TYPE);
};
// Resolve via classic UDP DNS. Returns the raw result value: an array of
// strings, a formatted record string, or 'N/A' on empty/failure.
const resolveDns = async (hostname, type, name, server) => {
const resolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 });
resolver.setServers([server]);
const resolve4Async = promisify(resolver.resolve4.bind(resolver));
const resolve6Async = promisify(resolver.resolve6.bind(resolver));
const resolveTxtAsync = promisify(resolver.resolveTxt.bind(resolver));
const resolveCnameAsync = promisify(resolver.resolveCname.bind(resolver));
const resolveNSAsync = promisify(resolver.resolveNs.bind(resolver));
const resolveMXAsync = promisify(resolver.resolveMx.bind(resolver));
try {
let addresses;
// Select different parsing methods based on the type parameter
switch (type) {
case 'A':
addresses = await resolve4Async(hostname);
addresses = await resolver.resolve4(hostname);
break;
case 'AAAA':
addresses = await resolve6Async(hostname);
addresses = await resolver.resolve6(hostname);
break;
case 'TXT':
addresses = await resolveTxtAsync(hostname);
addresses = await resolver.resolveTxt(hostname);
// TXT record parsing results is a two-dimensional array, here we flatten the result
addresses = addresses.flat();
break;
case 'CNAME':
addresses = await resolveCnameAsync(hostname);
addresses = await resolver.resolveCname(hostname);
break;
case 'NS':
addresses = await resolveNSAsync(hostname);
addresses = await resolver.resolveNs(hostname);
break;
case 'MX':
addresses = await resolveMXAsync(hostname);
addresses = await resolver.resolveMx(hostname);
addresses = addresses.map(item => `${item.priority} ${item.exchange}.`)
.join(', ');
break;
case 'SOA':
addresses = formatSoaRecord(await resolver.resolveSoa(hostname));
break;
case 'CAA':
addresses = formatCaaRecords(await resolver.resolveCaa(hostname));
break;
default:
throw new Error('Unsupported type');
}
if (NAME_VALUED_TYPES.has(type)) addresses = addresses.map(withRootDot);
if (addresses.length === 0 || addresses === '' || addresses === null) {
return { [name]: `N/A` };
return 'N/A';
}
return { [name]: addresses };
return addresses;
} catch (error) {
// Per-server timeouts are expected (some DNS hosts are unreachable
// from a given network); demote to debug so they don't spam the
// terminal during normal operation.
logger.debug({ err: error, server: name }, 'DNS resolver: lookup failed, returning N/A');
return { [name]: `N/A` };
logDnsFailure(error, server, name);
return 'N/A';
}
};
// Resolve via the DNS-over-HTTPS JSON API. Same return semantics as
// resolveDns. `url` is a prefix ending in '?' or '&' (see the data file).
const resolveDoh = async (hostname, type, name, url) => {
try {
const response = await fetchUpstream(`${url}name=${hostname}&type=${type}`, {
timeoutMs: DOH_TIMEOUT_MS,
headers: { 'Accept': 'application/dns-json' }
});
const data = await response.json();
const addresses = data.Answer ? data.Answer.map(answer => answer.data) : ['N/A'];
if (addresses.length === 0 || addresses === '' || addresses === null) {
return { [name]: `N/A` };
if (!response.ok) {
logger.warn({ server: name, code: response.status }, 'DoH resolver: upstream returned a non-2xx response');
return 'N/A';
}
return { [name]: addresses };
const records = dohRecords(await response.json(), type);
if (records.length === 0) return 'N/A';
const addresses = records.map((record) => record.data);
return NAME_VALUED_TYPES.has(type) ? addresses.map(withRootDot) : addresses;
} catch (error) {
logger.debug({ err: error, server: name }, 'DoH resolver: lookup failed, returning N/A');
return { [name]: `N/A` };
logger.warn({ err: error, server: name, code: error?.code }, 'DoH resolver: lookup failed, returning N/A');
return 'N/A';
}
};
@@ -113,37 +158,44 @@ const dnsResolver = async (req, res) => {
return res.status(405).json({ message: 'Method Not Allowed' });
}
// Hostname presence, shape and lowercasing are guaranteed by requireValidDomain.
const { hostname, type } = req.query;
if (typeof hostname !== 'string') {
return res.status(400).send({ error: 'Hostname parameter must be a string' });
}
if (!hostname) {
return res.status(400).send({ error: 'Missing hostname parameter' });
}
if (!hostname.includes('.')) {
return res.status(400).send({ error: 'Invalid hostname' });
}
const dnsPromises = Object.entries(dnsServers).map(([name, ip]) => resolveDns(hostname, type, name, ip));
const dohPromises = Object.entries(dohServers).map(([name, url]) => resolveDoh(hostname, type, name, url));
// One lookup task per entry × protocol, in stable order: data-file order,
// udp before doh within a provider. Each task resolves to one row of the
// response; failures collapse to result 'N/A' inside the resolvers, so
// Promise.all never rejects here.
const lookups = DNS_RESOLVERS.flatMap((server) => {
const tasks = [];
if (server.udp) {
tasks.push(resolveDns(hostname, type, server.name, server.udp).then((result) => ({
id: server.id,
provider: server.name,
country: server.country,
type: 'udp',
result,
})));
}
if (server.doh) {
tasks.push(resolveDoh(hostname, type, server.name, server.doh).then((result) => ({
id: server.id,
provider: server.name,
country: server.country,
type: 'doh',
result,
})));
}
return tasks;
});
try {
// Execute all DNS and DoH queries in parallel
const result_dns = await Promise.all(dnsPromises);
const result_doh = await Promise.all(dohPromises);
res.json({
hostname,
result_dns,
result_doh
});
const results = await Promise.all(lookups);
res.json({ hostname, results });
} catch (error) {
logger.error({ err: error }, 'DNS resolver handler failed');
res.status(500).send({ error: error.message });
}
};
export { resolveDns, resolveDoh };
export default dnsResolver;
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
const key = process.env.IPCHECKING_API_KEY;
+21 -2
View File
@@ -1,7 +1,11 @@
// /api/whois — registry record for an IP, a domain or an ASN. IPs and ASNs
// go to RIR RDAP, domains to port-43 WHOIS with an RDAP fallback; every
// path answers with a WHOIS-like `__raw` text block for the Whois tool.
import whoiser from 'whoiser';
import { isValidIP, isUsablePublicIP } from '../common/valid-ip.js';
import { rdapDomain, rdapIp } from '../common/rdap.js';
import logger from '../common/logger.js';
import { rdapDomain, rdapIp, rdapAutnum, isAutnumMissing } from '../server/rdap.js';
import logger from '../server/logger.js';
function isValidDomain(domain) {
const domainPattern = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
@@ -26,6 +30,21 @@ export default async (req, res) => {
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'No address provided' });
}
// ASN path: RDAP autnum only — no port-43 fallback. The route's
// normalizeAsnQuery guard has already canonicalized the query to AS<n>.
const asnMatch = /^AS(\d+)$/.exec(query);
if (asnMatch) {
try {
return res.json(await rdapAutnum(Number(asnMatch[1])));
} catch (e) {
if (isAutnumMissing(e)) {
return res.status(404).json({ error: e.message });
}
logger.error({ err: e, query }, 'Failed to get RDAP autnum info');
return res.status(500).json({ error: e.message });
}
}
if (!isValidIP(query) && !isValidDomain(query)) {
return res.status(400).json({ error: 'Invalid IP or address' });
}
+1 -1
View File
@@ -4,7 +4,7 @@
// Cloudflare the origin hits GitHub at most once per cache window — well under
// the 60 req/hour unauthenticated limit.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// The project's own repository. GitHub requires a User-Agent on every
// request; fetchUpstream's default project UA satisfies that.
+2 -2
View File
@@ -5,8 +5,8 @@
// bytes, probe-country coverage changes slowly.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { buildProbeInventory } from '../common/globalping-inventory.js';
import logger from '../common/logger.js';
import { buildProbeInventory } from '../server/globalping-inventory.js';
import logger from '../server/logger.js';
const GLOBALPING_PROBES_URL = 'https://api.globalping.io/v1/probes';
+9 -4
View File
@@ -1,13 +1,18 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// Validate request legitimacy. `language` is the UI locale code, region
// subtag included (`pt-BR`, `zh-TW`); like every `?lang` it is not
// allow-listed, but it must be tag-shaped: every distinct value is an
// uncached Static Maps call whose answer is edge-cached for a year.
const LANGUAGE_TAG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,4}){0,2}$/;
// Validate request legitimacy
function isValidRequest(req) {
const isLatitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.latitude);
const isLongitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.longitude);
const isLanguageValid = /^[a-z]{2}$/.test(req.query.language);
const isLanguageValid = typeof req.query.language === 'string' && LANGUAGE_TAG.test(req.query.language);
if (!isLatitudeValid || !isLongitudeValid || !isLanguageValid) {
return false;
@@ -69,7 +74,7 @@ export default async (req, res) => {
styleParam = styles.Dark.join('&style=');
}
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&markers=color:blue%7C${latitude},${longitude}&scale=${scale}&zoom=${zoom}&maptype=roadmap&language=${language}&format=${fmt}&size=${mapSize}&style=${styleParam}&key=${apiKey}`;
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&markers=color:blue%7C${latitude},${longitude}&scale=${scale}&zoom=${zoom}&maptype=roadmap&language=${encodeURIComponent(language)}&format=${fmt}&size=${mapSize}&style=${styleParam}&key=${apiKey}`;
// Unlike the JSON handlers, the Static Maps response is a binary JPEG
// and must be passed through to the client. pipeline() propagates errors
+11 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// If length is not 28 and is not a combination of letters and numbers, return false
function isValidUserID(userID) {
@@ -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) {
+53
View File
@@ -0,0 +1,53 @@
// GET /api/ipblocklist?ip= — which blocklists an IP is on, from the private
// IPCheck.ing API (`/iphitlist`: live DNSBL lookups plus static threat lists).
// `?ip` is validated by requirePublicIP(). Sign-in is enforced upstream, so
// the caller's headers (auth token) are forwarded as-is.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../server/logger.js';
// A cold DNSBL pass queries dozens of zones under per-zone deadlines; well
// past the default 8s, and the frontend waits a little longer than this.
export const IP_BLOCKLIST_TIMEOUT_MS = 30000;
export default async (req, res) => {
const apiKey = process.env.IPCHECKING_API_KEY;
const apiEndpoint = process.env.IPCHECKING_API_ENDPOINT;
if (!apiKey || !apiEndpoint) {
return res.status(500).json({ error: 'API key is missing' });
}
const url = new URL(`${apiEndpoint}/iphitlist`);
url.searchParams.set('ip', req.query.ip);
url.searchParams.set('key', apiKey);
try {
const apiResponse = await fetchUpstream(url, {
headers: { ...req.headers },
timeoutMs: IP_BLOCKLIST_TIMEOUT_MS,
});
// Monthly quota exhausted: not a failure — pass status + code through
// so the frontend shows the quota hint.
if (apiResponse.status === 429) {
const errorData = await apiResponse.json().catch(() => ({}));
return res.status(429).json({ error: errorData.error || 'Monthly quota exceeded', code: 'quota_exceeded' });
}
// Sign-in problems are the visitor's state, not a failure: pass the
// status through so the frontend prompts sign-in.
if (apiResponse.status === 401 || apiResponse.status === 403) {
const errorData = await apiResponse.json().catch(() => ({}));
return res.status(apiResponse.status).json({ error: errorData.message || 'Sign in required' });
}
if (!apiResponse.ok) {
throw new Error(`API responded with status: ${apiResponse.status}`);
}
res.json(await apiResponse.json());
} catch (error) {
logger.error({ err: error, ip: req.query.ip }, 'ip-blocklist upstream request failed');
res.status(500).json({ error: error.message });
}
};
+1 -1
View File
@@ -2,7 +2,7 @@
// Token-free upstream; normalizes the response into the canonical geo
// shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Picks a random API key and normalizes the response into the canonical
// geo shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Forwards the optional ?lang and normalizes the response into the
// canonical geo shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Picks a random API key and normalizes the response into the canonical
// geo shape (plus isHosting / isProxy) via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
// Presence, validity and public routability guaranteed by the
+30 -15
View File
@@ -2,8 +2,8 @@
// Picks a random API token (when configured) and normalizes the response
// into the canonical geo shape via the shared makeGeoHandler factory.
import countryLookup from 'country-code-lookup';
import { makeGeoHandler } from '../common/geo-handler.js';
import getCountryName from '../common/country-name.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
@@ -19,27 +19,42 @@ function buildUrl(req) {
return token ? url_hasToken : url_noToken;
}
function modifyJson(json) {
const { ip, city, region, country, loc, org } = json;
// Parse one half of `loc`. Blank and non-numeric halves become null rather
// than the 0 Number() would hand back — 0 is a real coordinate.
const toCoordinate = (raw) => {
const value = Number(raw);
return raw?.trim() && Number.isFinite(value) ? value : null;
};
const countryName = countryLookup.byIso(country).country || 'Unknown Country';
// Every field is optional upstream: anycast ranges, bogons ({"bogon":true})
// and degraded answers all come back 200 with `loc`, `org` or `country`
// missing, so nothing here may be split or dereferenced unguarded. Absent
// data degrades to null / empty while the canonical shape stays complete.
export const modifyJson = (json) => {
const { ip, city, region, country, loc, org } = json || {};
const [latitude, longitude] = loc.split(',').map(Number);
const [asn, ...orgName] = org.split(' ');
const modifiedOrg = orgName.join(' ');
const countryName = getCountryName(country, 'en') || 'Unknown Country';
// "37.4056,-122.0775" — a partial or non-numeric pair degrades to null.
const [rawLat, rawLon] = typeof loc === 'string' ? loc.split(',') : [];
const latitude = toCoordinate(rawLat);
const longitude = toCoordinate(rawLon);
// "AS15169 Google LLC" — leading token is the ASN, the rest the org name.
const [asn = '', ...orgName] = typeof org === 'string' ? org.split(' ') : [];
return {
ip,
city,
region,
country,
ip: ip ?? null,
city: city ?? null,
region: region ?? null,
country: country ?? null,
country_name: countryName,
country_code: country,
country_code: country ?? null,
latitude,
longitude,
asn,
org: modifiedOrg
org: orgName.join(' ')
};
}
};
export default makeGeoHandler({ name: 'ipinfo-io', buildUrl, normalize: modifyJson });
+19 -58
View File
@@ -1,68 +1,29 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
// /api/macchecker?mac= — MAC Lookup against the local IEEE registries
// (server/datasets/oui-db.js). `mac` is a full address or a vendor prefix of 6–12
// hex digits (common/mac-input.js). The answer names the block that covers
// it, its range and the first-octet flags; an address no block covers is
// still a 200 with `found: false`.
// A canonical MAC address is 48 bits = 12 hex chars. Accepting shorter
// strings let the upstream API receive partial prefixes and return
// undefined behavior; tighten to exactly 12 hex chars.
const isValidMAC = (address) => {
const normalizedAddress = address.replace(/[:-]/g, '');
return normalizedAddress.length === 12 && /^[0-9A-Fa-f]+$/.test(normalizedAddress);
}
import { normalizeMacQuery } from '../common/mac-input.js';
import { lookupMac } from '../server/datasets/oui-db.js';
export default async (req, res) => {
// Get IP address from request
let macAddress = req.query.mac;
if (!macAddress) {
// `lookup` is injectable so tests can run the handler over fixture registries.
export const createMacChecker = (lookup) => async (req, res) => {
const { mac } = req.query;
if (!mac) {
return res.status(400).json({ error: 'No MAC address provided' });
} else {
macAddress = macAddress.replace(/:/g, '').replace(/-/g, '');
}
// Check if address is valid
if (!isValidMAC(macAddress)) {
const hex = normalizeMacQuery(String(mac));
if (!hex) {
return res.status(400).json({ error: 'Invalid MAC address' });
}
const token = process.env.MAC_LOOKUP_API_KEY || '';
const url_hasToken = `https://api.maclookup.app/v2/macs/${macAddress}?apiKey=${token}`;
const url_noToken = `https://api.maclookup.app/v2/macs/${macAddress}`;
const url = token ? url_hasToken : url_noToken;
try {
const apiRes = await fetchUpstream(url);
const json = await apiRes.json();
if (json.success !== true) {
return res.json({ success: false, error: json.error || 'Data not found' });
}
res.json(modifyData(json));
} catch (e) {
logger.error({ err: e, mac: macAddress }, 'mac-checker handler failed');
res.status(500).json({ error: e.message });
// Past the boot window (requireOfflineData) only a failed download leaves this empty.
const result = lookup(hex);
if (!result) {
return res.status(503).json({ error: 'MAC database unavailable' });
}
res.json(result);
};
const modifyData = (data) => {
// Check if address is unicast/multicast and local/global
const firstByte = parseInt(data.macPrefix.substring(0, 2), 16);
const isMulticast = (firstByte & 0x01) === 0x01;
const isLocal = (firstByte & 0x02) === 0x02;
data.isMulticast = isMulticast ? true : false;
data.isLocal = isLocal ? true : false;
data.isGlobal = !isLocal ? true : false;
data.isUnicast = !isMulticast ? true : false;
data.macPrefix = data.macPrefix ? data.macPrefix.match(/.{1,2}/g).join(':') : 'N/A';
data.company = data.company ? data.company : 'N/A';
data.country = data.country ? data.country : 'N/A';
data.address = data.address ? data.address : 'N/A';
data.updated = data.updated ? data.updated : 'N/A';
data.blockStart = data.blockStart ? data.blockStart.match(/.{1,2}/g).join(':') : 'N/A';
data.blockEnd = data.blockEnd ? data.blockEnd.match(/.{1,2}/g).join(':') : 'N/A';
data.blockSize = data.blockSize ? data.blockSize : 'N/A';
data.blockType = data.blockType ? data.blockType : 'N/A';
return data;
}
export default createMacChecker(lookupMac);
+5 -5
View File
@@ -1,14 +1,14 @@
import { lookupMaxMind } from '../common/maxmind-service.js';
import logger from '../common/logger.js';
import { pickLang } from '../common/langs.js';
import { lookupMaxMind } from '../server/datasets/maxmind-service.js';
import logger from '../server/logger.js';
export default (req, res) => {
// Presence, validity and public routability guaranteed by the
// requirePublicIP middleware — a reserved address never reaches here.
const ip = req.query.ip;
// Get request language
const lang = pickLang(req.query.lang, 'en');
// The raw tag goes straight in: the service owns the language set and
// normalizes onto it.
const lang = req.query.lang;
try {
res.json(lookupMaxMind(ip, lang));
-119
View File
@@ -1,119 +0,0 @@
// Handler for /api/outages — global internet outage feed for the Pulse panel.
// Merges two Cloudflare Radar sources into one recent-events list:
// - /radar/annotations/outages (manually verified outages, with a cause)
// - /radar/traffic_anomalies (auto-detected drops, all statuses)
// A verified anomaly is often promoted to an outage entry for the same event,
// so anomalies that match an outage's subject and start time are dropped.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
const DATE_RANGE = '30d';
const MAX_EVENTS = 30;
// An anomaly counts as a duplicate of an outage when they share a subject
// (location or ASN) and start within this window of each other.
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
const fetchFromRadar = async (endpoint) => {
const url = `https://api.cloudflare.com/client/v4${endpoint}`;
const response = await fetchUpstream(url, {
headers: {
'Authorization': `Bearer ${process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Cloudflare Radar responded ${response.status}`);
}
return response.json();
};
// Both kinds normalize to one flat shape; anomaly-only fields stay null on
// outages and vice versa. `endDate: null` means the event is still ongoing.
export const normalizeOutages = (annotations) => annotations.map((entry) => ({
kind: 'outage',
id: `o-${entry.id}`,
startDate: entry.startDate,
endDate: entry.endDate || null,
locations: (entry.locationsDetails || []).map((loc) => loc.code),
asns: (entry.asnsDetails || []).map((detail) => ({ asn: Number(detail.asn), name: detail.name })),
cause: entry.outage?.outageCause || 'UNKNOWN',
level: entry.outage?.outageType || null,
description: entry.description || null,
scope: entry.scope || null,
linkedUrl: entry.linkedUrl || null,
}));
// Anomaly statuses hidden from the feed. Live data carries values beyond the
// documented VERIFIED/UNVERIFIED pair (e.g. TP = third-party confirmed), so
// this is a blocklist, not an allowlist. Empty today — every status ships;
// add 'UNVERIFIED' here to restrict the feed to confirmed events only.
const EXCLUDED_ANOMALY_STATUSES = new Set([]);
export const normalizeAnomalies = (anomalies) => anomalies
.filter((entry) => !EXCLUDED_ANOMALY_STATUSES.has(entry.status))
.map((entry) => ({
kind: 'anomaly',
id: `a-${entry.uuid}`,
startDate: entry.startDate,
endDate: entry.endDate || null,
locations: entry.locationDetails?.code ? [entry.locationDetails.code] : [],
asns: entry.asnDetails?.asn
? [{ asn: Number(entry.asnDetails.asn), name: entry.asnDetails.name }]
: [],
cause: null,
level: null,
description: null,
scope: null,
linkedUrl: null,
}));
export const mergeEvents = (outages, anomalies) => {
const isDuplicate = (anomaly) => outages.some((outage) => {
const sharesSubject = anomaly.locations.some((code) => outage.locations.includes(code))
|| anomaly.asns.some((a) => outage.asns.some((o) => o.asn === a.asn));
if (!sharesSubject) return false;
return Math.abs(Date.parse(anomaly.startDate) - Date.parse(outage.startDate)) <= DEDUPE_WINDOW_MS;
});
// Two-level order, consumed by the frontend as-is: ongoing events first
// (endDate null), ended ones after, newest-first inside each group. The
// cap then favors ongoing events by construction.
return [...outages, ...anomalies.filter((anomaly) => !isDuplicate(anomaly))]
.sort((a, b) => {
const aOngoing = a.endDate === null;
const bOngoing = b.endDate === null;
if (aOngoing !== bOngoing) return aOngoing ? -1 : 1;
return Date.parse(b.startDate) - Date.parse(a.startDate);
})
.slice(0, MAX_EVENTS);
};
export default async (req, res) => {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
if (!process.env.CLOUDFLARE_API_KEY && !process.env.CLOUDFLARE_API) {
return res.status(500).json({ error: 'API key is missing' });
}
// One failed source degrades to a partial feed; both failing is an error.
const [outagesResult, anomaliesResult] = await Promise.allSettled([
fetchFromRadar(`/radar/annotations/outages?dateRange=${DATE_RANGE}&limit=50`),
fetchFromRadar(`/radar/traffic_anomalies?dateRange=${DATE_RANGE}&limit=100`),
]);
if (outagesResult.status === 'rejected' && anomaliesResult.status === 'rejected') {
logger.error({ err: outagesResult.reason }, 'net-outages: both Radar sources failed');
return res.status(500).json({ error: 'Internal server error' });
}
if (outagesResult.status === 'rejected' || anomaliesResult.status === 'rejected') {
const failed = outagesResult.status === 'rejected' ? outagesResult : anomaliesResult;
logger.warn({ err: failed.reason }, 'net-outages: one Radar source failed');
}
const outages = normalizeOutages(
outagesResult.status === 'fulfilled' ? outagesResult.value?.result?.annotations || [] : []);
const anomalies = normalizeAnomalies(
anomaliesResult.status === 'fulfilled' ? anomaliesResult.value?.result?.trafficAnomalies || [] : []);
res.json({ events: mergeEvents(outages, anomalies) });
};
+3 -3
View File
@@ -1,7 +1,7 @@
// /api/ooni-blocking — per-country blocking view for a domain over the last
// 30 days, built from OONI's open aggregation API (web_connectivity test,
// probe_cc × blocking_type axes). Powers the default view of the frontend
// CensorshipCheck tool; classification lives in common/ooni-blocking.js.
// CensorshipCheck tool; classification lives in server/ooni-blocking.js.
//
// OONI matches `domain` against the exact tested hostname, so a single query
// misses most of the dataset (e.g. `bbc.com` has ~6 rows while `www.bbc.com`
@@ -13,8 +13,8 @@
// `cacheable()` and CF absorbs repeat lookups of popular domains.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { classifyOoniCountries, OONI_WINDOW_DAYS } from '../common/ooni-blocking.js';
import logger from '../common/logger.js';
import { classifyOoniCountries, OONI_WINDOW_DAYS } from '../server/ooni-blocking.js';
import logger from '../server/logger.js';
const OONI_AGGREGATION_URL = 'https://api.ooni.io/api/v1/aggregation';
+57
View File
@@ -0,0 +1,57 @@
// POST /api/persona/evaluate — Persona Check.
//
// Thin proxy to the main IPCheck.ing API, which owns the evaluation this
// deployment deliberately does not carry. One observation in, one graded
// report out; per-visitor, never cached. The caller's headers travel
// upstream (the evaluator needs the request context); results come back as
// ids and enums, and the front end renders its own four-language copy.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../server/logger.js';
// Headers that describe *this* hop rather than the caller, dropped before the
// request is rebuilt.
const HOP_HEADERS = ['host', 'content-length', 'content-type', 'connection', 'transfer-encoding'];
const callerHeaders = (req) => {
const headers = { ...req.headers };
for (const name of HOP_HEADERS) delete headers[name];
return headers;
};
export default async (req, res) => {
// Defensive method gate (the route is POST-only) — covered by tests.
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
const apiKey = process.env.IPCHECKING_API_KEY;
const apiEndpoint = process.env.IPCHECKING_API_ENDPOINT;
if (!apiKey || !apiEndpoint) {
return res.status(500).json({ error: 'API key is missing' });
}
if (!req.body?.persona?.country) {
return res.status(400).json({ error: 'No persona provided' });
}
const url = new URL(`${apiEndpoint}/persona/evaluate`);
url.searchParams.set('key', apiKey);
try {
const apiResponse = await fetchUpstream(url, {
method: 'POST',
headers: { ...callerHeaders(req), 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});
// Status and payload pass through verbatim so the frontend can tell a
// rejected request from an unreachable evaluator and degrade to its
// error line rather than a blank report.
const data = await apiResponse.json().catch(() => ({}));
return res.status(apiResponse.status).json(data);
} catch (error) {
logger.error({ err: error }, 'persona evaluate upstream fetch failed');
return res.status(502).json({ error: 'Upstream fetch failed' });
}
};
+14 -2
View File
@@ -7,7 +7,7 @@
// only when VITE_SENTRY_DSN_FRONTEND is set.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { isValidIP } from '../common/valid-ip.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// The envelope header is the first newline-delimited JSON line and carries
// the DSN the SDK was configured with. Returns a URL or null. Exported for
@@ -133,7 +133,19 @@ export default async (req, res) => {
headers: { 'Content-Type': 'application/x-sentry-envelope' },
body,
});
res.status(apiRes.status).send(await apiRes.text());
const relayed = await apiRes.text();
if (!apiRes.ok) {
// Ingest refuses an envelope for reasons the browser can do
// nothing about — most often size, since replay recordings travel
// uncompressed here. Without this line the rejection is visible
// only in the visitor's devtools.
logger.warn({
statusCode: apiRes.status,
envelopeBytes: body.length,
bodyPreview: relayed.slice(0, 200),
}, 'sentry tunnel upstream rejected the envelope');
}
res.status(apiRes.status).send(relayed);
} catch (error) {
// warn, not error: a transient relay failure is infra noise, not an
// application defect worth a grouped Issue of its own.
+2 -2
View File
@@ -1,7 +1,7 @@
// /api/service-status — the "Service Status" section's read endpoints.
//
// Both handlers serve slices of the in-memory snapshot maintained by
// common/service-status-store.js (a background timer refreshes every provider
// server/service-status-store.js (a background timer refreshes every provider
// on a fixed 5-minute schedule). Neither touches an upstream — request volume
// has no effect on upstream load. Splitting overview vs per-provider detail
// keeps the initial page load light: the detail (sub-services + incidents) is
@@ -13,7 +13,7 @@
// The detail handler's `id` query param is whitelisted by
// requireValidProviderId() in backend-server.js.
import { getServiceStatusOverview, getProviderDetail } from '../common/service-status-store.js';
import { getServiceStatusOverview, getProviderDetail } from '../server/service-status-store.js';
// Overview: every provider's status light, no heavy detail arrays.
export default async (req, res) => {
+1 -1
View File
@@ -17,7 +17,7 @@
import crypto from 'node:crypto';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { validateReport, REPORT_TTL_DAYS, REPORT_MAX_BYTES } from '../common/report-schema.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// --- env / KV plumbing -------------------------------------------------------
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
// defensive; app.put() in backend-server.js already gates method
+97 -59
View File
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'url';
import { slowDown } from 'express-slow-down'
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import logger from './common/logger.js';
import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId, requireValidReportId } from './common/guards.js';
import { withTimeZone } from './common/ip-timezone.js';
import logger from './server/logger.js';
import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId,
requireValidRecordType, requireValidReportId, normalizeAsnQuery } from './server/guards.js';
import { withTimeZone } from './server/ip-timezone.js';
// Backend APIs
import mapHandler from './api/google-map.js';
@@ -21,8 +22,8 @@ import ip2locationHandler from './api/ip2location-io.js';
import ipsbHandler from './api/ip-sb.js';
import maxmindHandler from './api/maxmind.js';
// Others
import cfHander from './api/cf-radar.js';
import netOutagesHandler from './api/net-outages.js';
import cfRadarHandler from './api/cf-radar.js';
import { RADAR_VIEWS, isCompleteRadarAnswer } from './server/cf-radar.js';
import asnHistoryHandler from './api/asn-history.js';
import asnConnectivityHandler from './api/asn-connectivity.js';
import ooniBlockingHandler from './api/ooni-blocking.js';
@@ -36,17 +37,28 @@ import getWhois from './api/get-whois.js';
import sentryTunnelHandler from './api/sentry-tunnel.js';
import createReportHandler, { getReport as getReportHandler } from './api/share-report.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import ipBlocklistHandler from './api/ip-blocklist.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
import personaEvaluateHandler from './api/persona.js';
import asnProfileHandler from './api/asn-profile.js';
import { isCompleteProfile } from './server/asn-profile.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
import updateUserAchievement from './api/update-user-achievement.js';
import { reloadMaxMindDatabases, startMaxMindFileWatcher } from './common/maxmind-service.js';
import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js';
import { startCaidaAutoUpdate, bootstrapCaidaIfMissing } from './common/caida-updater.js';
import { bootstrapServiceStatus, startServiceStatusPolling } from './common/service-status-store.js';
import { initUpstreamUserAgent } from './common/upstream-ua.js';
import { reloadMaxMindDatabases, isMaxMindReady, getMaxMindDbPaths } from './server/datasets/maxmind-service.js';
import { bootstrapDatasets, startDatasetScheduler, watchDatasets } from './server/datasets/dataset-updater.js';
import { datasets } from './server/datasets/datasets.js';
import {
bootstrapServiceStatus, startServiceStatusPolling, isServiceStatusPrimed,
} from './server/service-status-store.js';
import { isAsRelLoaded } from './server/datasets/as-rel-db.js';
import { isAsOrgLoaded } from './server/datasets/as-org-db.js';
import { isOuiLoaded } from './server/datasets/oui-db.js';
import { runOfflineBootstrap, requireOfflineData, isOfflineBootstrapping } from './server/offline-data.js';
import { cacheable } from './server/cache-control.js';
import { initUpstreamUserAgent } from './server/upstream-ua.js';
dotenv.config({ quiet: true });
initUpstreamUserAgent();
@@ -182,13 +194,14 @@ const rateLimiter = rateLimit({
const speedLimiter = slowDown({
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
skip: (req) => req.path === '/monitoring',
delayMs: (used, req) => (used - req.slowDown.limit) * 400,
maxDelayMs: 5000,
skip: (req) => req.path === '/monitoring' || req.path === '/maxmind',
})
if (rateLimitSet !== 0) {
app.use('/api', rateLimiter);
logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 60 minutes`);
logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 20 minutes`);
}
if (speedLimitSet !== 0) {
@@ -204,32 +217,23 @@ if (speedLimitSet !== 0) {
app.use(express.json({ limit: '500kb' }));
// Default every /api/* response to no-store. Routes that want edge caching
// declare it explicitly via the `cacheable(maxAge)` middleware below.
// declare it explicitly via `cacheable(maxAge)` (server/cache-control.js).
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
// Cache-Control middleware factory. Hooks res.json so the header is only
// attached on 2xx — CF must not cache 4xx/5xx error pages. The intended cache
// value is also stashed on res.locals.cacheControl so binary-stream handlers
// (which bypass res.json) can apply it themselves on their own 2xx path.
const cacheable = (maxAgeSeconds) => (req, res, next) => {
res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`;
const originalJson = res.json.bind(res);
res.json = function (body) {
if (res.statusCode < 400) {
res.setHeader('Cache-Control', res.locals.cacheControl);
}
return originalJson(body);
};
next();
};
// Global referer gate for all /api/* routes. Handlers no longer repeat this
// check individually — see common/guards.js.
// check individually — see server/guards.js.
app.use('/api', requireReferer);
// Readiness gates for routes reading local data (server/offline-data.js):
// 503 while that data is still downloading at boot, a no-op afterwards.
const needsMaxMind = requireOfflineData([isMaxMindReady]);
const needsAsGraph = requireOfflineData([isAsRelLoaded, isAsOrgLoaded]);
const needsOui = requireOfflineData([isOuiLoaded]);
const needsServiceStatus = requireOfflineData([isServiceStatusPrimed]);
const FIVE_MIN_CACHE = 5 * 60;
const ONE_HOUR_CACHE = 60 * 60;
const ONE_DAY_CACHE = 24 * 60 * 60;
@@ -239,22 +243,20 @@ const ONE_YEAR_CACHE = 365 * 24 * 60 * 60;
// Cacheable routes — TTLs picked against each upstream's natural refresh cadence.
// Short Cache
app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);
app.get('/api/service-status', needsServiceStatus, cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
app.get('/api/service-status/detail', requireValidProviderId(), needsServiceStatus, cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);
// Cache for 1 day
app.get('/api/ipinfo', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ipinfoHandler);
app.get('/api/ipapicom', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ipapicomHandler);
app.get('/api/ipsb', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ipsbHandler);
app.get('/api/ipapiis', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ipapiisHandler);
app.get('/api/ip2location', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ip2locationHandler);
app.get('/api/maxmind', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), maxmindHandler);
app.get('/api/whois', cacheable(ONE_DAY_CACHE), getWhois);
app.get('/api/maxmind', requirePublicIP(), needsMaxMind, withTimeZone(), cacheable(ONE_DAY_CACHE), maxmindHandler);
app.get('/api/whois', normalizeAsnQuery(), cacheable(ONE_DAY_CACHE), getWhois);
app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler);
// Feature flags derived from env vars — they only change on a redeploy, so
// an hour of caching is safe.
app.get('/api/configs', cacheable(ONE_HOUR_CACHE), validateConfigs);
// Radar's outage feed moves on an hourly-ish cadence
app.get('/api/outages', cacheable(ONE_HOUR_CACHE), netOutagesHandler);
// OONI aggregates cover a 30-day window aligned to UTC days — the payload
// only drifts as new measurements land, so 1 day of edge cache keeps us polite
// to OONI's free API without the view going meaningfully stale.
@@ -262,26 +264,46 @@ app.get('/api/ooni-blocking', requireValidDomain(), cacheable(ONE_DAY_CACHE), oo
// Which countries have online Globalping probes — coverage changes slowly,
// and the pickers fail open anyway, so a week of edge cache is fine.
app.get('/api/globalping-probes', cacheable(SEVEN_DAYS_CACHE), globalpingProbesHandler);
// ASN Profile aggregate: every source in one answer; reputation inside it is
// not per-user. A complete answer is cached for a week; a degraded one (a
// section in error or incomplete) for a day — an upstream that fails for
// some ASes every time would otherwise send every visit back to all sources.
// Not boot-gated: a section whose local data is still downloading answers
// 'error' on its own (api/asn-profile.js), and nothing degraded is cached
// during that window.
app.get('/api/asn-profile', requireValidASN(), cacheable(SEVEN_DAYS_CACHE, {
cacheIf: isCompleteProfile,
degradedMaxAge: () => (isOfflineBootstrapping() ? 0 : ONE_DAY_CACHE),
}), asnProfileHandler);
// All Cloudflare Radar data rides one route; `?view=` picks the dataset and
// the TTL comes from that view's registry entry (server/cf-radar.js) — 7d
// for the ASN summary and prefix list (same as /api/asn-profile), 30d for
// country traffic, 1h for the outage feed. A partial answer (some upstream
// calls failed, or prefix countries MaxMind couldn't fully work out) is
// served but not cached.
app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl, { cacheIf: isCompleteRadarAnswer }), cfRadarHandler);
// Cache for 30 days — registry / historical data that changes on a monthly
// (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection,
// and append-only BGP routing history.
app.get('/api/cfradar', cacheable(THIRTY_DAYS_CACHE), cfHander);
app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler);
app.get('/api/asn-connectivity', requireValidASN(), cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler);
app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker);
app.get('/api/asn-connectivity', requireValidASN(), needsAsGraph, cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler);
app.get('/api/macchecker', needsOui, cacheable(THIRTY_DAYS_CACHE), macChecker);
// Long Cache
app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler);
// Non-cacheable routes — auth-context, debug tools, or per-request lookups.
app.get('/api/ipchecking', requirePublicIP(), withTimeZone(), ipCheckingHandler);
app.get('/api/dnsresolver', dnsResolver);
app.get('/api/dnsresolver', requireValidDomain('hostname'), requireValidRecordType(), dnsResolver);
app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult);
app.get('/api/invisibility', invisibilitytestHandler);
app.get('/api/ipblocklist', requirePublicIP(), ipBlocklistHandler);
app.get('/api/getuserinfo', getUserinfo);
app.put('/api/updateuserachievement', updateUserAchievement);
// Shared reports stay no-store: an edge cache could serve a report past its
// KV expiry, and private diagnostic data doesn't belong in a public cache.
app.get('/api/report/:id', requireValidReportId(), getReportHandler);
app.post('/api/report', createReportHandler);
// One observation in, one graded report out — per-visitor by definition.
app.post('/api/persona/evaluate', personaEvaluateHandler);
// Sentry tunnel — first-party relay for the frontend SDK's envelopes
// Mounted only when this deployment actually built the frontend with a DSN.
@@ -311,30 +333,46 @@ app.use(express.static(path.join(__dirname, './dist')));
if (process.env.SENTRY_DSN_BACKEND) {
const Sentry = await import('@sentry/node');
Sentry.setupExpressErrorHandler(app);
logger.info('🛰️ Sentry backend monitoring enabled');
logger.info('🛰️ Sentry backend monitoring enabled');
}
// Bootstrap every offline dataset (MaxMind, CAIDA) before accepting traffic
// so we never serve mid-download. Each step is non-fatal: a failure leaves
// the dependent API in a degraded state (MaxMind → 503; CAIDA → empty graph
// or RIPEstat fallback) but doesn't block the listener.
async function bootBackend() {
await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases });
await reloadMaxMindDatabases('startup').catch(() => {
logger.error('❌ MaxMind API will return 503 until databases are loaded successfully');
});
await bootstrapCaidaIfMissing();
await bootstrapServiceStatus();
startMaxMindFileWatcher();
startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases });
startCaidaAutoUpdate();
startServiceStatusPolling();
// Boot sequence. Snapshots already on disk are in memory before the listener
// opens (CAIDA / PeeringDB / IEEE load at import, MaxMind just below), so a
// restart serves at once. Missing ones download behind the listener:
// meanwhile only the routes reading them answer 503 (requireOfflineData
// above). A failed download stays non-fatal — that route then serves degraded
// (MaxMind / IEEE → 503; CAIDA → empty graph, RIPEstat fallback or no
// peering section).
const bootBackend = async () => {
const { cityDbPath, asnDbPath } = getMaxMindDbPaths();
if (fs.existsSync(cityDbPath) && fs.existsSync(asnDbPath)) {
await reloadMaxMindDatabases('startup').catch(() => {});
}
app.listen(backEndPort, () => {
logger.info(`🚀 Backend server ready on http://localhost:${backEndPort}`);
});
}
// Watching from before the downloads: a dataset another process
// publishes (or someone places by hand) while a boot step is still
// running is reloaded, not taken as the watcher's baseline.
watchDatasets(datasets);
await runOfflineBootstrap([
async () => {
await bootstrapDatasets(datasets);
if (isMaxMindReady()) return;
await reloadMaxMindDatabases('startup').catch(() => {
logger.error('❌ MaxMind API will return 503 until databases are available: set MAXMIND_ACCOUNT_ID + '
+ 'MAXMIND_LICENSE_KEY, or drop GeoLite2-City.mmdb + GeoLite2-ASN.mmdb into common/maxmind-db/');
});
},
bootstrapServiceStatus,
]);
startDatasetScheduler(datasets);
startServiceStatusPolling();
};
bootBackend();
+18
View File
@@ -0,0 +1,18 @@
// The AS-number grammar shared by the ASN guards (server/guards.js) and the
// tools that take a typed AS number (Whois, ASN Profile — via the
// frontend/utils/ip/asn-input.js bridge): up to ten digits, AS1 …
// AS4294967295, `AS` prefix case-insensitive.
export const MAX_ASN = 4294967295;
// → the ASN as a number (leading zeros dropped), or null. Surrounding spaces
// are ignored; `requirePrefix` rejects a bare number (the Whois box, where
// digits alone are not an AS query).
export const parseAsnInput = (raw, { requirePrefix = false } = {}) => {
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
const pattern = requirePrefix ? /^AS(\d+)$/i : /^(?:AS)?(\d+)$/i;
const match = pattern.exec(String(raw).trim());
if (!match || match[1].length > 10) return null;
const asn = Number(match[1]);
return asn >= 1 && asn <= MAX_ASN ? asn : null;
};
+1 -1
View File
@@ -10,7 +10,7 @@ const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/;
// Expand an IPv6 address (with optional `::` compression) into 8 hextet
// strings. Returns null on any structural failure. Also consumed by
// common/rdap.js for CIDR containment math.
// server/rdap.js for CIDR containment math.
export function expandIPv6(ip) {
const halves = ip.split('::');
if (halves.length > 2) return null;
-372
View File
@@ -1,372 +0,0 @@
// Vendor-scoped CAIDA auto-updater. Each dataset is a row in `datasets`
// below; the orchestration (lock / state / atomic publish / validation /
// reload) is shared. Mirrors common/maxmind-updater.js's editions-array
// pattern.
//
// Registered datasets:
// - as2org (AS → org-name mapping) common/as-org-db
// - as-rel (AS relationships, p2c) common/as-rel-db
//
// Both share CAIDA_AUTO_UPDATE=true to gate the periodic scheduler
// (off by default); bootstrap always runs so a fresh checkout works.
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import { createDecompressor } from './decompress.js';
import { AS_ORG_DB_DIR, AS_ORG_FILE, reloadAsOrgDatabase } from './as-org-db.js';
import { AS_REL_DB_DIR, AS_REL_FILE, reloadAsRelDatabase } from './as-rel-db.js';
const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const INITIAL_UPDATE_DELAY_MS = 60 * 1000;
const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
const BOOTSTRAP_TIMEOUT_MS = 2 * 60 * 1000;
const STATE_FILE = '.caida-update-state.json';
const LOCK_FILE = '.caida-update.lock';
const datasets = [
{
id: 'as2org',
dbDir: AS_ORG_DB_DIR,
canonicalFile: AS_ORG_FILE,
archiveExt: '.txt.gz',
decompress: 'gzip',
// Stable 'latest' symlink server-side → HEAD + Last-Modified is enough.
findRemote: async ({ signal } = {}) => {
const url = 'https://publicdata.caida.org/datasets/as-organizations/latest.as-org2info.txt.gz';
const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal });
if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`);
return { url, identifier: res.headers.get('last-modified') };
},
validate: validateAsOrg,
reload: reloadAsOrgDatabase,
},
{
id: 'as-rel',
dbDir: AS_REL_DB_DIR,
canonicalFile: AS_REL_FILE,
archiveExt: '.txt.bz2',
decompress: 'bzip2',
// No 'latest' symlink — scrape directory listing, lex-sort YYYYMMDD
// filenames (= chrono), take the newest.
findRemote: async ({ signal } = {}) => {
const dir = 'https://publicdata.caida.org/datasets/as-relationships/serial-2/';
const res = await fetch(dir, { signal });
if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`);
const html = await res.text();
const matches = [...html.matchAll(/\b(\d{8})\.as-rel2\.txt\.bz2\b/g)];
if (matches.length === 0) throw new Error('No as-rel2.txt.bz2 found in listing');
matches.sort((a, b) => b[1].localeCompare(a[1]));
const filename = matches[0][0];
return { url: dir + filename, identifier: filename };
},
validate: validateAsRel,
reload: reloadAsRelDatabase,
},
];
let schedulerStarted = false;
const updateInProgress = new Set();
// ---------- Public API ----------
/**
* Download any missing CAIDA snapshots at boot. Always runs regardless of
* CAIDA_AUTO_UPDATE — a missing snapshot at boot means the operator wants
* one. Each dataset is independent; one failing doesn't abort others.
* Never throws.
*/
export async function bootstrapCaidaIfMissing() {
for (const dataset of datasets) {
try {
await bootstrapDataset(dataset);
} catch (error) {
logger.warn({ err: error, dataset: dataset.id }, '⚠️ CAIDA bootstrap failed');
}
}
}
/** Start the periodic updater. Opt-in via CAIDA_AUTO_UPDATE=true. */
export function startCaidaAutoUpdate() {
if (schedulerStarted) return;
schedulerStarted = true;
if (!isAutoUpdateEnabled()) return;
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN). Note runAllUpdates degrades per-dataset
// internally, so the monitor mainly catches a dead/hung scheduler.
const run = () => {
withCronMonitor('caida-auto-update', runAllUpdates, {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60,
maxRuntime: 30,
}).catch(error => {
logger.error({ err: error }, 'CAIDA auto update tick failed');
});
};
setTimeout(run, INITIAL_UPDATE_DELAY_MS).unref?.();
setInterval(run, UPDATE_INTERVAL_MS).unref?.();
const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS);
logger.info(`🗓️ CAIDA auto update plan: next check at ${nextRunAt.toLocaleString('en-US', { hour12: false })}, then every 24 hours (${datasets.length} datasets)`);
}
// ---------- Orchestration ----------
async function runAllUpdates() {
for (const dataset of datasets) {
try {
await updateDataset(dataset);
} catch (error) {
logger.error({ err: error, dataset: dataset.id }, 'CAIDA update failed');
}
}
}
async function bootstrapDataset(dataset) {
await fsp.mkdir(dataset.dbDir, { recursive: true });
await clearOrphanedLock(dataset);
if (snapshotExists(dataset)) return { status: 'present' };
const timeoutMinutes = BOOTSTRAP_TIMEOUT_MS / 60000;
logger.warn(`📥 CAIDA ${dataset.id} snapshot missing; attempting initial download (timeout ${timeoutMinutes} min)...`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), BOOTSTRAP_TIMEOUT_MS);
timer.unref?.();
try {
const result = await updateDataset(dataset, {
signal: controller.signal,
reloadReason: 'bootstrap',
});
if (result.updated) {
logger.warn(`✅ CAIDA ${dataset.id} snapshot downloaded and ready`);
return { status: 'downloaded' };
}
logger.warn(`⚠️ CAIDA ${dataset.id} bootstrap did not publish (${result.reason}).`);
return { status: 'no-op', reason: result.reason };
} catch (error) {
const reason = controller.signal.aborted
? `download did not complete within ${timeoutMinutes} min`
: error.message;
logger.warn(`⚠️ CAIDA ${dataset.id} initial download failed: ${reason}`);
return { status: 'failed', error };
} finally {
clearTimeout(timer);
}
}
async function updateDataset(dataset, { signal, reloadReason = 'auto update' } = {}) {
if (updateInProgress.has(dataset.id)) {
return { updated: false, reason: 'already-running' };
}
updateInProgress.add(dataset.id);
await fsp.mkdir(dataset.dbDir, { recursive: true });
const lock = await acquireUpdateLock(dataset);
if (!lock) {
updateInProgress.delete(dataset.id);
return { updated: false, reason: 'locked' };
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), `myip-caida-${dataset.id}-`));
try {
const result = await downloadAndPublish(dataset, tempDir, { signal });
if (result.updated && dataset.reload) {
dataset.reload(reloadReason);
}
return result;
} finally {
updateInProgress.delete(dataset.id);
await fsp.rm(tempDir, { recursive: true, force: true });
await lock.release();
}
}
async function downloadAndPublish(dataset, tempDir, { signal } = {}) {
const state = await readUpdateState(dataset);
const remote = await dataset.findRemote({ signal });
const targetPath = path.join(dataset.dbDir, dataset.canonicalFile);
if (fs.existsSync(targetPath) && state.identifier && state.identifier === remote.identifier) {
return { updated: false, reason: 'not-modified' };
}
const stagedPath = await downloadAndDecompress(dataset, remote.url, tempDir, { signal });
await dataset.validate(stagedPath);
await publishFile(stagedPath, targetPath);
await writeUpdateState(dataset, {
identifier: remote.identifier,
updatedAt: new Date().toISOString(),
});
logger.info({ dataset: dataset.id, identifier: remote.identifier }, 'CAIDA snapshot updated');
return { updated: true, identifier: remote.identifier };
}
// Stream archive to disk, then decompress to staged .txt. Split so errors
// clearly identify which phase failed (network vs decompression).
async function downloadAndDecompress(dataset, url, tempDir, { signal } = {}) {
const archivePath = path.join(tempDir, `archive${dataset.archiveExt}`);
const stagedPath = path.join(tempDir, dataset.canonicalFile);
const response = await fetch(url, { redirect: 'follow', signal });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${dataset.id}: HTTP ${response.status}`);
}
try {
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal });
} catch (error) {
throw new Error(`${dataset.id} download phase failed: ${error.message}`);
}
try {
await pipeline(
fs.createReadStream(archivePath),
createDecompressor(dataset.decompress),
fs.createWriteStream(stagedPath),
);
} catch (error) {
throw new Error(`${dataset.id} ${dataset.decompress} decompression phase failed: ${error.message}`);
}
return stagedPath;
}
// ---------- Validators (per-dataset, refuse partial / corrupt downloads) ----------
// Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change.
async function validateAsOrg(stagedPath) {
const MIN_VALID = 50000;
const text = await fsp.readFile(stagedPath, 'utf8');
const orgs = new Map();
const pending = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length === 5 && parts[0] && parts[2]) {
orgs.set(parts[0], parts[2]);
} else if (parts.length === 6 && Number(parts[0]) && parts[3]) {
pending.push(parts[3]);
}
}
let resolved = 0;
for (const orgId of pending) if (orgs.has(orgId)) resolved++;
if (resolved < MIN_VALID) {
throw new Error(`Staged as2org has only ${resolved} resolvable ASNs; refusing to publish`);
}
}
// Healthy as-rel2 has ~400k p2c rows; <100k indicates truncation.
async function validateAsRel(stagedPath) {
const MIN_VALID = 100000;
const text = await fsp.readFile(stagedPath, 'utf8');
let p2cRows = 0;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length >= 3 && parts[2] === '-1') p2cRows++;
}
if (p2cRows < MIN_VALID) {
throw new Error(`Staged as-rel has only ${p2cRows} p2c rows; refusing to publish`);
}
}
// ---------- Generic helpers ----------
async function publishFile(stagedPath, targetPath) {
const nextPath = `${targetPath}.next`;
const backupPath = `${targetPath}.bak`;
await fsp.copyFile(stagedPath, nextPath);
await fsp.copyFile(targetPath, backupPath).catch(error => {
if (error.code !== 'ENOENT') throw error;
});
try {
await fsp.rename(nextPath, targetPath);
} catch (error) {
await fsp.copyFile(backupPath, targetPath).catch(() => {});
throw error;
} finally {
await Promise.all([
fsp.rm(nextPath, { force: true }),
fsp.rm(backupPath, { force: true }),
]);
}
}
function isAutoUpdateEnabled() {
return process.env.CAIDA_AUTO_UPDATE === 'true';
}
function snapshotExists(dataset) {
if (!fs.existsSync(dataset.dbDir)) return false;
return fs.readdirSync(dataset.dbDir).some(f => f.endsWith('.txt'));
}
// At boot, any pre-existing lock is necessarily from a previous crashed run
// (we just started, no in-process updater can hold it). Clear it so a dead
// Ctrl+C doesn't block restart for LOCK_STALE_MS.
async function clearOrphanedLock(dataset) {
const lockPath = path.join(dataset.dbDir, LOCK_FILE);
if (fs.existsSync(lockPath)) {
await fsp.rm(lockPath, { force: true });
logger.warn(`🧹 Cleared orphaned CAIDA ${dataset.id} lock from previous boot`);
}
}
async function readUpdateState(dataset) {
try {
const content = await fsp.readFile(path.join(dataset.dbDir, STATE_FILE), 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') return {};
throw error;
}
}
async function writeUpdateState(dataset, state) {
await fsp.writeFile(
path.join(dataset.dbDir, STATE_FILE),
`${JSON.stringify(state, null, 2)}\n`,
'utf8',
);
}
async function acquireUpdateLock(dataset) {
const lockPath = path.join(dataset.dbDir, LOCK_FILE);
try {
const handle = await fsp.open(lockPath, 'wx');
await handle.writeFile(JSON.stringify({
pid: process.pid,
dataset: dataset.id,
startedAt: new Date().toISOString(),
}));
return {
release: async () => {
await handle.close();
await fsp.rm(lockPath, { force: true });
},
};
} catch (error) {
if (error.code !== 'EEXIST') throw error;
const stat = await fsp.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fsp.rm(lockPath, { force: true });
return acquireUpdateLock(dataset);
}
logger.info(`CAIDA ${dataset.id} update skipped: another process is updating`);
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
// Localized country/region names via the built-in Intl.DisplayNames API.
// Names come from the runtime's CLDR/ICU data, so any ISO 3166-1 alpha-2 code
// resolves in any supported locale with zero maintenance and zero bundle weight.
const displayNamesCache = new Map();
const getDisplayNames = (lang) => {
if (!displayNamesCache.has(lang)) {
let displayNames = null;
try {
// fallback: 'none' → unknown-but-well-formed codes yield undefined
// instead of echoing the code back, so callers get '' as before
displayNames = new Intl.DisplayNames([lang], { type: 'region', fallback: 'none' });
} catch {
// malformed / unsupported locale — cache the null so we don't retry
}
displayNamesCache.set(lang, displayNames);
}
return displayNamesCache.get(lang);
};
const getCountryName = (abbr, lang) => {
if (typeof abbr !== 'string' || typeof lang !== 'string') return '';
const code = abbr.toUpperCase();
// ZZ is the ISO "unknown region" placeholder; return '' so callers can fall back
if (!/^[A-Z]{2}$/.test(code) || code === 'ZZ') return '';
const displayNames = getDisplayNames(lang);
return (displayNames && displayNames.of(code)) || '';
};
export default getCountryName;
+13
View File
@@ -0,0 +1,13 @@
// The DNS record types /api/dnsresolver answers for — one list behind three
// consumers: the record-type <Select> in DnsResolver.vue, the requireValidRecordType
// guard, and the switch in api/dns-resolver.js that maps each type onto a
// resolver method. Array order is the order the picker renders.
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'CAA'];
export const DNS_RECORD_TYPE_SET = new Set(DNS_RECORD_TYPES);
// Types whose answers are domain names. Presentation form carries the root
// dot; Node's resolver drops it and a DoH endpoint may too, so both transports
// normalize through it — otherwise one provider's two rows differ on
// punctuation alone and read as a real disagreement.
export const NAME_VALUED_TYPES = new Set(['CNAME', 'NS']);
+1 -1
View File
@@ -36,7 +36,7 @@ export async function fetchWithTimeout(url, init = {}) {
}
// Optional User-Agent for fetchUpstream calls. Backend boot injects a
// project-identifying UA (see common/upstream-ua.js) because some upstream
// project-identifying UA (see server/upstream-ua.js) because some upstream
// WAFs hard-block undici's default `User-Agent: node`. Injection keeps this
// file browser-safe: the frontend bundle never touches fs / process.
let upstreamUserAgent = null;
+11
View File
@@ -0,0 +1,11 @@
// Firebase Auth handler proxy: `/__/auth/*` on the site's own origin →
// `<projectId>.firebaseapp.com`, mounted by frontend-server.js (production)
// and vite.config.js (dev). Why: api/AGENTS.md "Firebase Auth handler proxy".
export const FIREBASE_AUTH_PROXY_PATH = '/__/auth';
// null without a project id: no Firebase, no proxy.
export const firebaseAuthProxyTarget = (projectId) => {
const id = typeof projectId === 'string' ? projectId.trim() : '';
return id ? `https://${id}.firebaseapp.com` : null;
};
+378
View File
@@ -0,0 +1,378 @@
// IP address arithmetic shared by the IP Calculator (front-end) and rdap.js's
// CIDR containment (back-end). Both families are BigInt: 32-bit `<<` goes
// negative on 240.0.0.0, and IPv6 counts exceed Number anyway.
//
// Parsed = { family: 4 | 6, value: bigint }
// Cidr = { family, prefix, address, network, aligned }
//
// Every export is pure, never throws, and returns null (false for predicates)
// on unusable input. Parsers are strict — `01.2.3.4` is rejected because
// inet_aton would read the octet as octal; ip-calc.js labels such forms
// instead. No imports, so valid-ip.js could adopt this file without a cycle.
const BITS = { 4: 32, 6: 128 };
const MAX = { 4: (1n << 32n) - 1n, 6: (1n << 128n) - 1n };
const V4_OCTET = /^(0|[1-9]\d{0,2})$/;
const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/;
const V6_CHARS = /^[0-9a-fA-F:.]+$/;
const isFamily = (family) => family === 4 || family === 6;
const isBig = (v) => typeof v === 'bigint';
const inFamily = (value, family) => isBig(value) && value >= 0n && value <= MAX[family];
const isPrefix = (prefix, family) =>
Number.isInteger(prefix) && prefix >= 0 && prefix <= BITS[family];
/* ------------------------------------------------------------------ */
/* Parsing */
/* ------------------------------------------------------------------ */
export const parseIPv4 = (str) => {
if (typeof str !== 'string') return null;
const parts = str.split('.');
if (parts.length !== 4) return null;
let value = 0n;
for (const part of parts) {
if (!V4_OCTET.test(part)) return null;
const n = Number(part);
if (n > 255) return null;
value = (value << 8n) | BigInt(n);
}
return { family: 4, value };
};
// One `::` at most, 1–4 hex per group, and a dotted-quad tail only as the
// final token (`::ffff:192.0.2.1`), which then stands in for two groups.
export const parseIPv6 = (str) => {
if (typeof str !== 'string' || str === '' || !V6_CHARS.test(str)) return null;
let text = str;
let embeddedV4 = false;
if (str.includes('.')) {
const lastColon = str.lastIndexOf(':');
const tail = str.slice(lastColon + 1);
if (lastColon < 0 || !tail.includes('.')) return null;
const quad = parseIPv4(tail);
if (!quad) return null;
const hi = (quad.value >> 16n).toString(16);
const lo = (quad.value & 0xffffn).toString(16);
text = `${str.slice(0, lastColon + 1)}${hi}:${lo}`;
embeddedV4 = true;
}
const halves = text.split('::');
if (halves.length > 2) return null;
const shorthand = halves.length === 2;
const left = halves[0] === '' ? [] : halves[0].split(':');
const right = shorthand && halves[1] !== '' ? halves[1].split(':') : [];
const explicit = left.length + right.length;
if (shorthand ? explicit > 7 : explicit !== 8) return null;
const groups = [...left, ...Array(shorthand ? 8 - explicit : 0).fill('0'), ...right];
let value = 0n;
for (const group of groups) {
if (!HEX_GROUP.test(group)) return null;
value = (value << 16n) | BigInt(parseInt(group, 16));
}
return { family: 6, value, embeddedV4 };
};
export const parseIp = (str) => parseIPv4(str) || parseIPv6(str);
export const ipToBigInt = (str) => parseIp(str)?.value ?? null;
// `a.b.c.d/n`, `x::/n`, or (IPv4 only) `a.b.c.d/m.m.m.m` with a contiguous
// dotted mask. Host bits are kept on `address`; `network` has them cleared.
export const parseCidr = (str) => {
if (typeof str !== 'string') return null;
const slash = str.indexOf('/');
if (slash <= 0 || slash !== str.lastIndexOf('/')) return null;
const parsed = parseIp(str.slice(0, slash));
if (!parsed) return null;
const { family, value } = parsed;
const rhs = str.slice(slash + 1);
let prefix = null;
if (/^\d{1,3}$/.test(rhs)) {
prefix = Number(rhs);
} else if (family === 4) {
const mask = parseIPv4(rhs);
prefix = mask ? maskToPrefix(mask.value, 4) : null;
}
if (prefix === null || !isPrefix(prefix, family)) return null;
const network = value & prefixToMask(prefix, family);
return { family, prefix, address: value, network, aligned: network === value };
};
/* ------------------------------------------------------------------ */
/* Formatting */
/* ------------------------------------------------------------------ */
export const toOctets = (value) => {
if (!inFamily(value, 4)) return null;
return [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 0xffn));
};
export const toHextets = (value) => {
if (!inFamily(value, 6)) return null;
return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt((7 - i) * 16)) & 0xffffn));
};
export const formatIPv4 = (value) => toOctets(value)?.join('.') ?? null;
// RFC 5952: lowercase, no leading zeros, the longest run of two or more zero
// groups becomes `::` (first run wins a tie), a lone zero group stays.
export const formatIPv6 = (value, { expanded = false } = {}) => {
const hextets = toHextets(value);
if (!hextets) return null;
if (expanded) return hextets.map((h) => h.toString(16).padStart(4, '0')).join(':');
let bestStart = -1;
let bestLen = 0;
for (let i = 0; i < 8; i += 1) {
if (hextets[i] !== 0) continue;
let j = i;
while (j < 8 && hextets[j] === 0) j += 1;
if (j - i > bestLen) {
bestStart = i;
bestLen = j - i;
}
i = j;
}
const hex = hextets.map((h) => h.toString(16));
if (bestLen < 2) return hex.join(':');
const head = hex.slice(0, bestStart).join(':');
const tail = hex.slice(bestStart + bestLen).join(':');
return `${head}::${tail}`;
};
export const formatIp = (parsed) => {
if (!parsed || !isFamily(parsed.family)) return null;
return parsed.family === 4 ? formatIPv4(parsed.value) : formatIPv6(parsed.value);
};
export const formatCidr = (cidr, { network = true } = {}) => {
if (!cidr || !isFamily(cidr.family)) return null;
const ip = formatIp({ family: cidr.family, value: network ? cidr.network : cidr.address });
return ip === null ? null : `${ip}/${cidr.prefix}`;
};
/* ------------------------------------------------------------------ */
/* Masks & counts */
/* ------------------------------------------------------------------ */
export const prefixToMask = (prefix, family) => {
if (!isFamily(family) || !isPrefix(prefix, family)) return null;
const bits = BITS[family];
return ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix);
};
// Null unless the mask is ones followed by zeros.
export const maskToPrefix = (mask, family) => {
if (!isFamily(family) || !inFamily(mask, family)) return null;
for (let prefix = 0; prefix <= BITS[family]; prefix += 1) {
if (prefixToMask(prefix, family) === mask) return prefix;
}
return null;
};
export const wildcardMask = (prefix, family) => {
const mask = prefixToMask(prefix, family);
return mask === null ? null : MAX[family] ^ mask;
};
export const addressCount = (prefix, family) => {
if (!isFamily(family) || !isPrefix(prefix, family)) return null;
return 1n << BigInt(BITS[family] - prefix);
};
// IPv4 loses network + broadcast except on /31 (RFC 3021 point-to-point)
// and /32; IPv6 has no broadcast, every address is assignable.
export const usableCount = (prefix, family) => {
const count = addressCount(prefix, family);
if (count === null) return null;
if (family === 6 || prefix >= 31) return count;
return count - 2n;
};
// Smallest block that holds `count` addresses (number or bigint).
export const smallestPrefixFor = (count, family) => {
if (!isFamily(family)) return null;
let need;
try {
need = BigInt(count);
} catch {
return null;
}
if (need < 1n) return null;
for (let prefix = BITS[family]; prefix >= 0; prefix -= 1) {
if (addressCount(prefix, family) >= need) return prefix;
}
return null;
};
/* ------------------------------------------------------------------ */
/* Block info */
/* ------------------------------------------------------------------ */
export const cidrInfo = (str) => {
const cidr = parseCidr(str);
if (!cidr) return null;
const { family, prefix, address, network, aligned } = cidr;
const mask = prefixToMask(prefix, family);
const wildcard = wildcardMask(prefix, family);
const lastAddress = network | wildcard;
const hostRange = family === 4 && prefix < 31;
return {
family,
prefix,
cidr: formatCidr(cidr),
address,
network,
broadcast: family === 4 ? lastAddress : null,
first: hostRange ? network + 1n : network,
last: hostRange ? lastAddress - 1n : lastAddress,
lastAddress,
mask,
wildcard,
count: addressCount(prefix, family),
usable: usableCount(prefix, family),
aligned,
};
};
/* ------------------------------------------------------------------ */
/* Containment & ordering */
/* ------------------------------------------------------------------ */
export const prefixContains = (network, prefix, family, value) => {
if (!isFamily(family) || !isPrefix(prefix, family)
|| !inFamily(network, family) || !inFamily(value, family)) return false;
const shift = BigInt(BITS[family] - prefix);
return (value >> shift) === (network >> shift);
};
export const cidrContains = (cidrStr, ipStr) => {
const cidr = parseCidr(cidrStr);
const ip = parseIp(ipStr);
if (!cidr || !ip || cidr.family !== ip.family) return null;
return prefixContains(cidr.network, cidr.prefix, cidr.family, ip.value);
};
export const cidrOverlaps = (aStr, bStr) => {
const a = parseCidr(aStr);
const b = parseCidr(bStr);
if (!a || !b || a.family !== b.family) return null;
return prefixContains(a.network, a.prefix, a.family, b.network)
|| prefixContains(b.network, b.prefix, b.family, a.network);
};
// IPv4 sorts before IPv6; within a family, numerically.
export const compareIps = (a, b) => {
if (a.family !== b.family) return a.family < b.family ? -1 : 1;
if (a.value === b.value) return 0;
return a.value < b.value ? -1 : 1;
};
/* ------------------------------------------------------------------ */
/* Set operations */
/* ------------------------------------------------------------------ */
// Children of `cidrStr` at `newPrefix`. `total` is exact; `subnets` stops at
// `limit` so a /8 → /32 request never materialises 16M strings.
export const splitCidr = (cidrStr, newPrefix, options = {}) => {
if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
const { limit = 1024 } = options;
if (!Number.isSafeInteger(limit) || limit < 0) return null;
const cidr = parseCidr(cidrStr);
if (!cidr || !isPrefix(newPrefix, cidr.family) || newPrefix < cidr.prefix) return null;
const { family, network } = cidr;
const total = 1n << BigInt(newPrefix - cidr.prefix);
const size = addressCount(newPrefix, family);
const emit = total < BigInt(limit) ? Number(total) : limit;
const subnets = [];
for (let i = 0n; i < BigInt(emit); i += 1n) {
subnets.push(`${formatIp({ family, value: network + i * size })}/${newPrefix}`);
}
return { family, prefix: newPrefix, total, subnets, truncated: BigInt(emit) < total };
};
const isAligned = (network, prefix, family) =>
(network & (addressCount(prefix, family) - 1n)) === 0n;
// Minimal covering set per family: bare IPs become host routes, contained
// blocks are absorbed, aligned siblings merge upward (4 × /26 → one /24).
export const aggregateCidrs = (list) => {
const out = { v4: [], v6: [], invalid: [] };
if (!Array.isArray(list)) return out;
const blocks = { 4: [], 6: [] };
for (const token of list) {
const text = typeof token === 'string' ? token.trim() : '';
const cidr = parseCidr(text);
if (cidr) {
blocks[cidr.family].push({ network: cidr.network, prefix: cidr.prefix });
continue;
}
const ip = parseIp(text);
if (ip) {
blocks[ip.family].push({ network: ip.value, prefix: BITS[ip.family] });
continue;
}
out.invalid.push(token);
}
for (const family of [4, 6]) {
const sorted = blocks[family].sort((a, b) => {
if (a.network !== b.network) return a.network < b.network ? -1 : 1;
return a.prefix - b.prefix;
});
const stack = [];
for (const block of sorted) {
const top = stack[stack.length - 1];
if (top && top.prefix <= block.prefix
&& prefixContains(top.network, top.prefix, family, block.network)) continue;
stack.push(block);
while (stack.length >= 2) {
const a = stack[stack.length - 2];
const b = stack[stack.length - 1];
const size = addressCount(a.prefix, family);
if (a.prefix === 0 || a.prefix !== b.prefix || b.network !== a.network + size
|| !isAligned(a.network, a.prefix - 1, family)) break;
stack.splice(-2, 2, { network: a.network, prefix: a.prefix - 1 });
}
}
out[family === 4 ? 'v4' : 'v6'] = stack.map(
(b) => `${formatIp({ family, value: b.network })}/${b.prefix}`,
);
}
return out;
};
// Greedy cover of an inclusive range with the fewest CIDRs: at each step
// take the largest block aligned at `cur` that still fits before `end`.
export const rangeToCidrs = (startStr, endStr) => {
const start = parseIp(startStr);
const end = parseIp(endStr);
if (!start || !end || start.family !== end.family || start.value > end.value) return null;
const { family } = start;
const bits = BITS[family];
const cidrs = [];
let cur = start.value;
while (cur <= end.value) {
let prefix = 0;
for (; prefix <= bits; prefix += 1) {
const size = addressCount(prefix, family);
if (isAligned(cur, prefix, family) && cur + size - 1n <= end.value) break;
}
cidrs.push(`${formatIp({ family, value: cur })}/${prefix}`);
cur += addressCount(prefix, family);
}
return { family, cidrs };
};
export const cidrToRange = (cidrStr) => {
const info = cidrInfo(cidrStr);
if (!info) return null;
return { family: info.family, start: info.network, end: info.lastAddress };
};
-12
View File
@@ -1,12 +0,0 @@
// Shared language allow-list for handlers that accept a ?lang query param.
// Each consumer keeps its own default; pickLang validates against this list.
// Mirrors the UI's locale list (zh maps to zh-CN before hitting the API).
// Unknown values — including stale clients still sending lang=tr — fall
// back to the caller's default via pickLang.
export const SUPPORTED_LANGS = ['zh-CN', 'en', 'fr', 'ru'];
// Return raw if it's a supported language, otherwise the given fallback.
export function pickLang(raw, fallback) {
return SUPPORTED_LANGS.includes(raw) ? raw : fallback;
}
+45
View File
@@ -0,0 +1,45 @@
// Shape helpers for locale packs — the JSON trees under frontend/locales/.
//
// A beta pack is en's full skeleton with every untranslated value left as "",
// so a translation PR always reads as `"" → text` in the diff. "" is a
// source-level marker only: the Vite plugin in vite.config.js runs every pack
// through stripUntranslated() on the way into the bundle, so what the app ever
// sees has holes for the i18n fallback chain to fill — an untranslated value
// and an absent key are the same thing at runtime.
// path → leaf value, arrays flattened by index.
export const flattenPack = (value, prefix = '', out = new Map()) => {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === 'object') flattenPack(child, path, out);
else out.set(path, child);
}
return out;
};
export const isUntranslated = (value) => typeof value === 'string' && value.trim() === '';
// Deep-copy `value` without its untranslated leaves; `undefined` means the
// whole node is untranslated and its parent should drop it too.
//
// An array is all-or-nothing: dropping one element would shift the rest, and
// the arrays here are rendered as a unit (paragraph runs, checklist items), so
// a single empty entry falls the whole array back to en.
export const stripUntranslated = (value) => {
if (isUntranslated(value)) return undefined;
if (Array.isArray(value)) {
const kept = value.map(stripUntranslated);
return kept.some((entry) => entry === undefined) ? undefined : kept;
}
if (value && typeof value === 'object') {
const kept = Object.entries(value)
.map(([key, child]) => [key, stripUntranslated(child)])
.filter(([, child]) => child !== undefined);
return kept.length === 0 ? undefined : Object.fromEntries(kept);
}
return value;
};
// stripUntranslated for a whole pack: an entirely untranslated one collapses to
// an empty container rather than `undefined`, so callers always get a pack.
export const stripPack = (pack) => stripUntranslated(pack) ?? (Array.isArray(pack) ? [] : {});
+58
View File
@@ -0,0 +1,58 @@
// Central registry of the languages the UI ships. Adding one = a locale pack
// plus a line here. Front end imports it via `@/utils/locale-registry.js`.
//
// The back end takes no part in that: which languages upstream *data* comes in
// is a separate set owned by the source itself (SUPPORTED_LANGS in
// server/datasets/maxmind-service.js), and every `?lang` consumer resolves an unfamiliar
// tag onto its own family instead of rejecting it. A new UI locale is a
// front-end-only change.
// code=UI code + locale file name · apiTag=tag sent upstream ·
// htmlLang=<html lang> · status=full|beta
export const LOCALES = [
{ code: 'en', nativeName: 'English', flag: 'us', apiTag: 'en', htmlLang: 'en', status: 'full' },
{ code: 'zh', nativeName: '简体中文', flag: 'cn', apiTag: 'zh-CN', htmlLang: 'zh-CN', status: 'full' },
{ code: 'zh-TW', nativeName: '繁體中文', flag: 'tw', apiTag: 'zh-CN', htmlLang: 'zh-TW', status: 'full' }, // Upstream APIs do not support zh-TW, so apiTag aliases to zh-CN
{ code: 'fr', nativeName: 'Français', flag: 'fr', apiTag: 'fr', htmlLang: 'fr', status: 'full' },
{ code: 'ru', nativeName: 'Русский', flag: 'ru', apiTag: 'ru', htmlLang: 'ru', status: 'full' },
{ code: 'pt-BR', nativeName: 'Português (Brasil)', flag: 'br', apiTag: 'pt-BR', htmlLang: 'pt-BR', status: 'full' },
];
// Registry order — also the order the language picker renders.
export const LOCALE_CODES = LOCALES.map((locale) => locale.code);
// Locales held to full coverage; beta ones may ship a partial pack.
export const FULL_LOCALE_CODES = LOCALES.filter((l) => l.status === 'full').map((l) => l.code);
export const FALLBACK_LOCALE = 'en';
export const getLocale = (code) => LOCALES.find((locale) => locale.code === code);
// Both mappings pass an unregistered code through rather than blanking it.
export const toApiTag = (code) => getLocale(code)?.apiTag ?? code;
export const toHtmlLang = (code) => getLocale(code)?.htmlLang ?? code;
// The chain a missing translation walks: variant → base → en. A base only
// joins the chain when it is registered. This is the single definition of the
// order — i18n, the privacy / checklist datasets and the changelog all use it.
export const fallbackChain = (code) => {
const chain = [code];
const base = String(code).split('-')[0];
if (base !== code && LOCALE_CODES.includes(base)) chain.push(base);
if (!chain.includes(FALLBACK_LOCALE)) chain.push(FALLBACK_LOCALE);
return chain;
};
// Resolve a BCP-47 tag against `codes` in three steps: exact, then the base
// language, then any locale of that family (pt-PT → pt-BR), registry order
// deciding between siblings.
export const matchLocale = (tag, codes = LOCALE_CODES) => {
if (!tag) return null;
const wanted = String(tag).toLowerCase();
const base = wanted.split('-')[0];
return codes.find((code) => code.toLowerCase() === wanted)
?? codes.find((code) => code.toLowerCase() === base)
?? codes.find((code) => code.toLowerCase().split('-')[0] === base)
?? null;
};
+22
View File
@@ -0,0 +1,22 @@
// MAC Lookup query parsing, shared by the /api/macchecker handler and the
// MacChecker form (via frontend/utils/features/mac-input.js). A query is a
// full 48-bit address or any leading part of one, down to a 24-bit vendor
// prefix, in the usual notations: colon / hyphen pairs, Cisco dotted quads,
// or bare hex.
export const MAC_HEX_LENGTH = 12;
export const MAC_PREFIX_MIN_HEX = 6;
// Separators accepted between hex digits: `:` `-` `.` and whitespace.
const SEPARATORS = /[\s:.-]/g;
/**
* Upper-case bare hex of a full address or a prefix (6–12 hex digits), or
* null for anything else.
*/
export const normalizeMacQuery = (input) => {
if (typeof input !== 'string') return null;
const hex = input.replace(SEPARATORS, '').toUpperCase();
if (hex.length < MAC_PREFIX_MIN_HEX || hex.length > MAC_HEX_LENGTH) return null;
return /^[0-9A-F]+$/.test(hex) ? hex : null;
};
-532
View File
@@ -1,532 +0,0 @@
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import * as tar from 'tar';
import maxmind from 'maxmind';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import {
getMaxMindDbPaths,
MAXMIND_ASN_DB,
MAXMIND_CITY_DB,
} from './maxmind-service.js';
const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const INITIAL_UPDATE_DELAY_MS = 60 * 1000;
const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
// Cap the blocking "download databases at boot if missing" path. Long enough
// for a reasonable initial download over slow residential links, short enough
// that a misconfigured credential or a blocked outbound connection doesn't
// hold up the whole server indefinitely. We only abort the download — the
// server still starts after this timeout, just without MaxMind ready.
const BOOTSTRAP_TIMEOUT_MS = 5 * 60 * 1000;
const STATE_FILE = '.maxmind-update-state.json';
const LOCK_FILE = '.maxmind-update.lock';
const MAXMIND_UPDATE_ENV_KEYS = [
'MAXMIND_ACCOUNT_ID',
'MAXMIND_LICENSE_KEY',
'MAXMIND_AUTO_UPDATE',
];
const editions = [
{
editionId: 'GeoLite2-City',
fileName: MAXMIND_CITY_DB,
},
{
editionId: 'GeoLite2-ASN',
fileName: MAXMIND_ASN_DB,
},
];
let schedulerStarted = false;
let updateInProgress = false;
/**
* Start the periodic MaxMind updater when credentials and opt-in env vars are present.
*/
export function startMaxMindAutoUpdate({ reload } = {}) {
if (schedulerStarted) {
return;
}
schedulerStarted = true;
if (!hasAnyUpdateEnvironment()) {
return;
}
if (!isAutoUpdateEnabled()) {
logger.info('MaxMind auto update plan: disabled');
return;
}
if (!hasDownloadCredentials()) {
logger.info('MaxMind auto update skipped: MAXMIND_ACCOUNT_ID or MAXMIND_LICENSE_KEY is missing');
return;
}
// Run one update cycle and keep the scheduler alive if that cycle fails.
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN) so a silently dead scheduler gets noticed.
const run = () => {
withCronMonitor('maxmind-auto-update', () => updateMaxMindDatabases({ reload }), {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60, // minutes of lateness tolerated before "missed"
maxRuntime: 30, // minutes before an in-progress run counts as failed
}).catch(error => {
logger.error({ err: error }, 'MaxMind auto update failed');
});
};
const initialTimer = setTimeout(run, INITIAL_UPDATE_DELAY_MS);
initialTimer.unref?.();
const intervalTimer = setInterval(run, UPDATE_INTERVAL_MS);
intervalTimer.unref?.();
logUpdatePlan();
}
/**
* One-shot "download the MaxMind databases at boot if they are missing" path.
*
* The decision tree matches what we want the operator to experience on a
* cold checkout:
* - Both mmdb files already on disk → do nothing, let reload handle it.
* - Files missing AND no MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY configured
* → print a clear "how to fix this" warning and return; server will start
* anyway but the MaxMind API will 503 until DBs are provided.
* - Files missing AND credentials present → run a single download cycle,
* capped at BOOTSTRAP_TIMEOUT_MS. On failure (timeout, auth error, network
* block), log a warning and return; the server still starts.
*
* MAXMIND_AUTO_UPDATE is intentionally NOT consulted here — that flag only
* gates the periodic scheduler. If the operator put valid credentials in .env,
* the intent is obviously "I want MaxMind working", and making them set a
* second flag just to get the first download would be confusing.
*
* Never throws: the caller ({@link ./../backend-server.js}) treats this as
* advisory and always proceeds to `app.listen`.
*/
export async function bootstrapMaxMindIfMissing({ reload } = {}) {
const { cityDbPath, asnDbPath } = getMaxMindDbPaths();
if (fs.existsSync(cityDbPath) && fs.existsSync(asnDbPath)) {
return { status: 'present' };
}
if (!hasDownloadCredentials()) {
logger.warn(
'⚠️ MaxMind databases are missing and MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY are not configured.\n' +
' Set the credentials in .env and restart, or drop GeoLite2-City.mmdb + GeoLite2-ASN.mmdb\n' +
' into common/maxmind-db/. Starting server anyway; MaxMind API will return 503 until\n' +
' databases are available.'
);
return { status: 'missing-credentials' };
}
const timeoutMinutes = BOOTSTRAP_TIMEOUT_MS / 60000;
logger.info(`📥 MaxMind databases missing; attempting initial download (timeout ${timeoutMinutes} min)...`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), BOOTSTRAP_TIMEOUT_MS);
timer.unref?.();
try {
await updateMaxMindDatabases({
reload,
signal: controller.signal,
reloadReason: 'bootstrap',
});
return { status: 'downloaded' };
} catch (error) {
// Disambiguate "we hit the 5-minute cap" from "the download itself
// failed" so operators know whether to look at network / firewall vs
// credentials / MaxMind account status.
const reason = controller.signal.aborted
? `download did not complete within ${timeoutMinutes} min (check connectivity to download.maxmind.com)`
: error.message;
logger.warn(
`⚠️ MaxMind initial download failed: ${reason}\n` +
' Starting server anyway; MaxMind API will return 503 until databases are available.'
);
return { status: 'failed', error };
} finally {
clearTimeout(timer);
}
}
/**
* Run one locked update cycle and reload readers only after a successful publish.
*
* `signal` is an optional AbortSignal that is threaded into every HTTP fetch
* and stream pipeline, so callers (notably the bootstrap path at startup) can
* cap the whole cycle with a single timeout and have in-flight network I/O
* actually stop instead of quietly dangling in the background.
*
* `reloadReason` lets the caller tag the reload log line ("auto update" for
* the scheduler, "bootstrap" for the startup download, etc.).
*/
export async function updateMaxMindDatabases({ reload, signal, reloadReason = 'auto update' } = {}) {
if (updateInProgress) {
return { updated: false, reason: 'already-running' };
}
updateInProgress = true;
const { dbDir } = getMaxMindDbPaths();
await fsp.mkdir(dbDir, { recursive: true });
const lock = await acquireUpdateLock(dbDir);
if (!lock) {
updateInProgress = false;
return { updated: false, reason: 'locked' };
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'myip-maxmind-'));
try {
const result = await downloadAndReplaceDatabases(dbDir, tempDir, { signal });
if (result.updated && reload) {
await reload(reloadReason);
}
return result;
} finally {
updateInProgress = false;
await fsp.rm(tempDir, { recursive: true, force: true });
await lock.release();
}
}
/**
* Check the explicit opt-in flag for automatic MaxMind updates.
*/
function isAutoUpdateEnabled() {
return process.env.MAXMIND_AUTO_UPDATE === 'true';
}
/**
* Check whether both MaxMind download credential variables are configured.
*/
function hasDownloadCredentials() {
return Boolean(process.env.MAXMIND_ACCOUNT_ID && process.env.MAXMIND_LICENSE_KEY);
}
/**
* Check whether the deployment has any MaxMind update-related environment config.
*/
function hasAnyUpdateEnvironment() {
return MAXMIND_UPDATE_ENV_KEYS.some(key => Object.hasOwn(process.env, key));
}
/**
* Print the next MaxMind update check and repeat interval.
*/
function logUpdatePlan() {
const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS);
logger.info(`🗓️ MaxMind auto update plan: next check at ${formatScheduleTime(nextRunAt)}, then every ${formatDuration(UPDATE_INTERVAL_MS)}`);
}
/**
* Format scheduler timestamps for startup logs.
*/
function formatScheduleTime(date) {
return date.toLocaleString('en-US', { hour12: false });
}
/**
* Format millisecond durations into compact hour or minute text.
*/
function formatDuration(milliseconds) {
const hours = milliseconds / (60 * 60 * 1000);
if (Number.isInteger(hours)) {
return `${hours} hour${hours === 1 ? '' : 's'}`;
}
const minutes = milliseconds / (60 * 1000);
if (Number.isInteger(minutes)) {
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
return `${milliseconds} ms`;
}
/**
* Download all required newer editions, validate them, then publish them together.
*/
async function downloadAndReplaceDatabases(dbDir, tempDir, { signal } = {}) {
const state = await readUpdateState(dbDir);
const plannedUpdates = [];
for (const edition of editions) {
const remoteInfo = await getRemoteInfo(edition, { signal });
const targetPath = path.join(dbDir, edition.fileName);
if (!shouldDownloadDatabase(targetPath, state, edition.editionId, remoteInfo.lastModified)) {
continue;
}
const downloadedPath = await downloadAndExtractDatabase(edition, tempDir, { signal });
plannedUpdates.push({
...edition,
lastModified: remoteInfo.lastModified,
sourcePath: downloadedPath,
targetPath,
});
}
if (plannedUpdates.length === 0) {
return { updated: false, reason: 'not-modified' };
}
await validatePlannedDatabases(dbDir, plannedUpdates);
await publishDatabases(plannedUpdates);
for (const update of plannedUpdates) {
state[update.editionId] = {
lastModified: update.lastModified,
updatedAt: new Date().toISOString(),
};
}
await writeUpdateState(dbDir, state);
logger.info({ editions: plannedUpdates.map(update => update.editionId) }, 'MaxMind databases updated');
return {
updated: true,
editions: plannedUpdates.map(update => update.editionId),
};
}
/**
* Fetch remote metadata for a MaxMind edition without downloading the archive.
*/
async function getRemoteInfo(edition, { signal } = {}) {
const response = await fetch(getDownloadUrl(edition.editionId), {
method: 'HEAD',
headers: getAuthHeaders(),
redirect: 'follow',
signal,
});
if (!response.ok) {
throw new Error(`Failed to check ${edition.editionId}: HTTP ${response.status}`);
}
return {
lastModified: response.headers.get('last-modified'),
};
}
/**
* Decide whether the local database should be refreshed from MaxMind.
*/
function shouldDownloadDatabase(targetPath, state, editionId, lastModified) {
if (!fs.existsSync(targetPath)) {
return true;
}
if (!lastModified) {
return true;
}
const lastRemoteUpdate = Date.parse(lastModified);
if (Number.isNaN(lastRemoteUpdate)) {
return true;
}
if (!state[editionId]?.lastModified) {
return true;
}
const recordedUpdate = Date.parse(state[editionId].lastModified);
return Number.isNaN(recordedUpdate) || lastRemoteUpdate > recordedUpdate;
}
/**
* Download a MaxMind archive, extract it, and return the matching .mmdb file path.
*/
async function downloadAndExtractDatabase(edition, tempDir, { signal } = {}) {
const editionDir = path.join(tempDir, edition.editionId);
const archivePath = path.join(tempDir, `${edition.editionId}.tar.gz`);
await fsp.mkdir(editionDir, { recursive: true });
const response = await fetch(getDownloadUrl(edition.editionId), {
headers: getAuthHeaders(),
redirect: 'follow',
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${edition.editionId}: HTTP ${response.status}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal });
await tar.x({ file: archivePath, cwd: editionDir });
const mmdbPath = await findExtractedDatabase(editionDir, edition.fileName);
if (!mmdbPath) {
throw new Error(`Downloaded ${edition.editionId} archive did not contain ${edition.fileName}`);
}
return mmdbPath;
}
/**
* Open the staged database set to ensure both City and ASN files are valid.
*/
async function validatePlannedDatabases(dbDir, plannedUpdates) {
const stagedPaths = new Map(plannedUpdates.map(update => [update.fileName, update.sourcePath]));
const cityPath = stagedPaths.get(MAXMIND_CITY_DB) || path.join(dbDir, MAXMIND_CITY_DB);
const asnPath = stagedPaths.get(MAXMIND_ASN_DB) || path.join(dbDir, MAXMIND_ASN_DB);
await Promise.all([
maxmind.open(cityPath),
maxmind.open(asnPath),
]);
}
/**
* Atomically publish prepared databases while keeping backups for rollback.
*/
async function publishDatabases(plannedUpdates) {
const preparedUpdates = plannedUpdates.map(update => ({
...update,
nextPath: `${update.targetPath}.next`,
backupPath: `${update.targetPath}.bak`,
}));
for (const update of preparedUpdates) {
await fsp.copyFile(update.sourcePath, update.nextPath);
await fsp.copyFile(update.targetPath, update.backupPath).catch(error => {
if (error.code !== 'ENOENT') {
throw error;
}
});
}
try {
for (const update of preparedUpdates) {
await fsp.rename(update.nextPath, update.targetPath);
}
} catch (error) {
for (const update of preparedUpdates) {
await fsp.copyFile(update.backupPath, update.targetPath).catch(() => {});
}
throw error;
} finally {
for (const update of preparedUpdates) {
await Promise.all([
fsp.rm(update.nextPath, { force: true }),
fsp.rm(update.backupPath, { force: true }),
]);
}
}
}
/**
* Recursively find the expected .mmdb file inside an extracted MaxMind archive.
*/
async function findExtractedDatabase(dir, fileName) {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === fileName) {
return entryPath;
}
if (entry.isDirectory()) {
const found = await findExtractedDatabase(entryPath, fileName);
if (found) {
return found;
}
}
}
return null;
}
/**
* Build the MaxMind permalink download URL for an edition archive.
*/
function getDownloadUrl(editionId) {
return `https://download.maxmind.com/geoip/databases/${editionId}/download?suffix=tar.gz`;
}
/**
* Build the Basic Auth header required by MaxMind downloads.
*/
function getAuthHeaders() {
const auth = Buffer.from(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`).toString('base64');
return {
Authorization: `Basic ${auth}`,
};
}
/**
* Read the updater state file that records previously downloaded remote versions.
*/
async function readUpdateState(dbDir) {
try {
const content = await fsp.readFile(path.join(dbDir, STATE_FILE), 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Persist the updater state after a successful database publish.
*/
async function writeUpdateState(dbDir, state) {
await fsp.writeFile(path.join(dbDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
/**
* Acquire a filesystem lock so multiple pm2 processes do not update concurrently.
*/
async function acquireUpdateLock(dbDir) {
const lockPath = path.join(dbDir, LOCK_FILE);
try {
const handle = await fsp.open(lockPath, 'wx');
await handle.writeFile(JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
}));
return {
// Release the inter-process lock after this update cycle finishes.
release: async () => {
await handle.close();
await fsp.rm(lockPath, { force: true });
},
};
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
const stat = await fsp.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fsp.rm(lockPath, { force: true });
return acquireUpdateLock(dbDir);
}
logger.info('MaxMind update skipped: another process is updating databases');
return null;
}
}
+63 -7
View File
@@ -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,8 +24,16 @@ 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/ip/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)
// (ConnectivityTest.vue), the builder whitelist (utils/report/report-builders.js)
// and the schema enum below, so a rename can't drift them apart.
export const CONNECTIVITY_STATUS = {
OK: 'ok',
@@ -142,19 +153,19 @@ 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({
testedAt: isoDate(),
// 72 = 7 built-in targets + the 60-target custom/import cap, plus
// headroom; matches the builder's slice in utils/report-builders.js.
// headroom; matches the builder's slice in utils/report/report-builders.js.
targets: arr(72, obj({
id: str(48),
name: str(64),
@@ -257,6 +268,19 @@ const SECTION_SPECS = {
rtt: opt(int(0, 600000)),
}))),
}),
// Ad & Tracker Blocking Test: per-category request counts only. Group
// ids stay capped strings — the probe list lives in the front end and
// changes with it.
adblocktest: obj({
testedAt: isoDate(),
groups: arr(16, obj({
id: str(16),
total: int(0, 1024),
blocked: int(0, 1024),
open: int(0, 1024),
unknown: int(0, 1024),
})),
}),
invisibility: obj({
testedAt: isoDate(),
ip: opt(ipValue()),
@@ -285,6 +309,38 @@ const SECTION_SPECS = {
cd: opt(bool()),
})),
}),
// Persona Check. Deliberately verdict-only: each check contributes its id,
// its axis and its conclusion, and nothing else. The live report's `detail`
// objects carry what the visitor themselves supplied or what their machine
// exposed — issuing bank, the country their position resolved to, their
// zone and language list — none of which belongs behind a link anyone can
// open. Same doctrine as the invisibility section, which stores its signals
// as key + flag rather than the evidence behind them.
persona: obj({
testedAt: isoDate(),
country: countryCode(),
grade: oneOf('A', 'B', 'C', 'D', 'unknown'),
// Null whenever there was too little signal to grade at all.
score: nullable(int(0, 100)),
counts: obj({
total: int(0, 64),
scored: int(0, 64),
match: int(0, 64),
mismatch: int(0, 64),
unnatural: int(0, 64),
leak: int(0, 64),
unknown: int(0, 64),
notApplicable: int(0, 64),
}),
// `id` stays a capped string rather than an enum: the check registry
// lives in the evaluating API, and duplicating its ids here would be a
// second list to keep in step for no validation gain.
results: arr(32, obj({
id: str(48),
axis: oneOf('match', 'coherence', 'leak'),
verdict: oneOf('match', 'mismatch', 'leak', 'unnatural', 'unknown', 'not-applicable'),
})),
}),
};
// Section ids in homepage order — the report page renders in this order.
-47
View File
@@ -1,47 +0,0 @@
// Upstream source-of-truth for the "Service Status" homepage section.
//
// Each provider exposes a status page that is API-compatible with the Atlassian
// Statuspage / incident.io schema — both expose the same
// `/api/v2/summary.json` + `/api/v2/incidents.json` endpoints. Cloudflare
// self-hosts a page that mirrors the same shape.
//
// `id` — stable slug
// `name` — display name
// `api` — origin we hit `${api}/api/v2/{summary,incidents}.json` against
// `page` — public status page; also the base for incident detail links
// (`${page}/incidents/${incidentId}`), since incident.io's feed omits
// a usable shortlink.
//
// This list lives backend-side: the poller (common/service-status-store.js)
// walks it on a fixed schedule and caches the result in memory, so the
// upstream origins never reach the shipped frontend bundle.
export const STATUS_PROVIDERS = [
{ id: 'claude', name: 'Claude', api: 'https://status.claude.com', page: 'https://status.claude.com' },
{ id: 'chatgpt', name: 'ChatGPT', api: 'https://status.openai.com', page: 'https://status.openai.com' },
{ id: 'cursor', name: 'Cursor', api: 'https://status.cursor.com', page: 'https://status.cursor.com' },
// exclude: drop GitHub's "Visit … for more information" placeholder row.
{ id: 'github', name: 'GitHub', api: 'https://www.githubstatus.com', page: 'https://www.githubstatus.com', components: { exclude: ['0l2p9nhqnxpd'] } },
{ id: 'elevenlabs', name: 'ElevenLabs', api: 'https://status.elevenlabs.io', page: 'https://status.elevenlabs.io' },
// Default top-level: summary.json flattens away CF's mid-level product
// categories, leaving one "Cloudflare Sites and Services" rollup + 7
// continents — which keeps the card light consistent with this list.
{ id: 'cloudflare', name: 'Cloudflare', api: 'https://new.cloudflarestatus.com', page: 'https://new.cloudflarestatus.com' },
{ id: 'groq', name: 'Groq', api: 'https://groqstatus.com', page: 'https://groqstatus.com' },
{ id: 'notion', name: 'Notion', api: 'https://www.notion-status.com', page: 'https://www.notion-status.com' },
{ id: 'vercel', name: 'Vercel', api: 'https://www.vercel-status.com', page: 'https://www.vercel-status.com' },
{ id: 'netlify', name: 'Netlify', api: 'https://www.netlifystatus.com', page: 'https://www.netlifystatus.com' },
{ id: 'render', name: 'Render', api: 'https://status.render.com', page: 'https://status.render.com' },
{ id: 'supabase', name: 'Supabase', api: 'https://status.supabase.com', page: 'https://status.supabase.com' },
{ id: 'replicate', name: 'Replicate', api: 'https://replicatestatus.com', page: 'https://replicatestatus.com' },
{ id: 'figma', name: 'Figma', api: 'https://status.figma.com', page: 'https://status.figma.com' },
{ id: 'linear', name: 'Linear', api: 'https://linearstatus.com', page: 'https://linearstatus.com' },
// Stripe's customer-facing status.stripe.com is a homegrown page with a
// non-Statuspage schema; www.stripestatus.com is its Statuspage mirror,
// which exposes the /api/v2 endpoints this poller needs.
{ id: 'stripe', name: 'Stripe', api: 'https://www.stripestatus.com', page: 'https://www.stripestatus.com' },
];
// Whitelist used by requireValidProviderId() — the per-provider detail
// endpoints (components / incidents) take an `id` query param.
export const STATUS_PROVIDER_IDS = new Set(STATUS_PROVIDERS.map((p) => p.id));
+14 -8
View File
@@ -4,9 +4,9 @@ function isValidIP(ip) {
return false;
}
// IPv4
// IPv4 uses decimal octets; leading zeros can be interpreted as octal upstream.
const ipv4Pattern =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
if (ipv4Pattern.test(ip)) {
return true;
@@ -35,15 +35,21 @@ function isIPv6(ip) {
return typeof ip === 'string' && ip.includes(':');
}
// Validate an IP of one specific version — safe on untrusted input.
const isValidIPv4 = (ip) => isValidIP(ip) && !isIPv6(ip);
const isValidIPv6 = (ip) => isValidIP(ip) && isIPv6(ip);
// Validate if a string is a syntactically plausible domain name.
// Matches the hostname pattern used by DnsResolver / Whois / CensorshipCheck:
// lowercase-only labels of [a-z0-9-], at least one dot, and a TLD of 2+
// letters. This is intentionally a surface-level check — it accepts
// "foo.example" and doesn't know about public suffixes — because every
// caller also routes through `new URL()` parsing before landing here.
// labels of [a-z0-9-], at least one dot, and a TLD of 2+ letters. Any label
// but the TLD may also carry a leading underscore, which is how RFC 8552
// names service records — `_dmarc.example.com`, `_xmpp-server._tcp.example.com`
// — so a DNS lookup can reach them. This is intentionally a surface-level
// check: it accepts "foo.example" and doesn't know about public suffixes,
// because every caller also routes through `new URL()` parsing first.
function isValidDomain(domain) {
if (typeof domain !== 'string') return false;
return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(domain);
return /^_?[a-z0-9-]+(\._?[a-z0-9-]+)*\.[a-z]{2,}$/i.test(domain);
}
// IPv4 blocks outside publicly routable space: the RFC 1918 private ranges
@@ -117,4 +123,4 @@ const isUsablePublicIP = (ip) => {
return !(isIPv6(ip) ? isReservedV6(ip) : isReservedV4(ip));
};
export { isValidIP, isIPv6, isValidDomain, isUsablePublicIP };
export { isValidIP, isValidIPv4, isValidIPv6, isIPv6, isValidDomain, isUsablePublicIP };
+14 -1
View File
@@ -4,7 +4,8 @@ import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createProxyMiddleware } from 'http-proxy-middleware';
import logger from './common/logger.js';
import logger from './server/logger.js';
import { FIREBASE_AUTH_PROXY_PATH, firebaseAuthProxyTarget } from './common/firebase-auth-proxy.js';
dotenv.config({ quiet: true });
@@ -21,6 +22,18 @@ frontendApp.use('/api', createProxyMiddleware({
changeOrigin: true
}));
// Firebase's auth handler served first-party (api/AGENTS.md "Firebase Auth
// handler proxy"); ahead of the static layer and SPA fallback so it never
// resolves to index.html.
const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID);
if (firebaseAuthTarget) {
frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({
target: `${firebaseAuthTarget}${FIREBASE_AUTH_PROXY_PATH}`,
changeOrigin: true
}));
logger.info(`🛡️ Firebase Auth handler proxied: ${FIREBASE_AUTH_PROXY_PATH}/* → ${firebaseAuthTarget}`);
}
// Set static file directory.
// Cache-Control is set per-asset class so the static layer behaves well
// even when no CDN sits in front of it (CF in production sets its own
+166 -141
View File
@@ -6,180 +6,205 @@ Conventions specific to the Vue 3 SPA under `frontend/`. Universal rules
## Overview
Vue 3 `<script setup>` + Pinia + vue-router (HTML5 history) + Tailwind CSS v4
over shadcn-vue primitives (copied in, not a package). No TypeScript, no
`dark:` dual-pair utilities.
over copied-in shadcn-vue primitives. No TypeScript, no `dark:` dual pairs.
## Layout
```
frontend/
├── App.vue ← thin shell: global providers + <router-view>
├── main.js ← bootstrap + env-gated dynamic init (Sentry, Firebase)
├── store.js ← Pinia main store
├── firebase-init.js ← env-gated lazy Firebase Auth; boot path picked by the
│ utils/auth-hint.js flag (signed-in → gate mount on
│ auth; visitor → SDK never loads until sign-in)
├── sentry-init.js ← env-gated Sentry (see "Error monitoring" below)
├── router/ ← `/` Home · `/tools/:slug` StandaloneTool · `/privacy`
│ · `/r/:id` shared report (noindex)
│ (advanced tools also open in-page via `?tool=<slug>`)
├── locales/ ← en / zh / fr / ru + on-demand sub-packs
├── style/style.css ← Tailwind v4 entry + design tokens
├── lib/ ← cn() only (shadcn support layer)
├── data/ ← static config: achievements + achievement-rules /
│ ip-databases / sections / changelog / tools registry
│ (router + cards + drawer all derive from it) /
│ pulse-statuses (Earth Online vocabulary: presets +
│ date-windowed festival statuses + their celebration
│ effect mapping; recipes in utils/pulse-celebration.js)/
│ connectivity-import-lists (curated target sets; icons
│ are committed 64px PNGs under public/favicons/ — one
│ per member, enforced by its data test)
├── utils/ ← framework-agnostic helpers + IO
│ (app-events bus / getips/ / valid-ip / analytics / …)
├── composables/ ← Vue-aware `useXxx` logic
└── components/ ← Home / StandaloneTool / top-level sections, plus
ip-infos/ · advanced-tools/ · report/ · widgets/ ·
svgicons/ · ui/
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js;
│ PWA / blocked-popup redirect sign-in: utils/auth-redirect.js)
├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
├── data/ ← static config (tools registry drives pages+cards+nav)
├── lib/ · utils/ · composables/ ← see "Helper placement"
└── components/ ← sections + ip-infos/ advanced-tools/ report/ widgets/ svgicons/ ui/
```
Directory-level only — every file opens with a header comment stating its
purpose; read those for specifics.
Every file opens with a header comment stating its purpose — read those.
## Conventions
- **Composition API.** `<script setup>` everywhere; no Options API.
- **Path alias.** `@` → `frontend/`.
- **Shared-with-backend helpers live in `common/`**, re-exported through a
thin bridge in `utils/` so consumers keep `@/utils/...` imports (pattern:
`utils/valid-ip.js`, `utils/fetch-with-timeout.js`).
- **Helper placement:** needs Vue reactivity / lifecycle → `composables/`
(`useXxx`); otherwise → `utils/` (never `use-` prefixed). `lib/` stays
shadcn-only. A pure function living next to a composable is exported from
that composable's file, not promoted to its own.
- **Composition API** everywhere; no Options API. Alias `@` → `frontend/`.
- **Shared-with-backend helpers live in `common/`**, re-exported through a thin
`utils/` bridge so consumers keep `@/utils/...` imports (`utils/ip/valid-ip.js`).
- **Helper placement:** Vue reactivity / lifecycle → `composables/` (`useXxx`);
otherwise `utils/` (never `use-` prefixed). `lib/` stays shadcn-only. A pure
function next to a composable exports from that composable's file.
- **Inside `utils/`:** app-wide plumbing at its root; IP / ASN data shaping
and validation → `ip/`; the report pipeline → `report/`; the pure layer of
one feature (a tool, a section, Pulse) → `features/`; per-source probe
modules keep their own dirs (`getips/`, `dnsleaks/`, `persona/`).
### Achievements are event-driven
Components never touch the achievement system. They emit domain events
unconditionally — `emitAppEvent('speedtest:finished', {…})` on the
`utils/app-events.js` bus — and the pipeline downstream handles the rest:
`data/achievement-rules.js` maps events to achievement slugs (single place to
look for "what unlocks X"); `composables/use-achievement-engine.js` (init'd
once in App.vue) owns the signed-in / remote-sync / already-achieved / rate
guards (rules never evaluate until the remote achievements snapshot lands —
`store.userAchievementsSynced`; pre-sync hits are parked and re-checked).
New achievement = entry in `data/achievements.js` + rule + (only if no
suitable event exists) a new domain event. Tests:
`tests/achievement-rules.test.js`, `tests/composable-achievement-engine.test.js`.
Components never touch achievements — they emit domain events unconditionally
(`emitAppEvent('speedtest:finished', {…})`); `data/achievement-rules.js` maps
events → slugs, `composables/use-achievement-engine.js` owns all guards (rules
wait for the remote snapshot; pre-sync hits parked). New achievement = entry +
rule + (only if no suitable event exists) a new event. The shareable report
rides the same bus: `<domain>:finished` → `use-report-collector.js` →
`utils/report/report-builders.js` → sections whitelisted by `common/report-schema.js`.
New reportable test = event + builder + schema entry in one change; new result
semantics = builder whitelist + schema enum too (builders fail soft, fixtures
are frozen: drift = quietly missing fields). Report links are public: the
builder, not the renderer, drops visitor-supplied data (Persona Check: id / axis
/ verdict, never `detail`; Invisibility: key + flag).
The shareable diagnostic report rides the same bus: every "my network" test
emits `<domain>:finished` with its full structured result;
`composables/use-report-collector.js` (init'd once in App.vue) normalizes
payloads through `utils/report-builders.js` into sections whitelisted by
`common/report-schema.js`, and `components/report/` consumes the snapshots
(share dialog + read-only /r/:id page). New reportable test = emit event +
builder + schema entry, in the same change. Changing a test's result
semantics or an upstream field means updating that test's builder whitelist
+ schema enum too — builders fail soft (unknown values silently drop the
field) and test fixtures are frozen, so drift shows up as quietly missing
report fields, not as errors.
### Commands are the imperative twin of events
`utils/app-commands.js`: events say "this happened"; a command says "do this" —
one owner (`use-app-command.js`, scope-bound, setup-time), and
`dispatchAppCommand` resolves with its result. Payload = one plain JSON object
shaped at registration; rejections are `appCommandError(code, message)` with
reserved `auth` / `quota` / `input` (the bus adds `unavailable` / `timeout`).
Cross-component triggers use the bus, never template refs (refs stay for UI
chrome); tools register at setup, callers `waitForAppCommand` first.
### Advanced Tools are pages, kept alive
- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over `data/tools.js`),
and every opener routes there: cards are real `<a href>` (a plain click
`router.push`es, modified clicks stay the browser's), Nav, shortcuts
(`Advanced.vue`'s exposed `openTool`), in-app links. `/?tool=<slug>` is only
a replace-redirect in the router's `beforeEach` (`utils/legacy-tool-link.js`).
- Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` →
`cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()`
(card grid, Nav); a `/tools/:slug` link is never gated.
- `App.vue` keeps Home and `ToolPage` alive (`KeepAlive`); `ToolPage` keeps up
to 8 tools alive per slug, dropped on sign-out / account switch
(`utils/tool-cache.js`). Back / forward is plain history; the breadcrumb's
Home steps back when the previous entry is Home (`utils/back-target.js`), so
Home returns as left, scroll included.
- A cached page never unmounts, so whatever acts on the route, the document
head, window events or the store's one-shot triggers gates on
`use-route-active.js` (`useRouteActive` / `useActiveValue` /
`useActiveEventListener`), not on mount / unmount. Overlays on a page being
left are closed by `use-overlay-shortcuts.js` — their portal would outlive it;
App.vue's own (store `openSheet`, the `User` dialog) close on a path change.
- Homepage state reaches a tool page through the store, the app-events
collectors and the command bus: a cached Home keeps its commands registered,
so a caller dispatches in place and goes home first only when
`hasAppCommand()` says Home never mounted (Persona Check).
### ASN Profile
- One `/api/asn-profile` request, one loading state, then the whole page; a
section (reputation included — no `configs.originalSite` check) renders only
when its `status` is `ok`. `ASNConnectivity.vue` gets `:bordered="false"`
(the card draws the border) and keeps its expand-to-drawer; its defaults
keep IPCard / QueryIP unchanged.
- `peeringdb` is operator self-reporting: vocabularies → locale keys
(`PEERING_ENUMS`), names / cities as written. `ASN_PROFILE_TIMEOUT_MS` stays
above the largest backend section deadline (tested).
- ASN Info's footer links `/tools/asn?q=AS<n>` (hidden when gated off); in
QueryIP it emits `open-tool` via IpDetailPanel so the dialog closes first.
### Overlays take no keyboard shortcuts
One dispatcher (`utils/shortcut.js`) over the map `use-shortcuts.js` registers
(home route only; Home clears it when left; `registerShortcuts()` replaces it),
suspended while an overlay is open — keyed off form: the `ui/` roots `Dialog` /
`Sheet` / `Drawer` call `use-overlay-shortcuts.js`, so anything built on them
inherits it; overlays nest; Esc and native scrolling still work.
### Error monitoring (Sentry) is env-gated and invisible to app code
`sentry-init.js` loads via a build-time-gated dynamic import: no
`VITE_SENTRY_DSN_FRONTEND` → no Sentry code in the bundle at all (same
philosophy as `firebase-init.js`). Two rules:
- **Never import `@sentry/vue` in app code** — a static import would drag the
SDK back into the main bundle. All Sentry config lives in `sentry-init.js`.
- **Explicit signals go through the app-events bus**, like achievements: the
component emits, `sentry-init.js` subscribes. One signal is captured:
`ip-source:exhausted` (an IP card's whole source chain failed). Cards
report only when the `ipinfo:finished` snapshot shows some card resolved a
valid IP of the same version — otherwise "our chain failed" is
indistinguishable from visitor-side conditions (no IPv6 / dead network),
which is routine noise.
Capture surface: uncaught errors; `console.error` (fingerprinted on the first
argument, so a call site that fails several ways names the failure there —
`fetchErrorLabel` in `utils/authenticated-fetch.js` renders the HTTP status,
keeping an edge-blocked 403 out of the same issue as a 5xx; individual
`utils/getips/` source failures are `console.warn` — invisible to Sentry by
design, the per-card exhaustion event above is the health signal);
route-change traces;
error-only Replay, page text deliberately unmasked (the visitor's on-screen
network info IS the debugging context; typed input stays masked; disclosed
in the privacy policy). Third-party script errors (Cloudflare's RUM beacon)
are dropped via `denyUrls`.
Backend 5xx is deliberately NOT captured frontend-side — the backend SDK
reports its own failures. Envelopes ship through the first-party tunnel
`/api/monitoring` (`api/sentry-tunnel.js`) to beat ad blockers; source maps
upload at build, gated on `SENTRY_AUTH_TOKEN`.
No `VITE_SENTRY_DSN_FRONTEND`, or a DEV build → no Sentry loaded (gated dynamic
import, like `firebase-init.js`; all config in `sentry-init.js`). **Never import
`@sentry/vue` in app code. Explicit signals go through the app-events bus**:
`ip-source:exhausted` (a card's whole source chain failed), only when another
card resolved a valid IP of that version. Traps: `console.error` is captured,
fingerprinted on its first argument — name the failure there; `utils/getips/`
failures stay `console.warn`. Replay shows page text unmasked by design (input
masked; in the privacy policy). No backend 5xx capture; envelopes: `/api/monitoring`.
## UI system
**shadcn-vue first.** Check `components/ui/` (21 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` /
`toggle-group` deliberately use the `primary` pair for the pressed state —
don't revert that when syncing upstream.
**shadcn-vue first:** `components/ui/`, then the shadcn-vue docs; hand-rolled
Tailwind only when neither fits. Keep on upstream syncs: `Spinner` +
`ToolLoadingSkeleton`, `toggle` / `toggle-group`'s `primary` pressed pair, the
overlay roots' shortcut suspension, `select`'s trigger geometry (`py-1` + flex
value span, not `-webkit-box` — Safari shifts it ~2px). CLI trap: `shadcn-vue
add` here writes `import { cn } from "cn"` and installs the unrelated `cn`
package — drop the dependency, import from `@/lib/utils`.
### Design tokens
Top of `style/style.css`; four business-semantic colors on top of shadcn
defaults, each with a paired `-foreground`:
`--info` (waiting / in-progress) · `--success` (ok-fast) · `--warning`
(ok-slow) · `--action` (the "run / trigger" brand color)
Rule: semantic tokens only (`bg-info` / `bg-action` / `bg-muted` /
`text-muted-foreground` / …). Never write `dark:` dual pairs — tokens theme
themselves.
Button adds `action` and `success` variants to the shadcn set; Badge adds
`success` and has hover globally disabled (display element — wrap it for
interactivity). FAB colors express semantics, never decoration: `action` =
trigger, `default` = stateless panels, `success` = protective state active,
`secondary` = dock controls; at most two accents visible at once.
`style/style.css`: `--info` (waiting) · `--success` (ok-fast) · `--warning`
(ok-slow; `/50` for ok-mild) · `--action` (run), each with `-foreground`; semantic tokens only,
never `dark:` pairs. Button adds `action` / `success`; Badge adds `success`,
hover disabled (wrap it to interact). FAB colors: `action` trigger, `default`
stateless panel, `success` protective state on, `secondary` dock; ≤ 2 accents.
### Status tones
Every "business state → color" mapping goes through
`composables/use-status-tone.js` (`wait` / `ok-fast` / `ok-slow` / `fail`),
normally via its `ipFieldTone()` helper. No hand-rolled state→color switches.
Every "business state → color" mapping goes through `use-status-tone.js`
(`wait` / `ok-fast` / `ok-mild` / `ok-slow` / `fail`), normally via `ipFieldTone()`.
### Canonical patterns
### Canonical patterns — copy the named exemplar, don't re-invent
Copy from the named exemplar instead of re-inventing:
- **Trigger button** — `variant="action"` + `<Spinner v-if />` + `:disabled`
(QueryIP, MacChecker, Whois, …).
- **Input + icon trigger** — flex row, compact icon Button (lucide `Search`),
no text label (QueryIP / Whois / DnsResolver).
- **AutoFill-proof inputs** — every free-form Input carries all six:
`autocomplete="off" autocorrect="off" autocapitalize="off"
spellcheck="false" data-1p-ignore data-lpignore="true"`, and placeholder
copy avoids "address / 地址 / adresse / adresi" — iOS QuickType keys on the
word itself even with autocomplete off.
- **Status card** — `keyboard-shortcut-card jn-card` markers + hover-lift
transition (Connectivity / WebRTC / IPCard). `jn-card` = shadow / border /
keyboard outline; `keyboard-shortcut-card` = J/K navigation target.
- **Trigger button** — `variant="action"` + `<Spinner v-if />` + `:disabled` (QueryIP, Whois).
- **Input + icon trigger** — flex row, compact icon Button, no text label (QueryIP).
- **AutoFill-proof inputs** — all six on every free-form Input: `autocomplete`
/ `autocorrect` / `autocapitalize="off"`, `spellcheck="false"`,
`data-1p-ignore`, `data-lpignore="true"`; placeholders avoid "address / 地址
/ adresse / adresi" (iOS QuickType keys on the word).
- **Status card** — `keyboard-shortcut-card` (J/K target) `jn-card` (shadow /
border / outline) + hover lift (IPCard).
- **Flag** — always `<Icon :icon="'circle-flags:' + code.toLowerCase()" />`.
- **Fit-to-width tokens** — IP / MAC strings render inside `<FitText>`
(`HERO_TIERS` hero rows, `INLINE_TIERS` compact rows; `:max-lines="2"` on
heroes). Never per-component length-threshold helpers (IPCard, QueryIP).
- **Dates & times** — via `utils/time-utils.js` + the vue-i18n locale; no
hand-rolled `Intl` / `toLocaleDateString` without a why-comment.
- **Fit-to-width tokens** — IP / MAC strings in `<FitText>` (`HERO_TIERS` /
`INLINE_TIERS`; `:max-lines="2"` on heroes); no length-threshold helpers.
- **Filter tags** — `ToggleGroup :spacing="2"` of detached `h-7 rounded-full`
pills, `w-full flex-wrap` (IPHistory, DnsResolver); never `spacing=0`.
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
pathOf: () => route.path })` (immediate, so mount, later changes and a `q`
changed while cached all run; another page's `q` never does), `router.replace`
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`. A `q` runs
the tool only when a run needs no sign-in or quota (AsnProfile,
IpCalculator); otherwise it just prefills (IpBlocklist).
- **Fixed option sets** — a closed list wider than one line is a `Select`.
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
- **Tables vs lists** — real per-column header semantics → `<table>`;
otherwise a bordered `<ul class="rounded-lg border bg-card divide-y">`.
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
- **Drawer vs Sheet** — the vaul-vue bottom Drawer is reserved for the
Advanced Tools panel; side panels use `Sheet`.
- **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed;
`index.html` pads the body for it), never per page; `User` / `Preferences`
sit beside it, one each. What a click does on the current route is
`resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
no section is highlighted off Home.
- The link row collapses to the hamburger + Sheet when `use-nav-overflow.js`
measures it doesn't fit (no breakpoint); `isMobile` only drives phone behaviour.
- **Footer** — four columns on the Advanced Tools grid: brand / Project / Docs
& Support / Clients (only the CLI entry is gated, on the curl domains);
never repeats on-page content (tools, sections); About / Changelog /
Acknowledgement open in its About sheet, the CLI in its own dialog
(`Footer.vue`).
- **Page breadcrumb** — every page but Home opens its body with
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
helper, a bare last crumb `aria-current="page"`. A tool page ends on the
Advanced Tools crumb: its `<h1>` already names the tool.
- **Page transitions** — View Transitions hooked in the router's
`beforeResolve` (`utils/page-transition.js`: Home → page slides forward,
→ Home back, page → page fades; keyframes in `style/style.css`); none on a
query-only change, the first load, reduced motion or a history step the
browser animated itself. Only the Nav has a `view-transition-name` (it
stays still). After a history step, `utils/revive-animations.js` replays
the page's running CSS animations: iOS swipe-back leaves them frozen.
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
`hidden sm:flex` never shows: write `max-sm:hidden`.
- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an
inline visual (ASNConnectivity's graph); side panels use `Sheet`; a tool is
a page, never an overlay.
- **Motion** — hover lift `transition-transform duration-300 ease-out
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dot clusters.
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots.
## Testing
Composables and utils are the target (`tests/composable-*.test.js` and
friends). Vue rendering / browser APIs are out of scope for the Node runner.
Visual changes can't be self-tested — say so and let the user verify in
`pnpm dev`.
Composables and utils are the target (`tests/composable-*.test.js`); rendering /
browser APIs are out of scope — visual changes need the user in `pnpm dev`.
+43 -30
View File
@@ -1,62 +1,70 @@
<template>
<!-- Thin app shell: only the globals that must exist on every route live
here (tooltip context, toast host, PWA install prompt, theme). The
homepage and the standalone tool pages are swapped in via <router-view>. -->
<TooltipProvider :delay-duration="150">
<router-view />
<Alert />
<DocsAssistant />
<PWA v-if="offerPwaInstall" />
</TooltipProvider>
<!-- Thin app shell: the globals every route shares (Nav, account and
preferences dialogs, tooltip context, toast host, PWA prompt, theme).
Home and ToolPage stay alive, so coming back neither re-runs the tests nor
loses a tool's state; keyed by route name, every tool route shares one
ToolPage, which caches each tool per slug itself. -->
<TooltipProvider :delay-duration="150">
<NavBar />
<router-view v-slot="{ Component, route: viewRoute }">
<KeepAlive :include="['Home', 'ToolPage']">
<component :is="Component" :key="viewRoute.name" />
</KeepAlive>
</router-view>
<!-- Dialog hosts for the Nav's account menu and the tools' quota hints. -->
<User />
<Preferences />
<Alert />
<DocsAssistant />
<PWA v-if="offerPwaInstall" />
</TooltipProvider>
</template>
<script setup>
import { watch, ref, onMounted, defineAsyncComponent } from 'vue';
import { useRoute } from 'vue-router';
import { TooltipProvider } from '@/components/ui/tooltip';
import NavBar from '@/components/Nav.vue';
import Alert from '@/components/widgets/Toast.vue';
import DocsAssistant from '@/components/widgets/DocsAssistant.vue';
import { shouldOfferPwaInstall } from '@/utils/pwa.js';
import { sendVisitBeacon } from '@/utils/pulse-beacon.js';
import { sendVisitBeacon } from '@/utils/features/pulse-beacon.js';
import { useTheme } from '@/composables/use-theme.js';
import { useMainStore } from '@/store';
// 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'));
// Dialogs nothing shows on first paint: their code stays off the boot path.
const User = defineAsyncComponent(() => import('@/components/User.vue'));
const Preferences = defineAsyncComponent(() => import('@/components/widgets/Preferences.vue'));
const offerPwaInstall = ref(false);
onMounted(() => {
if (shouldOfferPwaInstall()) {
setTimeout(() => { offerPwaInstall.value = true; }, 30 * 1000);
}
// Earth Online visit tick — app-level so every route counts, once per
// page load; the backend dedups per IP (utils/pulse-beacon.js).
// page load; the backend dedups per IP (utils/features/pulse-beacon.js).
sendVisitBeacon();
});
import { useAchievementEngine } from '@/composables/use-achievement-engine.js';
import { useReportCollector } from '@/composables/use-report-collector.js';
import { useAppPersonaCollector } from '@/composables/use-persona-collector.js';
// The standalone pages (/tools/:slug, /privacy) carry their own header, so they
// drop the homepage's fixed-Nav body padding (see the `body.jn-standalone-page`
// rule in index.html) — otherwise a blank strip shows above their header. Toggle
// the marker class as the route changes. NB: "standalone" here is unrelated to
// PWA display mode — that's `isRunningAsPwa()` in utils/pwa.js.
const STANDALONE_ROUTES = new Set(['tool', 'privacy', 'report']);
// A page change closes store.openSheet: a site-wide sheet would stay open over
// the next page, and one a left page hosted would reopen when it returns.
const store = useMainStore();
const route = useRoute();
watch(
() => STANDALONE_ROUTES.has(route.name),
(isStandalone) => {
document.body.classList.toggle('jn-standalone-page', isStandalone);
},
{ immediate: true },
);
watch(() => route.path, () => {
if (store.openSheet) store.setOpenSheet(null);
});
// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html.
// #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque
// overlay while the overlay plays its exit (text fade → logo shrink →
// removal).
// Runs once at root mount, so it covers both the homepage and a fresh load of
// a standalone tool page.
// removal). Runs once at root mount, whichever page loads first.
const loadingElement = document.getElementById('jn-loading');
const appElement = document.getElementById('app');
@@ -84,6 +92,11 @@ useAchievementEngine();
// Report collector: keeps the latest schema-shaped snapshot of every finished
// test for the shareable diagnostic report.
useReportCollector();
// Persona collector: same bus, different consumer — keeps the normalized
// snapshots the Persona Check cross-references, so opening the tool costs no
// re-run of tests the visitor already went through.
useAppPersonaCollector();
</script>
<style scoped></style>
+7 -4
View File
@@ -113,7 +113,7 @@ import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Award, CircleCheck, CircleX } from '@lucide/vue';
const { t } = useI18n();
const { t, locale } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
@@ -132,7 +132,7 @@ const notAchievedList = computed(() =>
const achievedCount = computed(() => achievedList.value.length);
const notAchievedCount = computed(() => notAchievedList.value.length);
const convertTime = (timestamp) => timestamp == null ? '' : unixToDateTime(timestamp);
const convertTime = (timestamp) => timestamp == null ? '' : unixToDateTime(timestamp, locale.value);
const isOpen = computed(() => store.openSheet === 'achievements');
const onOpenChange = (val) => {
@@ -151,12 +151,15 @@ const flipCard = (achievementName) => {
}
};
// Immediate: UserMenu raises the request from another page and goes Home
// first, and this async component can mount after the flag is already set.
watch(() => triggerAchievements.value, (newVal) => {
if (newVal) openAchievements();
if (!newVal) return;
openAchievements();
if (!remoteUserInfoFetched.value) {
store.setTriggerRemoteUserInfo(true);
}
});
}, { immediate: true });
</script>
<style scoped>
-108
View File
@@ -1,108 +0,0 @@
<template>
<!-- Curl Dialog -->
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent :title="t('curl.Title')">
<DialogHeader :icon="Terminal" :title="t('curl.Title')" />
<div v-if="curlDomainsHadSet" class="space-y-3">
<!-- Description -->
<div class="space-y-1 text-xs font-mono">
<p class="jn-comment"><span class="text-muted-foreground">{{ t('curl.Note1') }}</span></p>
<p class="jn-comment">
<span class="text-muted-foreground">{{ t('curl.Note2_1') }}
<Badge variant="outline" class="text-success">curl</Badge> {{ t('curl.Note2_2') }}</span>
</p>
<p class="jn-comment">
<span class="text-muted-foreground"><Badge variant="outline" class="text-success">geo</Badge> {{ t('curl.Note3') }}</span>
</p>
</div>
<!-- 3 curl command blocks -->
<div class="space-y-3">
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv4') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv4Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv6') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv6Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.get6and4') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv64Domain }}<span class="text-success">/geo</span></pre>
</div>
</div>
</div>
<div v-else class="py-6 text-center">
<p class="text-sm text-muted-foreground">{{ t('curl.notAvailable') }}</p>
</div>
</DialogContent>
</Dialog>
<!-- Additional Tools: external product link bar -->
<div class="mx-auto text-center max-w-[98%]">
<div id="morefromipchecking" class="flex justify-center items-center gap-2 sm:gap-4 flex-wrap">
<a href="https://www.raycast.com/jason5ng32/ipcheck-ing" target="_blank" rel="noopener"
@click="trackEvent('Additional', 'AdditionalClick', 'Raycast')">
<img src="/additional/raycast.webp" alt="IPCheck.ing on Raycast"
class="w-[108px] sm:w-[180px] h-auto">
</a>
<button type="button" @click="openCurlModal"
aria-label="IPCheck.ing for Curl"
class="cursor-pointer bg-transparent border-0 p-0">
<img src="/additional/curl.webp" alt="IPCheck.ing for Curl"
class="w-[108px] sm:w-[180px] h-auto">
</button>
<a href="https://lite.ipcheck.ing" target="_blank" rel="noopener"
@click="trackEvent('Additional', 'AdditionalClick', 'Lite')">
<img src="/additional/lite.webp" alt="IPCheck.ing lite"
class="w-[108px] sm:w-[180px] h-auto">
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Terminal } from '@lucide/vue';
import { Badge } from '@/components/ui/badge';
const { t } = useI18n();
const store = useMainStore();
const ipv4Domain = computed(() => store.curl.ipv4Domain);
const ipv6Domain = computed(() => store.curl.ipv6Domain);
const ipv64Domain = computed(() => store.curl.ipv64Domain);
const curlDomainsHadSet = computed(() => store.curlDomainsHadSet);
const isOpen = ref(false);
const openCurlModal = () => {
isOpen.value = true;
trackEvent('Additional', 'AdditionalClick', 'Curl');
};
</script>
<style scoped>
.jn-curl::before {
content: '$ ';
color: var(--muted-foreground);
font-weight: 500;
margin-right: 0.25rem;
opacity: 0.7;
}
.jn-comment::before {
content: '// ';
color: var(--muted-foreground);
font-weight: 500;
opacity: 0.7;
}
</style>
+54 -155
View File
@@ -13,170 +13,93 @@
</div>
</header>
<!-- Card grid. Each card is a real <a> to the standalone /tools/:slug
page, so ⌘/Ctrl-click, middle-click and "open in new tab" all work.
A plain left-click (or Enter / Space) is intercepted to open the
in-page drawer instead. A dedicated ↗ corner button always opens the
standalone page in a new tab. -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<Card v-for="card in enabledCards" :key="card.slug"
:data-adv-slug="card.slug"
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<a :href="`/tools/${card.slug}`"
class="block cursor-pointer no-underline text-inherit"
@click="onCardClick($event, card.slug)"
@keydown.enter.prevent="openTool(card.slug)"
@keydown.space.prevent="openTool(card.slug)">
<CardContent class="p-4">
<h3 class="text-xl font-medium text-primary mb-2 pr-10">
<PanelBottomOpen
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
{{ t(card.titleKey) }}
</h3>
<!-- Description -->
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
{{ t(card.noteKey) }}
</p>
<!-- Top right emoji -->
<span class="jn-emoji" aria-hidden="true">{{ card.icon }}</span>
</CardContent>
</a>
</Card>
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
card is a real <a> to /tools/:slug: modified clicks stay the
browser's, a plain click / Enter / Space routes in-app. -->
<div class="mt-4 space-y-6">
<div v-for="group in cardGroups" :key="group.id">
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
{{ t(group.titleKey) }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<Card v-for="card in group.tools" :key="card.slug"
:data-adv-slug="card.slug"
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<a :href="`/tools/${card.slug}`"
class="block cursor-pointer no-underline text-inherit"
@click="onCardClick($event, card.slug)"
@keydown.enter.prevent="openTool(card.slug)"
@keydown.space.prevent="openTool(card.slug)">
<CardContent class="p-4">
<h4 class="text-xl font-medium text-primary mb-2 pr-10">
<ArrowRight
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
{{ t(card.titleKey) }}
</h4>
<!-- Description -->
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
{{ t(card.noteKey) }}
</p>
<!-- Top right emoji -->
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
</CardContent>
</a>
</Card>
</div>
</div>
</div>
<!-- Tool details Drawer -->
<Drawer :open="isOpen" @update:open="onOpenChange" :dismissible="true">
<DrawerContent :title="activeTool ? t(activeTool.titleKey) : t('advancedtools.Title')"
:safe-area-top="isMobile || isFullScreen"
:class="['jn-tools-drawer overflow-hidden', (isMobile || isFullScreen) ? 'h-full rounded-none' : 'h-[85vh]']">
<!-- Drawer internal header -->
<div class="flex items-center gap-2 px-4 pt-1 pb-3 jn-drawer-header shrink-0">
<button v-if="!isMobile" type="button"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="fullScreen" :aria-label="isFullScreen ? 'Exit full screen' : 'Full screen'">
<Maximize v-if="!isFullScreen" class="size-4" />
<Minimize v-else class="size-4" />
</button>
<span v-if="activeTool" class="flex-1 text-base md:text-lg font-medium truncate"
:class="isMobile ? 'text-left' : 'text-center'">
<span class="mr-1">{{ activeTool.emoji }}</span>{{ t(activeTool.titleKey) }}
</span>
<span v-else class="flex-1" />
<!-- Open the current tool as a standalone page (hidden in a
PWA window, which has no new-tab affordance) -->
<a v-if="activeTool && !isPwa" :href="`/tools/${activeTool.slug}`" target="_blank" rel="noopener"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="t('advancedtools.OpenInNewTab')" :aria-label="t('advancedtools.OpenInNewTab')">
<SquareArrowOutUpRight class="size-4" />
</a>
<DrawerClose
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" />
</div>
<!-- Content area (scrollable) -->
<div class="flex-1 overflow-y-auto px-1 md:px-2 pb-6" ref="scrollContainer">
<div :class="isMobile ? 'w-full px-3' : 'jn-canvas-width px-6'">
<component :is="activeComponent" v-if="activeComponent" />
</div>
</div>
</DrawerContent>
</Drawer>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="advanced" />
</section>
</template>
<script setup>
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { ADVANCED_TOOLS, TOOL_BY_SLUG } from '@/data/tools.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
import { listedTools } from '@/utils/tool-availability.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { Card, CardContent } from '@/components/ui/card';
import { Maximize, Minimize, PanelBottomOpen, SquareArrowOutUpRight } from '@lucide/vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { ArrowRight } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Running as an installed PWA → hide the "open in new tab" affordance (a PWA
// window has no tab strip; it would pop out to the browser). See utils/pwa.js.
const isPwa = isRunningAsPwa();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
const scrollContainer = ref(null);
const route = useRoute();
const router = useRouter();
// Cards derive from the shared tool registry (frontend/data/tools.js). `icon`
// is mapped from the registry's `emoji` so the template key stays stable.
const cards = ADVANCED_TOOLS.map((tool) => ({ ...tool, icon: tool.emoji }));
// Card groups behind the deployment gates (original site / configs flag, see
// utils/tool-availability.js). Reactive on configs, so gated cards appear the
// moment configs land.
const cardGroups = computed(() => groupToolsByCategory(
listedTools(ADVANCED_TOOLS, configs.value),
));
// Gate: the invisibility + enhanced DNS-leak tools only show on the original
// site (they need the private API + sign-in). Reactive on configs, so they
// appear the moment configs land — no fixed timeout needed.
const enabledCards = computed(() =>
cards.filter((c) => !c.requiresOriginalSite || configs.value.originalSite),
);
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
const activeTool = computed(() => {
const slug = route.query.tool;
return (typeof slug === 'string' && TOOL_BY_SLUG.get(slug)) || null;
});
const isOpen = computed(() => !!activeTool.value);
// Resolve each tool's lazy component once and cache it, so re-renders don't
// rebuild the async wrapper (which would remount the tool).
const asyncToolCache = new Map();
const activeComponent = computed(() => {
const tool = activeTool.value;
if (!tool) return null;
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent(tool.component));
}
return asyncToolCache.get(tool.slug);
});
const isFullScreen = ref(false);
// Open a tool in the in-page drawer (just sets the query; isOpen reacts).
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
const openTool = (slug) => {
router.push({ path: '/', query: { tool: slug } });
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
router.push(`/tools/${slug}`);
trackEvent('Nav', 'NavClick', slug);
};
// Card left-click: open the drawer. Modifier / middle clicks fall through to
// the <a href> default so the browser opens the standalone page in a new tab.
const onCardClick = (e, slug) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
if (!isPlainClick(e)) return;
e.preventDefault();
openTool(slug);
};
const onOpenChange = (val) => {
// Drawer closed (drag / overlay / Esc / close button) → drop the ?tool query.
if (!val) {
if (route.query.tool) router.push({ path: '/', query: {} });
isFullScreen.value = false;
}
};
// Full screen toggle: height determined by DrawerContent's class
const fullScreen = () => {
isFullScreen.value = !isFullScreen.value;
};
onMounted(() => {
store.setMountingStatus('AdvancedTools', true);
});
defineExpose({
openTool, fullScreen,
openTool,
});
</script>
@@ -200,28 +123,4 @@ defineExpose({
:global(.dark) .jn-adv-card:hover .jn-emoji {
text-shadow: 0 0 10pt rgb(255 255 255 / 0.15);
}
/* Drawer content area width (desktop) */
.jn-canvas-width {
width: fit-content;
margin: auto;
max-width: 1400px;
}
.jn-drawer-header {
border-bottom: 1px solid var(--border);
}
/* Drawer root container needs flex-col, so that the header is fixed + content scrollable */
.jn-tools-drawer {
display: flex;
flex-direction: column;
}
/* Full screen toggle height transition */
:global(.jn-tools-drawer) {
transition:
transform 0.5s cubic-bezier(0.32, 0.72, 0, 1),
height 0.3s cubic-bezier(0.32, 0.72, 0, 1) !important;
}
</style>
+299 -128
View File
@@ -6,12 +6,38 @@
class="m-0 flex min-w-0 flex-1 items-center gap-2 text-xl md:text-3xl font-semibold tracking-tight leading-tight">
🚦 {{ t('connectivity.Title') }}
</h2>
<JnTooltip :text="t('Tooltips.RefreshConnectivityTests')" side="left">
<Button size="icon" variant="outline" class="shrink-0 cursor-pointer" @click="handelCheckStart('manual')"
aria-label="Refresh Connectivity Test">
<component :is="isStarted ? RotateCw : Play" />
</Button>
</JnTooltip>
<!-- List picker + refresh as one joined button group. The picker
half only exists once a second list does; list management
lives at the bottom of the menu. -->
<div class="flex shrink-0 items-center">
<DropdownMenu v-if="lists.length > 1">
<DropdownMenuTrigger as-child>
<Button variant="outline" class="max-w-40 cursor-pointer gap-1.5 rounded-r-none border-r-0 px-3">
<span class="min-w-0 truncate">{{ activeList ? listName(activeList) : '—' }}</span>
<ChevronDown class="size-4 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="list in lists" :key="list.id" class="cursor-pointer"
@select="switchList(list.id)">
<Check class="size-4" :class="list.id === activeListId ? '' : 'invisible'" />
<span class="min-w-0 max-w-48 truncate">{{ listName(list) }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="openAddDialog('lists')">
<Settings2 class="size-4" />
<span>{{ t('connectivity.lists.Manage') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<JnTooltip :text="t('Tooltips.RefreshConnectivityTests')" side="left">
<Button size="icon" variant="outline" class="shrink-0 cursor-pointer"
:class="lists.length > 1 ? 'rounded-l-none' : ''" @click="handelCheckStart('manual')"
aria-label="Refresh Connectivity Test">
<component :is="isStarted ? RotateCw : Play" />
</Button>
</JnTooltip>
</div>
</div>
<div class="text-base text-muted-foreground">
<p v-if="!isSimpleMode">{{ t('connectivity.Note') }}</p>
@@ -22,19 +48,28 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card v-for="test in connectivityTests" :key="test.id"
class="keyboard-shortcut-card group relative transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50 jn-card">
<!-- Custom-card remove button, fades in on hover -->
<button v-if="test.custom" type="button" @click.stop="removeCustomTarget(test.id)"
class="absolute top-1.5 right-1.5 p-1 rounded-md text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-opacity cursor-pointer"
<!-- Remove button, fades in on hover. Two-step confirm: first click
arms "X?" (self-reverting), the second deletes; on the last
remaining target the click just raises a toast. -->
<button type="button" @click.stop="handleRemoveClick(test.id)"
class="absolute top-1.5 right-1.5 p-1 rounded-md inline-flex items-center transition-opacity cursor-pointer"
:class="confirmingRemoveId === test.id
? 'text-destructive bg-muted'
: 'text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted'"
:aria-label="t('connectivity.addCustom.Remove')" :title="t('connectivity.addCustom.Remove')">
<X class="size-3.5" />
<span v-if="confirmingRemoveId === test.id" class="text-xs font-semibold leading-none">?</span>
</button>
<CardContent class="p-4">
<!-- Site favicon (built-in / imported) or first-letter tile (custom) + name.
Favicons are same-origin (public/favicons/), so they render even
when the tested site is unreachable; a load error falls back to
the letter tile. -->
<div class="flex items-center gap-2 mb-3 cursor-pointer"
@click.prevent="checkConnectivityHandler(test, () => { }, true)" :title="t('connectivity.RefreshThisTest')">
<!-- Favicon (letter-tile fallback) + name. With the open-website
preference on and a resolvable site URL this is a real <a>;
otherwise plain text — per-card refresh lives on the ms figure.
Favicons are same-origin, so they render even when the site is
blocked. -->
<component :is="cardHref(test) ? 'a' : 'div'" v-bind="cardHref(test) ? {
href: cardHref(test), target: '_blank', rel: 'noopener noreferrer', 'data-card-open': '',
title: t('connectivity.OpenSite'),
} : {}" class="flex items-center gap-2 mb-3 min-w-0" :class="cardHref(test) ? 'cursor-pointer' : ''">
<img v-if="test.favicon && !test.faviconFailed" :src="test.favicon" alt=""
@error="test.faviconFailed = true"
class="size-6 shrink-0 rounded-md border bg-background object-contain p-0.5" loading="lazy" />
@@ -42,32 +77,45 @@
class="size-6 shrink-0 rounded-lg inline-flex items-center justify-center text-xs font-semibold text-muted-foreground border">
{{ (test.name || '?').charAt(0).toUpperCase() }}
</span>
<span class="text-base font-medium truncate">{{ test.name }}</span>
</div>
<!-- Status + ms. Multi mode pins these to the best round (see checkConnectivityHandler). -->
<div class="flex items-center justify-between gap-2">
<span class="flex items-center gap-1.5 text-base min-w-0">
<span class="text-base font-medium truncate"
:class="cardHref(test) ? 'hover:underline underline-offset-4' : ''">{{ test.name }}</span>
</component>
<!-- Status + ms (multi mode pins these to the best round). The ms
figure doubles as the per-card retest trigger; failed cards
get a retry icon in the same slot. -->
<div class="flex items-center justify-between gap-2 text-sm md:text-base">
<span class="flex items-center gap-1.5 min-w-0">
<span v-if="toneOf(test) === 'wait'" class="relative flex shrink-0">
<span class="absolute inline-flex size-2 rounded-full bg-info opacity-75 animate-ping"></span>
<span class="relative inline-flex size-2 rounded-full" :class="dotClass(toneOf(test))"></span>
</span>
<component v-else-if="statusFaceIcon(test)" :is="statusFaceIcon(test)" class="size-4 shrink-0"
:class="textClass(toneOf(test))" />
<span :class="textClass(toneOf(test))" class="font-mono whitespace-nowrap min-w-0">{{ test.status
<span :class="textClass(toneOf(test))" class="min-w-0 truncate" :title="test.status">{{ test.status
}}</span>
</span>
<span v-if="test.time !== 0" class="text-base font-mono tabular-nums text-muted-foreground"
:title="t('connectivity.minTestTime') + test.mintime + ' ms'">
{{ test.time }}<span class="ml-0.5 text-sm">ms</span>
</span>
<button v-if="test.time !== 0" type="button"
class="shrink-0 font-mono tabular-nums text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
@click="checkConnectivityHandler(test, () => { }, true)"
:aria-label="t('connectivity.RefreshThisTest')"
:title="t('connectivity.RefreshThisTest') + ' · ' + t('connectivity.minTestTime') + test.mintime + ' ms'">
{{ test.time }}<span class="ml-0.5 text-xs md:text-sm">ms</span>
</button>
<button v-else-if="toneOf(test) === 'fail'" type="button"
class="shrink-0 p-1 -m-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
@click="checkConnectivityHandler(test, () => { }, true)"
:aria-label="t('connectivity.RefreshThisTest')" :title="t('connectivity.RefreshThisTest')">
<RotateCw class="size-3.5" />
</button>
</div>
<!-- Multi-test per-round progress dots (historical snapshot). -->
<div v-if="multipleTests" class="flex items-center gap-1 mt-2">
<!-- Multi-test per-round latency bars, all on one absolute scale
(taller = slower, see barStyle); failed / pending rounds are
short gray stubs. -->
<div v-if="multipleTests" class="flex items-end gap-1 mt-2 h-4">
<JnTooltip v-for="i in totalRounds" :key="i" :text="roundTooltipText(test, i - 1)" side="top">
<span class="group/dot inline-flex items-center justify-center p-1 -m-1 cursor-default">
<span
class="size-1.5 rounded-full md:transition-transform md:duration-200 md:group-hover/dot:scale-[2.0]"
:class="progressDotClass(test, i - 1)"></span>
<span class="group/bar inline-flex h-full items-end justify-center px-1 -mx-1 cursor-default">
<span class="w-1.5 rounded-[2px] md:transition-[height,filter] md:duration-200 md:group-hover/bar:brightness-125"
:class="barClass(test, i - 1)" :style="barStyle(test, i - 1)"></span>
</span>
</JnTooltip>
</div>
@@ -75,13 +123,12 @@
</Card>
<!-- "Add Test" tile: stacked flag/brand icons signal that curated
lists live behind it, not just a blank form. Always visible —
at the cap the dialog is still the way to remove imported lists. -->
<Card @click="addDialogOpen = true"
lists live behind it, not just a blank form. -->
<Card @click="openAddDialog('import')"
class="cursor-pointer border-dashed bg-transparent hover:bg-muted/50 transition-colors"
:title="t('connectivity.addCustom.AddCard')">
<CardContent class="p-4 flex flex-col items-center justify-center gap-2 text-muted-foreground"
:class="multipleTests ? 'min-h-[106px]' : 'min-h-[92px]'">
:class="multipleTests ? 'min-h-29' : 'min-h-23'">
<span class="flex -space-x-2">
<template v-for="item in TILE_PREVIEW" :key="item.emoji || item.id">
<span v-if="item.type === 'emoji'"
@@ -98,8 +145,11 @@
</Card>
</div>
<!-- Add / import dialog (custom form + curated list browser) -->
<ConnectivityAddDialog v-model:open="addDialogOpen" :builtin-urls="builtinUrls" />
<!-- Add / import / manage-lists dialog -->
<ConnectivityAddDialog v-model:open="addDialogOpen" :initial-tab="dialogTab" :active-list-id="activeListId" />
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="connectivity" :settled="hasEverSettled" />
</section>
</template>
@@ -108,16 +158,22 @@ import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue'
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js';
import { TILE_PREVIEW, faviconPath } from '@/data/connectivity-import-lists.js';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
import { CONNECTIVITY_STATUS } from '@/utils/report/report-schema.js';
import { TILE_PREVIEW, faviconPath, MINE_LIST_ID } from '@/data/connectivity-import-lists.js';
import { siteUrlOf, removeMember } from '@/utils/features/connectivity-lists.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import ConnectivityAddDialog from '@/components/widgets/ConnectivityAddDialog.vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js';
import {
Play, Frown, Meh, Plus, RotateCw, Smile, X,
Check, ChevronDown, Play, Frown, Meh, Plus, RotateCw, Settings2, Smile, X,
} from '@lucide/vue';
const { t } = useI18n();
@@ -132,6 +188,8 @@ const alertMessage = ref("");
const multipleTests = ref(userPreferences.value.connectivityMultipleTests);
const autoShowAltert = ref(userPreferences.value.popupConnectivityNotifications);
const isStarted = ref(false);
// Sticky settled flag for the section's banner slot: true once a pass settles.
const hasEverSettled = ref(false);
const counter = ref(0);
const maxCounts = ref(9);
const manualRun = ref(false);
@@ -141,75 +199,88 @@ const totalRounds = computed(() => 1 + maxCounts.value);
const allRoundsDone = ref(false);
const alertFired = ref(false);
// Built-in targets first; custom / imported ones (`.custom = true`) are
// merged in by the watcher below. Keeping both in one reactive array lets
// every iterator (run-loop, keyboard nav) treat tiles uniformly.
// `roundResults` records per-round { tone, time } for the progress dots,
// independent of the best-of-N face/text. `time` powers the per-dot hover
// tooltip; for `tone: 'fail'` rounds it stays 0. Bootstrap-only writer.
// Icons come from the committed same-origin favicon set (public/favicons/).
const builtinTarget = (id, name, url) => ({
id, name, url, favicon: faviconPath(id), status: t('connectivity.StatusWait'), time: 0, mintime: 0, roundResults: [],
// ── Lists ──────────────────────────────────────────────────────────────────
// One list renders (and tests) at a time.
const lists = computed(() => userPreferences.value.connectivityLists?.lists || []);
// Open on the preferred default list (in-page switching is per-visit
// only); a stale preference falls back to Mine.
const initialListId = () => {
const preferred = userPreferences.value.connectivityDefaultListId;
return lists.value.some((l) => l.id === preferred) ? preferred : MINE_LIST_ID;
};
const activeListId = ref(initialListId());
const activeList = computed(() => lists.value.find((l) => l.id === activeListId.value));
// Mine's stored name is null — its display name is localized.
const listName = (list) => (list.id === MINE_LIST_ID ? t('connectivity.lists.Mine') : list.name);
const switchList = (id) => {
if (id === activeListId.value) return;
activeListId.value = id;
trackEvent('Section', 'SwitchConnectivityList', 'Connectivity');
};
// A deleted active list falls back to Mine instead of an empty grid.
watch(lists, (all) => {
if (!all.some((l) => l.id === activeListId.value)) activeListId.value = MINE_LIST_ID;
});
const connectivityTests = reactive([
builtinTarget('google', 'Google', 'https://www.google.com/favicon.ico'),
builtinTarget('youtube', 'YouTube', 'https://www.youtube.com/favicon.ico'),
builtinTarget('github', 'GitHub', 'https://github.com/favicon.ico'),
// Use speed.cloudflare.com (not www.cloudflare.com): the marketing site
// attaches `Link: rel=preload` headers for its brand fonts to every response
// — including /favicon.ico — which the browser honors regardless of fetch
// mode, then fails CORS on the woff2 files. speed.cloudflare.com serves a
// plain favicon with no preload headers.
builtinTarget('cloudflare', 'Cloudflare', 'https://speed.cloudflare.com/favicon.ico'),
builtinTarget('claude', 'Claude', 'https://claude.com/favicon.ico'),
builtinTarget('chatgpt', 'ChatGPT', 'https://chatgpt.com/favicon.ico'),
builtinTarget('wechat', 'WeChat', 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico'),
]);
// Hostname-dedupe input for the import dialog.
const builtinUrls = connectivityTests.map((test) => test.url);
const titleOpensSite = computed(() => userPreferences.value.connectivityCardTitleOpensSite);
// Card title link target — null (render a plain div) when the preference is
// off or the member's site URL can't be resolved.
const cardHref = (test) => (titleOpensSite.value ? siteUrlOf(test) : null);
// Reconcile custom targets by id (not wipe-and-refill) so existing cards
// don't flash back to "Awaiting Test" each time the user adds another one.
// ── Test entries ───────────────────────────────────────────────────────────
// One reactive entry per member of the active list. `roundResults` records
// per-round { tone, time } for the latency bars.
const targetEntry = (target) => reactive({
id: target.id,
name: target.name,
url: target.url,
siteUrl: target.siteUrl || null,
favicon: target.favicon || null,
status: t('connectivity.StatusWait'),
time: 0,
mintime: 0,
roundResults: [],
});
const connectivityTests = reactive([]);
// Rebuild vs reconcile: a list switch replaces the whole grid (the same
// member id can exist in two lists and must not carry results across); a
// same-list change reconciles in place so existing cards don't flash back
// to "Awaiting Test".
watch(
() => userPreferences.value.customConnectivityTargets,
(newTargets) => {
const targets = newTargets || [];
const targetIds = new Set(targets.map((t) => t.id));
() => ({ listId: activeListId.value, members: activeList.value?.members || [] }),
({ listId, members }, prev) => {
if (!prev || prev.listId !== listId) {
connectivityTests.splice(0, connectivityTests.length, ...members.map(targetEntry));
// A newly shown list runs a full pass right away, toast suppressed.
if (prev && isStarted.value) handelCheckStart('refresh');
return;
}
// Drop customs no longer in storage.
// Drop members no longer stored.
const memberIds = new Set(members.map((m) => m.id));
for (let i = connectivityTests.length - 1; i >= 0; i--) {
const entry = connectivityTests[i];
if (entry.custom && !targetIds.has(entry.id)) {
if (!memberIds.has(connectivityTests[i].id)) {
connectivityTests.splice(i, 1);
}
}
// Push only newcomers.
const existingCustomIds = new Set(
connectivityTests.filter((entry) => entry.custom).map((entry) => entry.id),
);
for (const target of targets) {
if (existingCustomIds.has(target.id)) continue;
const entry = reactive({
id: target.id,
name: target.name,
url: target.url,
custom: true,
// Imported entries carry a same-origin favicon + their source list;
// hand-added ones fall back to the letter tile.
favicon: target.favicon || null,
listId: target.listId || null,
status: t('connectivity.StatusWait'),
time: 0,
mintime: 0,
roundResults: [],
});
const existingIds = new Set(connectivityTests.map((entry) => entry.id));
for (const member of members) {
if (existingIds.has(member.id)) continue;
const entry = targetEntry(member);
connectivityTests.push(entry);
// Cards added after the bootstrap pass (import / hand-add) test
// themselves right away instead of sitting at "Awaiting Test".
if (isStarted.value) checkConnectivityHandler(entry, () => { }, true);
if (isStarted.value) {
entry.status = t('connectivity.StatusTesting');
checkConnectivityHandler(entry, () => { }, true);
}
}
},
{ immediate: true, deep: true },
@@ -220,7 +291,7 @@ watch(
const toneOf = (test) => {
const okLabel = t('connectivity.StatusAvailable');
return ipFieldTone(test.status, {
waitLabels: t('connectivity.StatusWait'),
waitLabels: [t('connectivity.StatusWait'), t('connectivity.StatusTesting')],
errorLabels: [t('connectivity.StatusUnavailable'), t('connectivity.StatusTimeout')],
isSuccess: (s) => typeof s === 'string' && s.includes(okLabel),
time: test.time,
@@ -228,13 +299,38 @@ const toneOf = (test) => {
};
const { dotClass, textClass } = useStatusTone();
// Filled → tone color; head-of-queue → pulse (suppressed once the user
// takes over manually so we don't promise rounds that won't land); rest → dim.
const progressDotClass = (test, idx) => {
// Bar color, by absolute latency bucket: green <200ms, yellow <1000ms, red
// beyond (extreme latency shares the fail tone), solid gray for
// unreachable rounds. Pending bars stay faint; head-of-queue pulses
// (suppressed once the user takes over manually).
const BAR_RED_MS = 1000;
const barClass = (test, idx) => {
const entry = test.roundResults[idx];
if (entry) return dotClass(entry.tone);
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
return 'bg-muted-foreground/20';
if (!entry) {
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
return 'bg-muted-foreground/20';
}
if (entry.tone === 'fail') return 'bg-muted-foreground';
if (entry.time < 200) return dotClass('ok-fast');
if (entry.time < BAR_RED_MS) return dotClass('ok-slow');
return dotClass('fail');
};
// Bar height, on the card's own dynamic range: the fastest round sits at
// the floor (kept hoverable), the slowest at 100%, the rest on a log curve
// between — rising latency compresses into ever-smaller height steps, and
// the top end is told apart by color instead. Failed and pending rounds
// have no latency and sit at the floor.
const BAR_FLOOR = 25;
const barStyle = (test, idx) => {
const entry = test.roundResults[idx];
if (!entry || !entry.time) return { height: `${BAR_FLOOR}%` };
const times = test.roundResults.map((r) => r.time).filter((v) => v > 0);
const min = Math.min(...times);
const max = Math.max(...times);
if (max === min) return { height: '100%' };
const p = Math.log(entry.time / min) / Math.log(max / min);
return { height: `${Math.round(BAR_FLOOR + (100 - BAR_FLOOR) * p)}%` };
};
// Per-dot hover tooltip: empty string disables the tooltip (JnTooltip's
@@ -261,8 +357,15 @@ const statusFaceIcon = (test) => {
// both have failure modes that mis-flag reachable sites as down.
// cache: 'no-store' avoids cached near-zero RTTs. AbortController lets us
// distinguish a completed request from a timeout without double-firing.
// Run generation: bumped per handelCheckStart cycle. Checks still in
// flight from a previous run must not write into the fresh grid; rounds
// overlapping within one run keep writing by design — slow sites' round
// results land after the next tick starts.
let runSeq = 0;
const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManualRun) => {
manualRun.value = isManualRun;
const runId = runSeq;
// Only multi-cycle passes feed the dot history; per-card refreshes skip it.
const recordRound = multipleTests.value && !isManualRun;
const controller = new AbortController();
@@ -276,10 +379,14 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
signal: controller.signal,
});
clearTimeout(timeoutId);
if (runId !== runSeq) {
onTestComplete(true);
return;
}
const testTime = Math.round(performance.now() - beginTime);
test.status = t('connectivity.StatusAvailable');
// Locale-free twin of the localized status label, consumed by the report
// builder (utils/report-builders.js) — the label itself can't be mapped
// builder (utils/report/report-builders.js) — the label itself can't be mapped
// back to an enum without string comparison against t() output.
test.statusCode = CONNECTIVITY_STATUS.OK;
test.mintime = test.mintime === 0 ? testTime : Math.min(test.mintime, testTime);
@@ -288,6 +395,10 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
onTestComplete(true);
} catch {
clearTimeout(timeoutId);
if (runId !== runSeq) {
onTestComplete(false);
return;
}
// Best-of-N: in multi mode a later failure doesn't downgrade a card
// that already succeeded (mintime > 0). The dot row still records the
// real 'fail' — it's per-round history, separate from the face/text.
@@ -305,46 +416,69 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
}
};
// Pass generation counter: overlapping passes (a manual refresh over a
// still-running boot pass, or multi-round ticks outlasting their interval)
// share the per-target state, so only the newest pass may judge and report
// it — a superseded pass just resolves.
let passSeq = 0;
const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => {
// Sticky false→true; interval ticks must not overwrite the bootstrap's `true`.
if (isAlertToShow) alertToShow.value = true;
const passId = ++passSeq;
return new Promise((resolve) => {
if (isRefresh) {
connectivityTests.forEach((test) => {
test.status = t('connectivity.StatusWait');
test.statusCode = undefined;
test.time = 0;
});
trackEvent('Section', 'RefreshClick', 'Connectivity');
}
// Unjudged cards flip from "Awaiting Test" to "Testing…"; later
// multi-round ticks leave cards that already carry a verdict alone.
connectivityTests.forEach((test) => {
if (test.statusCode === undefined) test.status = t('connectivity.StatusTesting');
});
const totalTests = connectivityTests.length;
let successCount = 0;
// The pass judges only the targets it schedules here — cards added
// mid-pass (self-tested by the watcher) belong to the next pass.
const scheduledIds = new Set(connectivityTests.map((test) => test.id));
const testPromises = [];
const onTestComplete = (isSuccess) => { if (isSuccess) successCount++; };
connectivityTests.forEach((test, index) => {
testPromises.push(new Promise((testResolve, testReject) => {
setTimeout(() => {
checkConnectivityHandler(test, (isSuccess) => {
if (isSuccess) { onTestComplete(true); testResolve(); }
else { onTestComplete(false); testReject(); }
if (isSuccess) testResolve();
else testReject();
}, isManualRun);
}, 50 * index);
}));
});
Promise.allSettled(testPromises).then(() => {
if (passId !== passSeq) {
resolve();
return;
}
// Verdict = scheduled targets still present at settle time: a card
// removed mid-pass must not fail the survivors, a card added
// mid-pass must not fail the pass it never ran in.
// Multi mode overwrites this with finalizeMultiTestAlert before the toast fires.
updateConnectivityAlert(successCount === totalTests ? 'success' : 'error');
// Domain event: snapshot after every pass (multi-round included) —
// latest wins downstream in the report collector.
const finished = connectivityTests.filter(
(test) => scheduledIds.has(test.id) && test.statusCode !== undefined,
);
const allOk = finished.every((test) => test.statusCode === CONNECTIVITY_STATUS.OK);
updateConnectivityAlert(allOk ? 'success' : 'error');
hasEverSettled.value = true;
// Domain event: deliberately the whole grid, not just this pass —
// the report is a latest-wins snapshot, and a mid-pass addition with
// a real result is accurate information there.
emitAppEvent('connectivity:finished', {
targets: connectivityTests.map((test) => ({
id: test.id,
name: test.name,
custom: test.custom === true,
custom: test.id.startsWith('custom-'),
statusCode: test.statusCode,
time: test.time,
mintime: test.mintime,
@@ -380,20 +514,48 @@ const updateConnectivityAlert = (type) => {
}
};
// ── Add/remove custom targets ──────────────────────────────────────────────
// The add/import dialog itself lives in ConnectivityAddDialog.vue; this
// component only owns the open flag and per-card removal.
// ── Add / import / manage dialog + per-card removal ───────────────────────
// The dialog lives in ConnectivityAddDialog.vue; this component owns the
// open flag and the tab it opens on.
const addDialogOpen = ref(false);
const dialogTab = ref('import');
const openAddDialog = (tab) => {
dialogTab.value = tab;
addDialogOpen.value = true;
};
const removeCustomTarget = (id) => {
const current = userPreferences.value.customConnectivityTargets || [];
store.updatePreference(
'customConnectivityTargets',
current.filter((t) => t.id !== id),
);
const removeTargetById = (id) => {
const result = removeMember(lists.value, activeListId.value, id);
if (result.error) return;
store.updatePreference('connectivityLists', {
...userPreferences.value.connectivityLists,
lists: result.lists,
});
trackEvent('Section', 'RemoveCustomTarget', 'Connectivity');
};
// Two-step removal: first click arms "X?" (self-reverting), second click
// deletes. Mine's last remaining site just toasts — the default list must
// never go empty (other lists may be emptied; deleting them requires it).
const confirmingRemoveId = ref(null);
let confirmResetTimer = null;
const handleRemoveClick = (id) => {
clearTimeout(confirmResetTimer);
if (activeListId.value === MINE_LIST_ID && (activeList.value?.members.length || 0) <= 1) {
confirmingRemoveId.value = null;
store.setAlert(true, 'text-warning',
t('connectivity.addCustom.LastTargetMessage'), t('connectivity.addCustom.LastTargetTitle'));
return;
}
if (confirmingRemoveId.value === id) {
confirmingRemoveId.value = null;
removeTargetById(id);
return;
}
confirmingRemoveId.value = id;
confirmResetTimer = setTimeout(() => { confirmingRemoveId.value = null; }, 3000);
};
// Multi-mode aggregate: `mintime > 0` ≡ reachable in at least one round.
const finalizeMultiTestAlert = () => {
const total = connectivityTests.length;
@@ -405,11 +567,12 @@ const finalizeMultiTestAlert = () => {
// `trigger` selects three behaviors (arming the toast = setting alertToShow):
// 'boot' — startup auto-run: arm toast, no card reset
// 'manual' — section refresh button: arm toast, reset cards
// 'refresh' — global "refresh everything": suppress toast (global alert covers it), reset cards
// 'refresh' — global "refresh everything" + list switches: suppress toast, reset cards
// Multi-round mode applies to every trigger, not just boot — with the
// connectivity auto-run switch off, the manual/global refresh IS the user's
// only entry point, and the rounds preference must still hold there.
const handelCheckStart = async (trigger = 'boot') => {
runSeq += 1;
const multi = multipleTests.value;
const isAuto = trigger === 'boot';
const showToast = trigger !== 'refresh';
@@ -455,21 +618,29 @@ const handelCheckStart = async (trigger = 'boot') => {
}
};
// Command owner: run the connectivity pass. `trigger` keeps handelCheckStart's
// toast / card-reset semantics; resolves with the next connectivity:finished
// snapshot (the first pass in multi-round mode).
useAppCommand('connectivity:run', ({ trigger = 'manual' } = {}) => {
const finished = waitForAppEvent('connectivity:finished');
handelCheckStart(trigger);
return finished;
});
onMounted(() => {
store.setMountingStatus('Connectivity', true);
});
// Stop the interval on unmount.
// Stop the interval (and any pending confirm revert) on unmount.
onBeforeUnmount(() => {
if (intervalId.value !== null) {
clearInterval(intervalId.value);
intervalId.value = null;
}
clearTimeout(confirmResetTimer);
});
// Either signal flipping fires sendAlert; the gates inside pick the winner.
watch(() => store.allHasLoaded, (v) => { if (v) sendAlert(); });
watch(allRoundsDone, (v) => { if (v) sendAlert(); });
defineExpose({ handelCheckStart });
</script>
+61 -60
View File
@@ -17,13 +17,12 @@
</div>
<div class="text-base text-muted-foreground">
<p v-if="!isSimpleMode">{{ t('dnsleaktest.Note') }}</p>
<p v-if="!isSimpleMode">{{ t('dnsleaktest.Note2') }}</p>
</div>
</header>
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<Card v-for="(leak, index) in leakTest" :key="leak.id"
<Card v-for="(leak, index) in leakTest" :key="leak.slot"
class="keyboard-shortcut-card jn-card min-w-0 overflow-hidden transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<CardContent class="p-4 min-w-0">
<!-- Top: heartbeat icon + name + index -->
@@ -34,8 +33,7 @@
<span class="font-mono text-muted-foreground shrink-0">#{{ index + 1 }}</span>
</div>
<!-- Provider name (secondary information) — fixed per card, sourced
from each provider's `name` export in utils/dnsleaks. -->
<!-- Provider that actually answered (changes on fallback). -->
<p class="w-full min-w-0 mb-1 text-xs font-mono text-muted-foreground truncate" :title="leak.providerName">
{{ leak.providerName }}
</p>
@@ -83,78 +81,67 @@
</Card>
</div>
<!-- Enhanced DNS leak test banner — surfaces the deeper tool once the
homepage test has resolved (success or timeout). -->
<InfoBanner :show="showEnhancedBanner" :icon="Sparkles" :title="t('dnsleaktest.EnhancedBanner.Title')"
:note="t('dnsleaktest.EnhancedBanner.Note')" :cta="t('dnsleaktest.EnhancedBanner.CTA')"
@action="openEnhancedTest" />
<!-- Section banner slot (data-driven; see InfoBanner.vue); settled once
the homepage test has resolved (success or timeout). -->
<InfoBanner section="dnsleak" :settled="hasEverSettled" />
</section>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useStatusTone, ipFieldTone, isFieldPending as isFieldPendingShared } from '@/composables/use-status-tone.js';
import { createMaskGate } from '@/composables/use-info-mask.js';
import { useMaxmind } from '@/composables/use-maxmind.js';
import { EthernetPort, Play, MapPin, RotateCw, Sparkles, DoorOpen } from '@lucide/vue';
import { EthernetPort, Play, MapPin, RotateCw, DoorOpen } from '@lucide/vue';
import { Icon } from '@iconify/vue';
import FitText from '@/components/widgets/FitText.vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
import {
ipApi, surfshark, ipleak, fastly, runWithRetry,
ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak, every1dns,
createDnsLeakRunner,
} from '@/utils/dnsleaks';
// One source of truth for which providers to run and in what order. Adding
// a new provider = create the file under utils/dnsleaks and append it here.
// (browserleaks stays available in utils/dnsleaks but is benched for now.)
const PROVIDERS = [ipApi, surfshark, ipleak, fastly];
// All providers in priority order: the first SLOT_COUNT hold a card each,
// the rest are standbys. New provider = file under utils/dnsleaks + append.
const PROVIDERS = [ipApi, every1dns, myipstack, fastly, surfshark, bashws, browserleaks, ipleak];
const SLOT_COUNT = 4;
const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT);
const { t } = useI18n();
const store = useMainStore();
const router = useRouter();
const { lookupMaxmind } = useMaxmind();
// Skip the info-mask blur on waiting/error placeholders (not a real IP).
const maskAttr = createMaskGate(t);
const isStarted = ref(false);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
// Sticky flag for the Enhanced DNS Leak Test banner.
// Sticky settled flag for the section's banner slot.
const hasEverSettled = ref(false);
// Also gated on configs.originalSite to match the Advanced.vue card gate.
const showEnhancedBanner = computed(() =>
hasEverSettled.value && store.configs?.originalSite === true
);
const openEnhancedTest = () => {
trackEvent('Section', 'BannerClick', 'EnhancedDnsLeakTest');
// Open the in-depth DNS Leak Test's in-page drawer (tools are query-driven now).
router.push({ path: '/', query: { tool: 'enhanceddnsleaktest' } });
};
let runId = 0;
const { dotClass, textClass } = useStatusTone();
// Business status → 4 tone levels
const toneOf = (leak) => ipFieldTone(leak.ip, {
waitLabels: t('dnsleaktest.StatusWait'),
waitLabels: [t('dnsleaktest.StatusWait'), t('dnsleaktest.StatusTesting')],
errorLabels: t('dnsleaktest.StatusError'),
});
// Status
const isFieldPending = (value) => isFieldPendingShared(value, {
waitLabels: t('dnsleaktest.StatusWait'),
waitLabels: [t('dnsleaktest.StatusWait'), t('dnsleaktest.StatusTesting')],
errorLabels: t('dnsleaktest.StatusError'),
});
@@ -166,15 +153,19 @@ const createDefaultCard = () => ({
org: t('dnsleaktest.StatusWait'),
});
const leakTest = reactive(PROVIDERS.map((p) => ({
// `slot` is the card's stable key; `id` / `providerName` follow the
// provider that actually answered.
const leakTest = reactive(ACTIVE.map((p) => ({
...createDefaultCard(),
slot: p.id,
id: p.id,
providerName: p.name,
})));
// Apply MaxMind lookup to a card that already has a resolved leak IP.
const applyMaxMindGeo = async (index, ip) => {
const applyMaxMindGeo = async (index, ip, currentRun) => {
const geo = await lookupMaxmind(ip);
if (currentRun !== runId) return;
if (geo) {
leakTest[index].country_code = geo.country_code;
leakTest[index].country = geo.country;
@@ -193,48 +184,55 @@ const markLeakCardError = (index) => {
leakTest[index].org = t('dnsleaktest.StatusError');
};
// Run one provider against its card slot, with retry (3 attempts, fresh
// prefix per attempt — see `runWithRetry` in utils/dnsleaks). On full
// failure (all attempts threw) the card is flipped to the error state.
const runProvider = async (index, provider) => {
// Run one slot through its fallback chain (see utils/dnsleaks); the card
// flips to error only when the whole chain fails.
const runProvider = async (index, run, currentRun) => {
if (currentRun !== runId) return;
try {
const { ip } = await runWithRetry(provider);
const { ip, provider } = await run(index);
if (currentRun !== runId) return;
leakTest[index].id = provider.id;
leakTest[index].providerName = provider.name;
leakTest[index].ip = ip;
await applyMaxMindGeo(index, ip);
await applyMaxMindGeo(index, ip, currentRun);
} catch (error) {
if (currentRun !== runId) return;
console.error('Error fetching leak test data:', error);
markLeakCardError(index);
}
};
// Check all. Staggers startup by 300ms per provider to avoid a thundering-
// Check all. Staggers startup by 200ms per provider to avoid a thundering-
// herd on first paint.
const checkAllDNSLeakTest = async (isRefresh) => {
const currentRun = ++runId;
const run = createDnsLeakRunner(PROVIDERS, SLOT_COUNT);
isStarted.value = true;
if (isRefresh) {
trackEvent('Section', 'RefreshClick', 'DNSLeakTest');
leakTest.forEach((server) => {
server.ip = t('dnsleaktest.StatusWait');
server.country = t('dnsleaktest.StatusWait');
server.country_code = '';
server.org = t('dnsleaktest.StatusWait');
});
}
if (isRefresh) trackEvent('Section', 'RefreshClick', 'DNSLeakTest');
// Every run starts from the primary provider and "Testing…" — on boot
// this is a no-op for the slot, on refresh it drops the fallback result.
leakTest.forEach((server, index) => {
server.id = ACTIVE[index].id;
server.providerName = ACTIVE[index].name;
server.ip = t('dnsleaktest.StatusTesting');
server.country = t('dnsleaktest.StatusTesting');
server.country_code = '';
server.org = t('dnsleaktest.StatusTesting');
});
const delayedRun = (provider, index, delay) => new Promise((resolve) => {
const delayedRun = (index, delay) => new Promise((resolve) => {
setTimeout(() => {
runProvider(index, provider).then(resolve, resolve);
runProvider(index, run, currentRun).then(resolve, resolve);
}, delay);
});
const promises = PROVIDERS.map((provider, index) =>
delayedRun(provider, index, index * 200),
);
const promises = ACTIVE.map((_, index) => delayedRun(index, index * 200));
const allSettledPromise = Promise.allSettled(promises);
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
return Promise.race([allSettledPromise, timeoutPromise]).then(() => {
if (currentRun !== runId) return;
store.setLoadingStatus('DNSLeakTest', true);
// Local sticky flag for the Enhanced DNS Leak Test banner
hasEverSettled.value = true;
@@ -252,12 +250,15 @@ const checkAllDNSLeakTest = async (isRefresh) => {
});
};
// Command owner: run all leak providers. Resolves with the next
// dnsleak:finished snapshot.
useAppCommand('dnsleak:run', ({ isRefresh = false } = {}) => {
const finished = waitForAppEvent('dnsleak:finished');
checkAllDNSLeakTest(isRefresh);
return finished;
});
onMounted(() => {
store.setMountingStatus('DNSLeakTest', true);
});
defineExpose({
checkAllDNSLeakTest,
leakTest,
});
</script>
+255 -76
View File
@@ -1,59 +1,153 @@
<!-- Site footer on every page: brand / Project / Docs & Support / Clients
columns, plus a signature bar (with the copyright on self-hosted
deployments). About / Changelog / Acknowledgement open in its About
sheet, the CLI in its own dialog; nothing already on the page (tools,
sections) repeats here. -->
<template>
<footer class="pb-6 text-sm text-muted-foreground">
<!-- Primary footer links: Sponsor / Privacy / About / Changelog / Special
Thanks. The last three open the same About sheet on their own tab.
Text-only (no per-button icons) to keep the row calm. -->
<div class="flex flex-wrap items-center justify-center gap-x-3 mb-2 mx-auto">
<Button variant="link" size="default" as-child class="text-[#d63384] hover:text-[#d63384]">
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener">
{{ t('about.Sponsor') }}
</a>
</Button>
<Button variant="link" size="default" as-child class="cursor-pointer">
<RouterLink to="/privacy" @click="trackEvent('Footer', 'FooterClick', 'Privacy')">
{{ t('about.Privacy') }}
</RouterLink>
</Button>
<Button variant="link" size="default" as-child class="cursor-pointer">
<a :href="'https://docs.ipcheck.ing/'+(locale === 'en' ? '' : locale)" target="_blank" rel="noopener"
@click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
</Button>
<Button variant="link" size="default" @click="openAboutTab('changelog', 'Changelog')" class="cursor-pointer">
{{ t('changelog.Title') }}
</Button>
<Button variant="link" size="default" @click="openAboutTab('acknowledgement', 'Acknowledgement')"
class="cursor-pointer">
{{ t('acknowledgement.Title') }}
</Button>
<Button variant="link" size="default" @click="openAbout" class="cursor-pointer">
{{ t('about.Title') }}
</Button>
</div>
<!-- Author + GitHub on one line, copyright dimmed beneath — low-emphasis signature block -->
<div class="flex flex-col items-center justify-center gap-y-1.5 text-xs opacity-70">
<div class="flex items-center justify-center gap-x-2">
<span>Created by Jason Ng with love</span>
<JnTooltip :text="t('Tooltips.GithubLink')" side="top">
<Button variant="ghost" size="icon" as-child class="size-5 text-foreground/70 hover:text-foreground">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" aria-label="Github"
<footer class="border-t border-secondary text-sm text-muted-foreground">
<div class="mx-auto w-full px-4 jn-container">
<!-- Link columns: four tracks on md+, matching the Advanced Tools grid;
on phones the brand spans the top and the rest flow two per row. -->
<div class="grid grid-cols-2 gap-8 py-12 md:grid-cols-4">
<!-- Brand -->
<div class="col-span-2 md:col-span-1">
<div class="inline-flex items-center gap-1.5 text-xl font-semibold text-foreground">
<brandIcon />
<span class="tracking-tight">
<span class="font-bold">IP</span><span class="font-extralight">Check.ing</span>
</span>
</div>
<p class="mt-2">{{ t('footer.Tagline') }}</p>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Github')">
<Icon icon="ri:github-line" />
<Icon icon="ri:github-line" class="size-4" />
<span>GitHub</span>
</a>
</Button>
</JnTooltip>
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener"
class="inline-flex items-center gap-1 text-sm text-[#d63384] no-underline transition-opacity hover:opacity-80">
<Icon icon="ri:heart-line" class="size-4" />
{{ t('about.Sponsor') }}
</a>
</div>
</div>
<!-- Project: the About sheet's tabs, privacy, license and contributing -->
<nav :aria-label="t('footer.Project')">
<h3 :class="headingClass">{{ t('footer.Project') }}</h3>
<ul class="space-y-2">
<li>
<button type="button" :class="linkClass" @click="openAbout">
{{ t('about.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('acknowledgement', 'Acknowledgement')">
{{ t('acknowledgement.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('changelog', 'Changelog')">
{{ t('changelog.Title') }}
</button>
</li>
<li>
<RouterLink to="/privacy" :class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'Privacy')">
{{ t('about.Privacy') }}
</RouterLink>
</li>
<li>
<a :href="`${t('page.footerLink')}/blob/main/LICENSE`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'License')">
{{ t('footer.License') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/contribute`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Contribute')">
{{ t('footer.Contribute') }}
</a>
</li>
</ul>
</nav>
<!-- Docs & Support: locale-aware docs.ipcheck.ing links + issue tracker -->
<nav :aria-label="t('footer.Support')">
<h3 :class="headingClass">{{ t('footer.Support') }}</h3>
<ul class="space-y-2">
<li>
<a :href="docsUrl('knowledge-base', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'DeveloperDocs')">
{{ t('footer.DeveloperDocs') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale, 'getting-started/')" target="_blank" rel="noopener"
:class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'SelfHosting')">
{{ t('footer.SelfHosting') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/issues`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'ReportIssue')">
{{ t('footer.ReportIssue') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/issues/new?template=feature_request.md`" target="_blank" rel="noopener"
:class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'FeatureRequest')">
{{ t('footer.FeatureRequest') }}
</a>
</li>
</ul>
</nav>
<!-- Clients: Raycast / Lite always; the CLI entry needs the curl
domains configured. -->
<nav :aria-label="t('footer.Clients')">
<h3 :class="headingClass">{{ t('footer.Clients') }}</h3>
<ul class="space-y-2">
<li>
<a href="https://www.raycast.com/jason5ng32/ipcheck-ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Raycast')">
<Icon icon="simple-icons:raycast" class="size-4 shrink-0" />
{{ t('footer.Raycast') }}
</a>
</li>
<li v-if="curlDomainsHadSet">
<button type="button" :class="linkClass" @click="openCurl">
<Terminal class="size-4 shrink-0" />
{{ t('curl.Title') }}
</button>
</li>
<li>
<a href="https://lite.ipcheck.ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Lite')">
<Feather class="size-4 shrink-0" />
{{ t('footer.Lite') }}
</a>
</li>
</ul>
</nav>
</div>
<template v-if="!configs.originalSite">
<span>
<!-- Signature bar: every deployment; the copyright only when self-hosted -->
<div class="flex flex-col items-center gap-y-1.5 border-t border-secondary py-6 text-center text-xs opacity-70">
<span>Created by Jason Ng with love</span>
<span v-if="!configs.originalSite">
{{ t('page.copyRightName') }}
<a :href="t('page.copyRightLink')" target="_blank" rel="noopener"
class="text-foreground/80 hover:text-foreground hover:underline">
{{ t('page.copyRightLinkName') }}
</a>
</span>
</template>
</div>
</div>
<!-- About Sheet -->
@@ -74,23 +168,19 @@
<!-- Content area (independent scrolling) -->
<div class="flex-1 overflow-y-auto px-5 py-5" ref="sheetBody">
<!-- About -->
<!-- About: sections come from aboutSections, each one's paragraphs an array
in the locale file — adding or reordering copy is a locale-only edit. -->
<TabsContent value="about" class="space-y-6 mt-0">
<section class="space-y-2">
<p v-for="i in 3" :key="i" class="text-sm leading-relaxed text-foreground/85">
{{ t(`about.product${i}`) }}
</p>
</section>
<section>
<h3 class="text-base font-semibold mb-2">{{ t('about.meTitle') }}</h3>
<div class="space-y-2 mb-3">
<p v-for="i in 3" :key="i" class="text-sm leading-relaxed text-foreground/85">
{{ t(`about.me${i}`) }}
<section v-for="section in aboutSections" :key="section.key">
<h3 v-if="section.titleKey" class="text-base font-semibold mb-2">{{ t(section.titleKey) }}</h3>
<div class="space-y-2" :class="section.links && 'mb-3'">
<p v-for="(paragraph, idx) in tm(`about.${section.key}`)" :key="idx"
class="text-sm leading-relaxed text-foreground/85">
{{ rt(paragraph) }}
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button v-for="link in personalLinks" :key="link.href" variant="outline" size="sm" as-child
<div v-if="section.links" class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button v-for="link in section.links" :key="link.href" variant="outline" size="sm" as-child
class="justify-start">
<a :href="link.href" target="_blank" rel="noopener">
<Compass />
@@ -100,11 +190,6 @@
</Button>
</div>
</section>
<section>
<h3 class="text-base font-semibold mb-2">{{ t('about.contactTitle') }}</h3>
<div class="text-sm leading-relaxed text-foreground/85" v-html="t('about.contact')"></div>
</section>
</TabsContent>
<!-- Changelog -->
@@ -115,7 +200,8 @@
<section v-for="(version, vi) in changelogReversed" :key="vi">
<header class="flex items-baseline justify-between mb-2">
<h3 class="text-lg font-semibold tracking-tight">{{ version.version }}</h3>
<span class="text-xs text-muted-foreground tabular-nums">{{ formatIsoDate(version.date, locale) }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ formatIsoDate(version.date, locale)
}}</span>
</header>
<Separator class="mb-3" />
<ul class="space-y-2">
@@ -127,7 +213,7 @@
<CirclePlus v-if="item.type === 'add'" class="size-3.5" />
<BugOff v-if="item.type === 'fix'" class="size-3.5" />
</Badge>
<span class="leading-relaxed">{{ item.change[locale] || item.change.en }}</span>
<span class="leading-relaxed">{{ changeText(item.change) }}</span>
</li>
</ul>
</section>
@@ -153,10 +239,55 @@
</li>
</ul>
</TabsContent>
</div>
</Tabs>
</SheetContent>
</Sheet>
<!-- Command Line API dialog: only opened from the Clients link, which
is listed only when the curl domains are configured. -->
<Dialog :open="isCurlOpen" @update:open="isCurlOpen = $event">
<DialogContent :title="t('curl.Title')">
<DialogHeader :icon="Terminal" :title="t('curl.Title')" />
<div class="space-y-3">
<!-- Description -->
<div class="space-y-1 text-xs font-mono">
<p class="jn-comment"><span class="text-muted-foreground">{{ t('curl.Note1') }}</span></p>
<p class="jn-comment">
<span class="text-muted-foreground">{{ t('curl.Note2_1') }}
<Badge variant="outline" class="text-success">curl</Badge> {{ t('curl.Note2_2') }}
</span>
</p>
<p class="jn-comment">
<span class="text-muted-foreground">
<Badge variant="outline" class="text-success">geo</Badge> {{ t('curl.Note3') }}
</span>
</p>
</div>
<!-- 3 curl command blocks -->
<div class="space-y-3">
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv4Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv6') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv6Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.get6and4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv64Domain }}<span class="text-success">/geo</span></pre>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</footer>
</template>
@@ -166,26 +297,38 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import changelogData from '@/data/changelog.json';
import { trackEvent } from '@/utils/analytics';
import { docsUrl } from '@/utils/docs-url.js';
import { formatIsoDate } from '@/utils/time-utils';
import { fallbackChain } from '@/utils/locale-registry.js';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { JnTooltip } from '@/components/ui/tooltip';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Compass, ExternalLink, CircleFadingArrowUp, CirclePlus, BugOff, Smile } from '@lucide/vue';
import { Compass, ExternalLink, CircleFadingArrowUp, CirclePlus, BugOff, Smile, Terminal, Feather } from '@lucide/vue';
import brandIcon from './svgicons/Brand.vue';
import { Icon } from '@iconify/vue';
import { useRouteActive } from '@/composables/use-route-active.js';
const { t, locale } = useI18n();
const { t, tm, rt, locale } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
const configs = computed(() => store.configs);
const tabs = ['about', 'changelog', 'acknowledgement'];
const curl = computed(() => store.curl);
const curlDomainsHadSet = computed(() => store.curlDomainsHadSet);
const tabs = ['about', 'acknowledgement', 'changelog'];
const content = ref('about');
const headingClass = 'mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground';
const linkClass = 'inline-flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-foreground cursor-pointer';
// Static data from JSON — reverse once via computed so the template stays tidy.
const changelogReversed = computed(() => changelogData.slice().reverse());
// Entries carry one string per language; history is only guaranteed for full
// locales, so a beta one walks its fallback chain.
const changeText = (change) => fallbackChain(locale.value).map((code) => change[code]).find(Boolean) ?? '';
const sheetBody = ref(null);
const personalLinks = [
@@ -195,13 +338,24 @@ const personalLinks = [
{ href: 'https://twitter.com/jason5ng32', labelKey: 'about.twitter' },
];
// About tab layout: one entry per section, rendered in order. `key` names the
// paragraph array under `about.` in the locale files; `titleKey` is omitted for
// the lead-in section, and `links` appends a link grid under the paragraphs.
const aboutSections = [
{ key: 'project' },
{ key: 'founder', titleKey: 'about.founderTitle', links: personalLinks },
{ key: 'contact', titleKey: 'about.contactTitle' },
];
const acknowledgementsList = [
{ name: 'Setilis Hu', link: '' },
{ name: 'Seven Yu', link: 'https://github.com/dofy' },
{ name: 'Nikolai Tschacher', link: 'https://incolumitas.com/pages/about/' },
{ name: 'Project Alexandria (Cloudflare)', link: 'https://www.cloudflare.com/lp/project-alexandria/' },
{ 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: 'SwiftProxy', link: 'https://www.swiftproxy.net/?ref=myip' },
{ name: 'Proxy-Seller', link: 'https://proxy-seller.com/?partner=4FEC1644D12240F4' },
{ name: 'Sentry', link: 'https://www.sentry.io/' },
{ name: '1Password', link: 'https://www.1password.com/' },
{ name: 'Greptile', link: 'https://www.greptile.com/' },
@@ -224,8 +378,10 @@ const changelogBadgeClass = (type) => {
return '';
};
// Sheet toggle and store.openSheet bidirectional binding
const isOpen = computed(() => store.openSheet === 'about');
// Sheet toggle and store.openSheet bidirectional binding. Home and ToolPage are
// both cached with a Footer each; only the page on screen opens its sheet.
const routeActive = useRouteActive();
const isOpen = computed(() => routeActive.value && store.openSheet === 'about');
const onOpenChange = (val) => {
store.setOpenSheet(val ? 'about' : null);
};
@@ -245,6 +401,12 @@ const openAboutTab = (tab, trackLabel) => {
trackEvent('Footer', 'FooterClick', trackLabel);
};
const isCurlOpen = ref(false);
const openCurl = () => {
isCurlOpen.value = true;
trackEvent('Footer', 'FooterClick', 'Curl');
};
watch(content, () => {
nextTick(() => {
if (sheetBody.value) sheetBody.value.scrollTop = 0;
@@ -252,3 +414,20 @@ watch(content, () => {
});
</script>
<style scoped>
.jn-curl::before {
content: '$ ';
color: var(--muted-foreground);
font-weight: 500;
margin-right: 0.25rem;
opacity: 0.7;
}
.jn-comment::before {
content: '// ';
color: var(--muted-foreground);
font-weight: 500;
opacity: 0.7;
}
</style>
+17 -38
View File
@@ -1,15 +1,12 @@
<template>
<NavBar />
<User ref="userRef" />
<Achievements ref="achievementsRef" />
<Preferences />
<main id="mainpart" class="mx-auto w-full px-4 jn-container">
<div class="rounded-md" tabindex="0">
<IPCheck ref="IPCheckRef" />
<Connectivity ref="connectivityRef" />
<WebRTC ref="webRTCRef" />
<DNSLeaks ref="dnsLeaksRef" />
<SpeedTest ref="speedTestRef" />
<div class="rounded-md">
<IPCheck />
<Connectivity />
<WebRTC />
<DNSLeaks />
<SpeedTest />
<AdvancedTools ref="advancedToolsRef" />
</div>
</main>
@@ -21,23 +18,19 @@
<QueryIP ref="queryIPRef" />
</FloatingDock>
<HelpModal ref="helpModalRef" />
<Additional />
<Footer />
</template>
<script setup>
// The homepage. Holds every top-level section plus the Advanced Tools drawer.
// Split out of App.vue when the app moved to history-mode routing: App is now a
// thin shell, and this component is what /'s <router-view> renders. The truly
// global widgets (tooltip provider, toast, PWA, theme) stay in App.
// The homepage (`/`): every top-level section plus the Advanced Tools cards;
// what every route shares lives in App.vue.
//
// Components — the test sections and the always-visible chrome load
// synchronously; everything the first paint can't show (dialogs, drawers,
// the below-fold Additional/Footer) is an async component so its code stays
// synchronously; everything the first paint can't show (dialogs, the
// below-fold Footer) is an async component so its code stays
// out of the route chunk and out of the mount's critical path. Their
// template refs are null until the chunk lands — consumers (use-shortcuts)
// must optional-chain.
import NavBar from './Nav.vue';
import IPCheck from './IpInfos.vue';
import Connectivity from './ConnectivityTest.vue';
import WebRTC from './WebRtcTest.vue';
@@ -51,16 +44,12 @@ import FloatingDock from './widgets/FloatingDock.vue';
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
// Async (off-critical-path) components
const Additional = defineAsyncComponent(() => import('./Additional.vue'));
const Footer = defineAsyncComponent(() => import('./Footer.vue'));
const User = defineAsyncComponent(() => import('./User.vue'));
const Achievements = defineAsyncComponent(() => import('./Achievements.vue'));
const Preferences = defineAsyncComponent(() => import('./widgets/Preferences.vue'));
const QueryIP = defineAsyncComponent(() => import('./widgets/QueryIP.vue'));
const HelpModal = defineAsyncComponent(() => import('./widgets/Help.vue'));
const IPHistory = defineAsyncComponent(() => import('./widgets/IPHistory.vue'));
const ShareReport = defineAsyncComponent(() => import('./report/ShareReportDialog.vue'));
import { useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
@@ -71,27 +60,20 @@ import { useShortcuts } from '@/composables/use-shortcuts.js';
import { useSectionTracking } from '@/composables/use-section-tracking.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
defineOptions({ name: 'Home' });
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
// A tool drawer is open iff the home route carries a `?tool=` query (set by
// Advanced.vue). Drives the `f` fullscreen shortcut gate.
const isToolOpen = computed(() => !!route.query.tool);
// Template refs
const userRef = ref(null);
// Template refs — UI chrome only; the test sections are reached through the
// command bus (utils/app-commands.js), not refs.
const achievementsRef = ref(null);
const queryIPRef = ref(null);
const helpModalRef = ref(null);
const shareReportRef = ref(null);
const speedTestRef = ref(null);
const advancedToolsRef = ref(null);
const IPCheckRef = ref(null);
const connectivityRef = ref(null);
const webRTCRef = ref(null);
const dnsLeaksRef = ref(null);
// Info mask
const { infoMaskLevel, isInfosLoaded, showMaskButton, toggleInfoMask } = useInfoMask({
@@ -101,7 +83,6 @@ const { infoMaskLevel, isInfosLoaded, showMaskButton, toggleInfoMask } = useInfo
// Refresh / initial load sequence
const { loadingControl } = useRefreshOrchestrator({
refs: { IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef },
store,
t,
userPreferences,
@@ -111,9 +92,8 @@ const { loadingControl } = useRefreshOrchestrator({
// Shortcuts
const { loadShortcuts } = useShortcuts({
refs: {
queryIPRef, helpModalRef, shareReportRef,
speedTestRef, advancedToolsRef, IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef,
isInfosLoaded, isToolOpen, toggleInfoMask,
queryIPRef, helpModalRef, shareReportRef, advancedToolsRef,
isInfosLoaded, toggleInfoMask,
},
store, t, configs, userPreferences,
});
@@ -124,8 +104,7 @@ useSectionTracking();
// Localized homepage head. Provide title/description explicitly via t() rather
// than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is
// taken at module load, before the (now async) locale messages land, so it would
// pin the head to index.html's English title. Reactive t() also re-applies the
// right copy when SPA-navigating back from a /tools/:slug page.
// pin the head to index.html's English title.
useDocumentMeta(() => ({
title: t('page.title'),
description: t('page.description'),
+56 -27
View File
@@ -22,6 +22,10 @@
:asnConnectivityInfos="asnConnectivityInfos" @refresh-card="refreshCard" />
</div>
</div>
<!-- Section banner slot — renders nothing unless a data file for this
section exists (see InfoBanner.vue). -->
<InfoBanner section="ipinfo" :settled="cardsHaveSettled" />
</section>
</template>
@@ -31,12 +35,16 @@ import { ref, computed, onMounted, reactive, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { isValidIP } from '@/utils/valid-ip.js';
import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
import { isUsablePublicIP } from '@/utils/ip/valid-ip.js';
import { toApiTag } from '@/utils/locale-registry.js';
import { transformDataFromIPapi } from '@/utils/ip/transform-ip-data.js';
import { getIPFromIPIP, getIPFromCloudflare_V4, getIPFromCloudflare_V6, getIPFromIPChecking64, getIPFromIPChecking4, getIPFromIPChecking6 } from '@/utils/getips';
import { emitAppEvent } from '@/utils/app-events';
import { authenticatedFetch, fetchErrorLabel } from '@/utils/authenticated-fetch';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
import { useAsnInfo } from '@/composables/use-asn-info.js';
import { authenticatedFetch, fetchErrorLabel, logSourceFetchFailure } from '@/utils/authenticated-fetch';
import IPCard from './ip-infos/IPCard.vue';
import InfoBanner from './widgets/InfoBanner.vue';
const { t } = useI18n();
@@ -62,8 +70,8 @@ const createDefaultCard = () => ({
isp: "",
asn: "",
asnlink: "",
mapUrl: '/res/defaultMap.webp',
mapUrl_dark: '/res/defaultMap_dark.webp',
mapUrl: "",
mapUrl_dark: "",
});
// IP data cards
@@ -102,8 +110,8 @@ const ipDataCards = reactive([
},
]);
// Default ASN information
const asnInfos = ref({
// ASN information (Radar summary) session cache, with a default entry.
const { asnInfos } = useAsnInfo({
"AS888888": {
"asnName": "Google", "asnOrgName": "GOGL-ARIN", "estimatedUsers": "888888", "IPv4_Pct": "95.35", "IPv6_Pct": "4.65", "HTTP_Pct": "3.16", "HTTPS_Pct": "96.84", "Desktop_Pct": "58.88", "Mobile_Pct": "41.12", "Bot_Pct": "98.46", "Human_Pct": "1.54"
}
@@ -122,6 +130,8 @@ const IPArray = ref([]);
const ipGeoSource = ref(userPreferences.value.ipGeoSource);
const usingSource = ref(userPreferences.value.ipGeoSource);
const fetchStatus = reactive([]);
// Timing gate for the sponsor slot: flips once every visible card settled.
const cardsHaveSettled = ref(false);
// Middleware
let pendingIPDetailsRequests = new Map();
@@ -207,10 +217,18 @@ const loadCardDetails = async (cardID, ip) => {
}
};
// Single-card path (refresh button).
// Single-card path (refresh button), and the per-card body of checkAllIPs.
const fetchIP = async (cardID, getFromSource) => {
const { ip } = await resolveIP(cardID, getFromSource);
if (ip !== null) await loadCardDetails(cardID, ip);
if (ip === null) return; // resolveIP already settled the card
if (isUsablePublicIP(ip)) {
await loadCardDetails(cardID, ip);
} else {
// A source can hand back reserved space (DNS hijack, captive portal, LAN
// echo). The address stays on the card — the visitor really did get it —
// but the geo phase is skipped: /api/* rejects non-public IPs with 400.
markFetched(cardID);
}
};
// Report data fetch status, and send to store
@@ -224,6 +242,7 @@ const trackFetchStatus = (status) => {
}
}
if (allHasFetched) {
cardsHaveSettled.value = true;
store.setLoadingStatus('IPInfo', true);
// Domain event: full snapshot of the visible cards, re-emitted whenever a
// card settles after this point (single-card refresh included). The report
@@ -240,12 +259,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,
})),
});
}
@@ -255,6 +274,10 @@ const trackFetchStatus = (status) => {
// allSettled so one card can't sink the batch (fetchIP already swallows per-card
// errors — this is belt-and-suspenders). Cards paint independently as they land.
const checkAllIPs = async () => {
// A whole-grid pass means no card is settled: clear the completion flags so
// ipinfo:finished (and the ipinfo:refresh command riding on it) waits for
// every card to land again, instead of re-firing on the first one.
fetchStatus.splice(0);
const ipSources = [
[0, getIPFromIPChecking4],
[1, getIPFromIPChecking6],
@@ -274,10 +297,7 @@ const fetchIPDetails = async (cardIndex, ip, sourceID = null) => {
sourceID = sourceID || ipGeoSource.value;
const card = ipDataCards[cardIndex];
card.ip = ip;
let setLang = lang.value;
if (setLang === 'zh') {
setLang = 'zh-CN';
}
const setLang = toApiTag(lang.value);
// Check if the IP data is already in the cache
if (ipDataCache.has(ip)) {
@@ -329,7 +349,7 @@ const fetchIPDetails = async (cardIndex, ip, sourceID = null) => {
return;
}
} catch (error) {
console.error(`Error fetching IP details from source ${source.id} (${fetchErrorLabel(error)}):`, error);
logSourceFetchFailure(`Error fetching IP details from source ${source.id} (${fetchErrorLabel(error)}):`, error);
currentSourceIndex = (currentSourceIndex + 1) % sources.length;
attempts++;
}
@@ -363,14 +383,16 @@ const selectIPGeoSource = async () => {
});
ipDataCache.clear();
// Query every card that holds a valid IP against the chosen source, in
// parallel. Captured once so an in-flight fetchIPDetails mutating
// Query every card that holds a publicly routable IP against the chosen
// source, in parallel — a card's ip slot can hold an error label or an
// address from reserved space, neither of which the geo routes accept.
// chosenSource is captured once so an in-flight fetchIPDetails mutating
// ipGeoSource.value can't shift the source mid-batch.
const chosenSource = ipGeoSource.value;
await Promise.allSettled(
ipDataCards
.map((card, index) => ({ card, index }))
.filter(({ card }) => isValidIP(card.ip))
.filter(({ card }) => isUsablePublicIP(card.ip))
.map(({ card, index }) => fetchIPDetails(index, card.ip, chosenSource))
);
};
@@ -420,14 +442,21 @@ watch(IPArray, () => {
store.updateAllIPs(IPArray.value);
});
onMounted(() => {
store.setMountingStatus('IPInfo', true);
// Command owner: refresh one card ({ index }) or the whole grid. Resolves
// with the next ipinfo:finished snapshot — the grid re-emits it whenever a
// card settles, single-card refreshes included.
useAppCommand('ipinfo:refresh', ({ index } = {}) => {
const finished = waitForAppEvent('ipinfo:finished');
if (Number.isInteger(index) && ipDataCards[index]) {
refreshCard(ipDataCards[index], index);
} else {
checkAllIPs();
}
return finished;
});
defineExpose({
checkAllIPs,
ipDataCards,
refreshCard,
onMounted(() => {
store.setMountingStatus('IPInfo', true);
});
</script>
+115 -240
View File
@@ -1,56 +1,59 @@
<template>
<!-- iOS PWA safe-area painter. Pairs with apple-mobile-web-app-status-bar-style=black-translucent
in index.html — the only way to get a live status-bar tint on iOS PWA, since WebKit
ignores JS theme-color writes and media-variant theme-color tags in standalone mode.
Color tracks --page-bg (style.css), which follows .dark class. -->
<div class="fixed top-0 left-0 right-0 z-50 pointer-events-none transition-colors duration-300"
style="height: env(safe-area-inset-top); background: var(--page-bg);" aria-hidden="true"></div>
<!-- The iOS status bar is opaque (no apple-mobile-web-app-status-bar-style), so the
inset is non-zero only on landscape notch edges. jn-site-nav: see style.css. -->
<header
class="fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
:class="{ '-translate-y-full': isNavHidden,
'bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur': !isPwa || (isPwa && !isMobile),
'bg-page-bg': isPwa && isMobile }">
<nav id="navbar-top" class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<nav id="navbar-top" ref="navEl"
class="relative mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<!-- Left: Hamburger (only mobile) + Brand -->
<div class="flex items-center gap-2">
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
<!-- Left: Hamburger (collapsed nav only) + Brand. Expanded, every group
keeps its natural width so use-nav-overflow measures true sizes. -->
<div ref="brandEl" class="flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<Button v-if="isNavCollapsed" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
aria-label="Toggle navigation menu" @click="store.toggleSheet('navMenu')">
<Menu />
</Button>
<a href="#" @click="handleLogoClick"
<a :href="isHome ? '#' : homeHref" @click="handleLogoClick"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-lg font-semibold text-foreground no-underline hover:opacity-80 transition-opacity">
<brandIcon />
<span class="tracking-tight truncate">
<span class="font-bold">IP</span><span class="font-extralight">Check.</span>
<span class="font-extralight"
:class="{ 'jn-shimmer-light': !loaded && !isDarkMode, 'jn-shimmer-dark': !loaded && isDarkMode }">ing</span>
:class="{ 'jn-shimmer-light': shimmer && !isDarkMode, 'jn-shimmer-dark': shimmer && isDarkMode }">ing</span>
</span>
</a>
</div>
<!-- Middle: Desktop nav links + GitHub star badge (left aligned, next to brand) -->
<div v-if="!isMobile" class="flex items-center gap-0.5">
<!-- Middle: nav links + GitHub star badge (left aligned, next to brand).
Always rendered so its width stays measurable; collapsed, it is
invisible, out of flow and out of the a11y tree / tab order. -->
<div ref="linksEl" class="flex w-max shrink-0 items-center gap-0.5"
:class="{ 'invisible absolute left-0 top-0 pointer-events-none': isNavCollapsed }"
:aria-hidden="isNavCollapsed ? 'true' : undefined" :inert="isNavCollapsed || undefined">
<template v-for="item in navItems" :key="item">
<!-- Advanced Tools: hover reveals the sub-tools, click scrolls to the
section (disable-click-trigger frees the click from toggling the
menu; viewport=false anchors the panel under the trigger). -->
<!-- Advanced Tools: hover reveals the sub-tools, click goes to the
section like the other links (disable-click-trigger frees the
click from toggling the menu; viewport=false anchors the panel
under the trigger). -->
<NavigationMenu v-if="item === 'AdvancedTools'" as="div" :viewport="false" :disable-click-trigger="true"
class="flex-none">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger :class="['h-auto bg-transparent', navLinkClass(item)]"
@click="scrollToSection('AdvancedTools'); trackEvent('Nav', 'NavClick', item)">
<NavigationMenuTrigger :class="['h-auto bg-transparent whitespace-nowrap', navLinkClass(item)]"
@click="goToSection(item)">
{{ t(`nav.${item}`) }}
</NavigationMenuTrigger>
<NavigationMenuContent class="z-50">
<!-- Two-column grid on PC -->
<ul class="relative grid grid-cols-2 gap-x-4 gap-y-0.5 min-w-[24rem]">
<ul class="relative grid grid-cols-2 gap-x-4 gap-y-0.5 min-w-[28rem]">
<span aria-hidden="true"
class="pointer-events-none absolute inset-y-1 left-1/2 w-px -translate-x-1/2 bg-border"></span>
<li v-for="tool in advancedTools" :key="tool.slug">
<NavigationMenuLink as-child class="cursor-pointer">
<button type="button" class="w-full truncate text-left" @click="openTool(tool.slug)">
<NavigationMenuLink as-child class="cursor-pointer" :active="tool.slug === currentToolSlug">
<button type="button" class="w-full text-left leading-snug" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
</NavigationMenuLink>
@@ -60,9 +63,8 @@
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<!-- All other sections stay plain smooth-scroll anchors. -->
<a v-else href="#" :class="navLinkClass(item)"
@click.prevent="scrollToSection(item); trackEvent('Nav', 'NavClick', item)">
<!-- All other sections -->
<a v-else href="#" :class="[navLinkClass(item), 'whitespace-nowrap']" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
@@ -80,125 +82,23 @@
</div>
<!-- Right: Action area (ml-auto push to the right) -->
<div class="ml-auto flex items-center gap-2">
<div ref="actionsEl" class="ml-auto flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<!-- Earth Online entry (code name: pulse) -->
<Pulse />
<!-- Docs assistant entry point (ask box on desktop, icon on mobile) -->
<!-- Docs assistant entry point (ask box from xl up, icon below) -->
<DocsSearch />
<!-- Preferences — standalone cog only for Firebase-less self-hosted
instances (no user menu to host it). With the user system on,
preferences lives inside the user dropdown for every state. -->
<JnTooltip v-if="!isFireBaseSet" :text="t('shortcutKeys.Preferences')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@click="OpenPreferences">
<Cog />
</Button>
</JnTooltip>
<!-- Sign In / User Dropdown -->
<DropdownMenu v-if="isFireBaseSet">
<DropdownMenuTrigger as-child>
<!-- Not signed in: the solid block reads as the "sign in"
call-to-action, and the menu opens on the sign-in options, so
the affordance is self-explaining one click deep. -->
<Button v-if="!isSignedIn" size="sm" @click="getUserInfo"
class="h-8 gap-1 px-1.5 cursor-pointer" aria-label="User menu">
<UserRound class="size-5" />
<ChevronDown class="opacity-60" />
</Button>
<!-- Signed in: avatar + chevron -->
<Button v-else variant="ghost" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1 cursor-pointer"
aria-label="User menu">
<span class="inline-flex size-7 overflow-hidden rounded-full">
<img :src="userPhotoURL" :alt="userName" :title="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<ChevronDown class="opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 shadow-md">
<!-- Signed in -->
<template v-if="isSignedIn">
<div class="px-2 pt-2 pb-3">
<div class="flex items-center gap-3">
<span class="inline-flex size-10 overflow-hidden rounded-full shrink-0">
<img :src="userPhotoURL" :alt="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-sm font-semibold leading-none">{{ userName }}</span>
<span v-if="remoteUserInfoFetched && remoteUserInfo.userLevel">
<Badge :class="levelBadgeClass"
class="border-transparent text-[10px] font-medium px-1.5 py-0 h-4">
{{ t('user.Level.' + remoteUserInfo.userLevel) }}
</Badge>
</span>
<span v-else-if="!remoteUserInfoFetched" class="text-xs text-muted-foreground">{{
t('user.Fields.Fetching') }}</span>
</div>
</div>
<dl class="mt-3 space-y-1 text-xs">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.CreatedAt') }}</dt>
<dd class="font-medium">{{ userCreatedAt }}</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.FunctionUses') }}</dt>
<dd class="font-medium">
<span v-if="remoteUserInfoFetched">{{ remoteUserInfo.functionUses?.total ?? 0 }}</span>
<span v-else class="text-muted-foreground">{{ t('user.Fields.Fetching') }}</span>
</dd>
</div>
</dl>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerAchievements(true)">
<Award />
<span>{{ t('user.MyAchievements') }}</span>
</DropdownMenuItem>
</template>
<!-- Not signed in -->
<template v-else>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGoogle">
<Icon icon="ri:google-line" />
<span>{{ t('user.SignInWithGoogle') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGithub">
<Icon icon="ri:github-line" />
<span>{{ t('user.SignInWithGithub') }}</span>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="OpenPreferences">
<Cog />
<span>{{ t('nav.preferences.title') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerUserBenefits(true)">
<HeartHandshake />
<span>{{ t('user.Benefits.Title') }}</span>
</DropdownMenuItem>
<template v-if="isSignedIn">
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.signOut">
<LogOut />
<span>{{ t('user.SignOut') }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
<UserMenu />
</div>
</nav>
<!-- Mobile navigation drawer. Flex column so the link list scrolls instead
of clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-72 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
<!-- Navigation sheet of the collapsed nav (a phone, or a viewport where the
links don't fit). Flex column so the link list scrolls instead of
clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isNavCollapsed" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h5 class="m-0 text-base font-semibold">{{ t('nav.Navigation') }}</h5>
<SheetClose />
@@ -219,16 +119,15 @@
<CollapsibleContent>
<div class="my-0.5 ml-3 flex flex-col gap-0.5 border-l pl-3">
<button v-for="tool in advancedTools" :key="tool.slug" type="button"
class="block w-full truncate rounded-md px-3 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
@click="openTool(tool.slug)">
class="block w-full rounded-md px-3 py-1.5 text-left text-sm leading-snug text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground aria-[current=page]:bg-accent/50 aria-[current=page]:text-foreground"
:aria-current="tool.slug === currentToolSlug ? 'page' : undefined" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
</div>
</CollapsibleContent>
</Collapsible>
<!-- All other sections stay plain smooth-scroll anchors. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })"
@click.prevent="scrollToSection(item); trackEvent('Nav', 'NavClick', item); store.setOpenSheet(null)">
<!-- All other sections, as in the expanded nav. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
@@ -251,12 +150,13 @@
</template>
<script setup>
// The site navigation bar, rendered once by App.vue above every route; what a
// section or tool entry does on the current route is utils/nav-target.js.
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { unixToDateTime } from '@/utils/time-utils';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import {
@@ -269,51 +169,62 @@ import {
} from '@/components/ui/navigation-menu';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { JnTooltip } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Award, ChevronDown, UserRound, HeartHandshake,
LogOut, Menu, Cog,
} from '@lucide/vue';
import { ChevronDown, Menu } from '@lucide/vue';
import DocsSearch from '@/components/widgets/DocsSearch.vue';
import Pulse from '@/components/widgets/Pulse.vue';
import UserMenu from '@/components/UserMenu.vue';
import { Icon } from '@iconify/vue';
import brandIcon from './svgicons/Brand.vue';
import { SECTION_IDS } from '@/data/sections';
import { ADVANCED_TOOLS } from '@/data/tools.js';
import { listedTools } from '@/utils/tool-availability.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { formatStarCount } from '@/utils/format-star-count.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { useNavOverflow } from '@/composables/use-nav-overflow.js';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const { navigateTo } = useNavTarget();
const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile);
const currentSection = computed(() => store.currentSection);
const loaded = computed(() => store.allHasLoaded);
// Running as an installed PWA (chromeless window). Distinct from the app's
// "standalone tool pages" — see utils/pwa.js.
// The link row collapses into the hamburger + Sheet on a phone, or wherever
// it doesn't fit beside the brand and actions. isMobile alone drives phone
// behaviour (scroll-hide, PWA background).
const NAV_GAP_PX = 8; // the nav's gap-2
const navEl = ref(null);
const brandEl = ref(null);
const linksEl = ref(null);
const actionsEl = ref(null);
const { overflow } = useNavOverflow({
nav: navEl, brand: brandEl, links: linksEl, actions: actionsEl, gap: NAV_GAP_PX,
});
const isNavCollapsed = computed(() => isMobile.value || overflow.value);
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
const isHome = computed(() => route.name === 'home');
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
const homeHref = router.resolve('/').href;
const loaded = computed(() => store.allHasLoaded);
// The brand shimmers while the homepage tests run.
const shimmer = computed(() => isHome.value && !loaded.value);
// Running as an installed PWA (chromeless window) — see utils/pwa.js.
const isPwa = isRunningAsPwa();
const navItems = SECTION_IDS;
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: original-site-
// only tools stay hidden on self-hosted instances. Reactive on configs.
// Gated tools stay hidden where the deployment lacks them, as in the card grid.
const configs = computed(() => store.configs);
const advancedTools = computed(() =>
ADVANCED_TOOLS.filter((tool) => !tool.requiresOriginalSite || configs.value.originalSite),
);
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
// Sheet: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// GitHub star count for the repo badge. Fetched from our own edge-cached
@@ -340,49 +251,20 @@ const navLinkClass = (item, { block = false } = {}) => {
return [base, state, block ? 'block' : ''].filter(Boolean).join(' ');
};
// Firebase / User
const isFireBaseSet = computed(() => store.isFireBaseSet);
const isSignedIn = computed(() => store.isSignedIn);
const userName = computed(() => store.user?.displayName);
const userPhotoURL = computed(() => store.user?.photoURL);
const userCreatedAt = computed(() => unixToDateTime(store.user?.metadata.createdAt));
const remoteUserInfo = computed(() => store.remoteUserInfo);
const remoteUserInfoFetched = computed(() => store.remoteUserInfoFetched);
// Level Badge Color: mapped to semantic token, keep each level color distinction
const levelBadgeClass = computed(() => {
const level = remoteUserInfo.value?.userLevel;
switch (level) {
case 'Premium': return 'bg-action text-action-foreground';
case 'Owner': return 'bg-foreground text-background';
case 'Developer': return 'bg-success text-success-foreground';
case 'HonoraryMember': return 'bg-warning text-warning-foreground';
case 'Standard':
default: return 'bg-muted-foreground text-background';
}
});
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
store.setTriggerRemoteUserInfo(true);
};
const isNavMenuOpen = computed(() => store.openSheet === 'navMenu');
const onNavMenuChange = (val) => {
store.setOpenSheet(val ? 'navMenu' : null);
};
// Opens the Preferences sheet
const OpenPreferences = () => {
store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences');
};
// At top → full refresh; mid-page → smooth scroll up. preventDefault
// avoids the native instant-jump of <a href="#">.
// Off the homepage → home (a real link there, so modified clicks open a new
// tab). On it: at top → full refresh; mid-page → smooth scroll up.
// preventDefault avoids the native instant-jump of <a href="#">.
const handleLogoClick = (e) => {
if (window.scrollY === 0) {
if (!isHome.value) {
if (!isPlainClick(e)) return;
e.preventDefault();
router.push('/');
} else if (window.scrollY === 0) {
store.setRefreshEveryThing(true);
} else {
e.preventDefault();
@@ -391,29 +273,24 @@ const handleLogoClick = (e) => {
trackEvent('Nav', 'NavClick', 'Logo');
};
// Menu scroll (leave space for sticky header)
const scrollToSection = (el, offset = 70) => {
const element = typeof el === 'string' ? document.getElementById(el) : el;
if (!element) return;
const y = element.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: 'smooth' });
// Section and tool entries close the nav Sheet first and navigate once
// its slide-out is over: WebKit records the back/forward snapshot of a page
// as it navigates away, and a swipe back would show the sheet still open.
const SHEET_CLOSE_MS = 300; // ui/sheet data-[state=closed]:duration-300
const afterSheetClosed = (fn) => {
if (!isNavMenuOpen.value) return fn();
store.setOpenSheet(null);
setTimeout(fn, SHEET_CLOSE_MS);
};
const goToSection = (section) => {
afterSheetClosed(() => navigateTo({ section }));
trackEvent('Nav', 'NavClick', section);
};
// Open a tool from the nav: scroll to the Advanced Tools section, then raise the
// drawer (driven by the `?tool=` query Advanced.vue watches). Scrolls twice — the
// mobile nav Sheet locks body scroll until it closes, so the first scroll is a
// no-op there and the deferred one lands after the Sheet is gone.
let openToolTimer = null;
const openTool = (slug) => {
store.setOpenSheet(null); // close the mobile nav Sheet (no-op on desktop)
scrollToSection('AdvancedTools');
clearTimeout(openToolTimer);
openToolTimer = setTimeout(() => {
scrollToSection('AdvancedTools');
router.push({ path: '/', query: { tool: slug } });
}, 300);
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
afterSheetClosed(() => navigateTo({ tool: slug }));
trackEvent('Nav', 'NavClick', slug);
};
// Mobile: hide nav on scroll-down, show on scroll-up.
@@ -434,7 +311,7 @@ const onScroll = () => {
if (y <= SHOW_AT_TOP) {
isNavHidden.value = false;
} else if (Math.abs(dy) > SCROLL_DELTA) {
// Keep nav visible while the menu drawer is open so its close
// Keep nav visible while the menu sheet is open so its close
// affordance stays in place.
if (dy > 0 && !isNavMenuOpen.value) {
isNavHidden.value = true;
@@ -447,27 +324,25 @@ const onScroll = () => {
});
};
watch(isMobile, (mobile) => {
if (!mobile) {
isNavHidden.value = false;
window.removeEventListener('scroll', onScroll);
// Mobile only; attaching resyncs lastScrollY so the first scroll isn't a phantom jump.
const setScrollListener = (on) => {
if (on) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
} else {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
window.removeEventListener('scroll', onScroll);
}
}, { immediate: false });
};
onMounted(() => {
if (isMobile.value) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
}
fetchGithubStars();
});
watch(isMobile, (mobile) => {
if (!mobile) isNavHidden.value = false;
setScrollListener(mobile);
}, { immediate: true });
onMounted(fetchGithubStars);
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll);
clearTimeout(openToolTimer);
setScrollListener(false);
});
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<!-- Breadcrumb above the <h1> of every page but Home: ← Home / … / current page.
Markup and classes follow shadcn-vue's Breadcrumb (ui/ has no copy-in). -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
<!-- Home: back in history when Home is the previous entry, so it returns as left -->
<li class="inline-flex items-center gap-1.5">
<a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
@click="onHome">
<ArrowLeft class="size-3.5" aria-hidden="true" />
{{ t('nav.Home') }}
</a>
</li>
<template v-for="(crumb, index) in items" :key="index">
<li role="presentation" aria-hidden="true">/</li>
<li class="inline-flex min-w-0 items-center gap-1.5">
<a v-if="crumb.section" :href="homeHref" class="transition-colors hover:text-foreground"
@click="onSection($event, crumb.section)">{{ crumb.label }}</a>
<span v-else role="link" aria-disabled="true" aria-current="page"
class="font-normal text-foreground">{{ crumb.label }}</span>
</li>
</template>
</ol>
</nav>
</template>
<script setup>
// `items` are the localized crumbs after Home: `{ label, section }` links to a
// homepage section, reached as the Nav does; a bare `{ label }` is the current
// page. A trail may end on a section when the <h1> beside it names the page.
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ArrowLeft } from '@lucide/vue';
import { resolveBackTarget } from '@/utils/back-target.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { trackEvent } from '@/utils/analytics';
defineProps({
items: { type: Array, default: () => [] },
});
const { t } = useI18n();
const router = useRouter();
const { navigateTo } = useNavTarget();
const homeHref = router.resolve('/').href;
const onHome = (event) => {
if (!isPlainClick(event)) return;
event.preventDefault();
if (resolveBackTarget(window.history.state) === 'back') router.back();
else router.push('/');
trackEvent('Nav', 'BreadcrumbClick', 'Home');
};
const onSection = (event, section) => {
if (!isPlainClick(event)) return;
event.preventDefault();
navigateTo({ section });
trackEvent('Nav', 'BreadcrumbClick', section);
};
</script>
+34 -38
View File
@@ -1,17 +1,15 @@
<template>
<!-- Standalone privacy page at /privacy (shareable + crawlable) -->
<div class="flex min-h-screen flex-col">
<!-- Slim header (shared with the standalone tool pages). -->
<StandalonePageHeader :title="t('about.Privacy')" />
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
<PageBreadcrumb :items="[{ label: t('about.Privacy') }]" />
<!-- Content -->
<main class="flex-1">
<!-- Brief loading state while the locale pack is fetched on first paint. -->
<div v-if="!ready" class="flex justify-center py-20">
<Spinner />
</div>
<article v-else class="mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
<article v-else>
<h1 class="mb-1 text-2xl md:text-3xl font-semibold tracking-tight">{{ t('privacy.Title') }}</h1>
<p class="mb-6 text-xs text-muted-foreground">{{ t('privacy.UpdatedLabel') }}: {{ LAST_UPDATED }}</p>
@@ -47,8 +45,9 @@ import { useMainStore } from '@/store';
import { isAnalyticsEnabled } from '@/utils/analytics';
import { isDocsConfigured } from '@/composables/use-docs-assistant.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
import Footer from '@/components/Footer.vue';
import StandalonePageHeader from '@/components/StandalonePageHeader.vue';
import PageBreadcrumb from '@/components/PageBreadcrumb.vue';
import { Spinner } from '@/components/ui/spinner';
const { t, tm, rt, locale, mergeLocaleMessage } = useI18n();
@@ -56,7 +55,7 @@ const store = useMainStore();
const { isFireBaseSet } = storeToRefs(store);
// Bump when the policy text changes materially.
const LAST_UPDATED = '2026-08-09';
const LAST_UPDATED = '2026-08-20';
// Sentry telemetry is a build-time decision (see frontend/AGENTS.md): the
// section only renders on deployments actually built with a DSN.
@@ -71,48 +70,45 @@ const isReportSharingEnabled = computed(() => store.configs?.reportSharing === t
// section never describes a feature this deployment doesn't ship.
const isDocsAssistantEnabled = computed(() => isDocsConfigured && store.configs?.originalSite === true);
// Privacy copy is loaded on demand per locale (mirrors the security-checklist
// dataset pattern), then merged into i18n so t() / tm() can resolve it.
const privacyLoaders = {
en: () => import('@/locales/privacy/en.json'),
zh: () => import('@/locales/privacy/zh.json'),
fr: () => import('@/locales/privacy/fr.json'),
ru: () => import('@/locales/privacy/ru.json'),
};
// Persona Check is the one tool whose measurements leave the browser, so it
// gets its own section — gated exactly like the tool itself (originalSite in
// data/tools.js), which keeps the section off deployments without it.
const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === true);
const loaded = new Set();
// Privacy copy is loaded on demand per locale (same loader as the security
// checklist dataset), then merged into i18n so t() / tm() can resolve it.
// A pack is complete or absent (tests/locale-packs.test.js), so whichever
// pack the chain resolves to is merged under the ACTIVE locale — the page
// reveals with one language, never a mix, and never raw keys unless the whole
// chain is unreachable.
const privacyLoaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json'));
const privacyCache = new Map();
const merged = new Set();
const ready = ref(false);
// Merge one locale's privacy copy into i18n (memoized). Does NOT touch `ready` —
// the caller decides when to reveal, so a background fallback load can't race the
// active locale and paint the wrong language.
const loadPrivacy = async (loc) => {
if (loaded.has(loc)) return;
const load = privacyLoaders[loc] || privacyLoaders.en;
const { default: msgs } = await load();
mergeLocaleMessage(loc, msgs);
loaded.add(loc);
};
// Reveal only after the ACTIVE locale's copy is merged — otherwise the en
// fallback load could resolve first and paint English (t() falling back) until
// the next re-render. The en fallback (covering any key the active locale might
// miss) loads in the background and doesn't gate the reveal.
watch(locale, async (loc) => {
ready.value = false;
await loadPrivacy(loc);
if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight
if (loc !== 'en') loadPrivacy('en');
const pack = await loadLocaleDataset(privacyLoaders, loc, privacyCache);
if (loc !== locale.value) return; // stale load — the locale changed mid-flight
if (pack) {
if (!merged.has(loc)) mergeLocaleMessage(loc, pack.data);
merged.add(loc);
} else {
console.error('Privacy copy unavailable in every locale; rendering keys');
}
ready.value = true;
}, { immediate: true });
// Ordered section ids, gated on which collection actually happens here. The
// "why" section explains the reason for whichever collection is active.
const order = computed(() => {
const ids = ['tools'];
// Earth Online — build-time gate (VITE_PULSE_URL), same as its nav entry
// point: the section only renders on builds that actually ship the feature.
if ((import.meta.env ?? {}).VITE_PULSE_URL) ids.push('pulse');
// Earth Online — gated on the pulse beacon backend (build-time), where all its
// collection (beacon / status sends) happens; the outage-only mode
// collects nothing, so no section.
if ((import.meta.env ?? {}).VITE_PULSE_BEACON_URL) ids.push('pulse');
if (isReportSharingEnabled.value) ids.push('sharedReports');
if (isPersonaCheckEnabled.value) ids.push('personaCheck');
if (isDocsAssistantEnabled.value) ids.push('docsAssistant');
if (isAnalyticsEnabled) ids.push('analytics');
if (isFireBaseSet.value) ids.push('account');
+94 -41
View File
@@ -88,8 +88,13 @@
</div>
<!-- Progress Bar -->
<Progress :model-value="state.speedTest.progress" class="mb-6"
:class="[progressIndicatorClass, state.speedTest.status === 'idle' ? 'invisible' : '']" />
<div class="mb-6" :class="{ invisible: state.speedTest.status === 'idle' }">
<p class="mb-2 text-sm text-muted-foreground" role="status" aria-live="polite" aria-atomic="true">
{{ progressLabel }}
</p>
<Progress :model-value="state.speedTest.progress" :aria-label="progressLabel"
:class="progressIndicatorClass" />
</div>
<!-- 4 Metrics Tiles -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
@@ -140,18 +145,31 @@
</p>
<p class="mb-2">
{{ t('speedtest.score') }}
{{ t('speedtest.videoStreaming') }} :
{{ t('speedtest.videoStreaming') }} :
<span :class="qualityBadgeClass(state.speedTest.streamingQuality)">{{ t('speedtest.quality.' +
state.speedTest.streamingQuality) }}</span> ;
{{ t('speedtest.gaming') }}:
state.speedTest.streamingQuality) }}</span> ;
{{ t('speedtest.gaming') }}:
<span :class="qualityBadgeClass(state.speedTest.gamingQuality)">
{{ t('speedtest.quality.' + state.speedTest.gamingQuality) }}
</span> ;
{{ t('speedtest.rtc') }}:
</span> ;
{{ t('speedtest.rtc') }}:
<span :class="qualityBadgeClass(state.speedTest.rtcQuality)">
{{ t('speedtest.quality.' + state.speedTest.rtcQuality) }}
</span>
</p>
<!-- Next step: the latency / route tools. Real links (modified
clicks stay the browser's); break-normal undoes the block's
break-all for prose. -->
<i18n-t keypath="speedtest.ToolHint" tag="p" scope="global" class="m-0 break-normal">
<template #ping>
<a href="/tools/pingtest" :class="toolLinkClass" @click="openTool($event, 'pingtest')">
{{ t('pingtest.Title') }}</a>
</template>
<template #mtr>
<a href="/tools/mtrtest" :class="toolLinkClass" @click="openTool($event, 'mtrtest')">
{{ t('mtrtest.Title') }}</a>
</template>
</i18n-t>
</span>
</div>
</div>
@@ -165,26 +183,34 @@
</div>
</CardContent>
</Card>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="speedtest" :settled="hasEverSettled" />
</section>
</template>
<script setup>
import { reactive, computed, onMounted, markRaw, onUnmounted } from 'vue';
import { reactive, ref, computed, onMounted, markRaw, onUnmounted, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { isPlainClick } from '@/utils/nav-target.js';
import { emitAppEvent } from '@/utils/app-events.js';
import { useAppCommand } from '@/composables/use-app-command.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { isValidIP } from '@/utils/valid-ip.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { isValidIP } from '@/utils/ip/valid-ip.js';
import { parseTrace } from '@/utils/ip/parse-trace.js';
import getCountryName from '@/data/country-name.js';
import SpeedTestEngine from '@cloudflare/speedtest';
import { createSpeedTestSession, getSpeedTestLiveValues } from '@/utils/features/speedtest-session.js';
import useSpeedTestCharts from '@/composables/use-speedtest-charts.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import {
ArrowLeftRight, CalendarCheck2, CircleAlert, Play, CloudDownload, CloudUpload,
Globe, Pause, PersonStanding, RotateCw,
@@ -192,10 +218,24 @@ import {
import { Icon } from '@iconify/vue';
const { t } = useI18n();
const router = useRouter();
// Tool links in the result copy. A plain click routes in-app and sends
// ToolEntry (category = this section, label = slug), which is how GA tells
// this entrance apart from the card grid's NavClick.
const toolLinkClass = 'font-medium underline underline-offset-4 decoration-current/40 hover:decoration-current';
const openTool = (event, slug) => {
if (!isPlainClick(event)) return;
event.preventDefault();
trackEvent('SpeedTest', 'ToolEntry', slug);
router.push({ path: `/tools/${slug}` });
};
const store = useMainStore();
const lang = computed(() => store.lang);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
// Sticky settled flag for the section's banner slot: true once a run finishes.
const hasEverSettled = ref(false);
// State Management
const state = reactive({
speedTest: {
@@ -213,6 +253,7 @@ const state = reactive({
rtcScore: '-',
rtcQuality: '-',
status: 'idle',
phase: 'probe',
hasScores: false,
progress: 0,
},
@@ -221,7 +262,7 @@ const state = reactive({
package: {
download: { bytes: 50e6, count: 4 },
upload: { bytes: 15e6, count: 4 },
latency: { count: 30 },
latency: { count: 20 },
},
},
});
@@ -241,6 +282,13 @@ const isPaused = computed(() => state.speedTest.status === 'paused');
const isFinished = computed(() => state.speedTest.status === 'finished');
const isError = computed(() => state.speedTest.status === 'error');
const progressLabel = computed(() => {
if (isFinished.value) return t('speedtest.phaseFinished');
if (isError.value) return t('speedtest.phaseFailed');
const phase = t(`speedtest.phases.${state.speedTest.phase}`);
return isPaused.value ? t('speedtest.phasePaused', { phase }) : phase;
});
// CTA Icon: running=Pause, finished/error=Retry, otherwise=Start
const ctaIcon = computed(() => {
if (isRunning.value) return Pause;
@@ -324,17 +372,11 @@ const connectionMethods = {
// --- Test Engine ----------------------------------------------------------
let testEngine;
let runId = 0;
const engineMethods = {
// Build a fresh engine. State reset is owned by speedTestController.
reset() {
return markRaw(new SpeedTestEngine({
autoStart: false,
measurements: [
{ type: 'latency', numPackets: state.config.package.latency.count },
{ type: 'download', bytes: state.config.package.download.bytes, count: state.config.package.download.count },
{ type: 'upload', bytes: state.config.package.upload.bytes, count: state.config.package.upload.count },
],
}));
return markRaw(createSpeedTestSession(SpeedTestEngine, state.config.package));
},
updateResults(results) {
@@ -356,33 +398,25 @@ const engineMethods = {
// onUnmounted dropped the reference, so the engine may already be gone.
updateProgress() {
const rawData = testEngine?.results?.raw;
if (!rawData) return;
if (!rawData || testEngine.isProbe) return;
// 3 stages: latency / download / upload.
const perStage = 100 / 3;
let progress = 0;
if (rawData.latency?.started) progress += rawData.latency.finished ? perStage : perStage / 2;
if (rawData.download?.started) progress += rawData.download.finished ? perStage : perStage / 2;
if (rawData.upload?.started) progress += rawData.upload.finished ? perStage : perStage / 2;
state.speedTest.progress = Math.min(progress, 100);
state.speedTest.progress = Math.max(state.speedTest.progress, Math.min(progress, 100));
},
// Live values via engine getters (supported API). Getters can briefly return
// `null` between "started" and "first datapoint", so guard with `!= null`.
// Keep preview readings until each formal metric has actual samples.
// CF bandwidth/latency getters can return zero for an empty sample set.
updateSpeedInRealTime() {
if (state.speedTest.status === 'finished' || state.speedTest.status === 'error') return;
try {
const results = testEngine?.results;
if (!results) return;
const dl = results.getDownloadBandwidth();
const ul = results.getUploadBandwidth();
const lat = results.getUnloadedLatency();
const jit = results.getUnloadedJitter();
if (dl != null) state.speedTest.downloadSpeed = parseFloat((dl / 1e6).toFixed(2));
if (ul != null) state.speedTest.uploadSpeed = parseFloat((ul / 1e6).toFixed(2));
if (lat != null) state.speedTest.latency = parseFloat(lat.toFixed(2));
if (jit != null) state.speedTest.jitter = parseFloat(jit.toFixed(2));
Object.assign(state.speedTest, getSpeedTestLiveValues(results));
updateCharts(
state.speedTest.downloadSpeed,
@@ -399,17 +433,24 @@ const engineMethods = {
// --- Test Control ----------------------------------------------------------
const setupTestEngine = async () => {
const loadConnection = async (currentRun) => {
if (!state.connection.ip) {
const connectionData = await connectionMethods.getIPFromSpeedTest();
if (connectionData) {
if (connectionData && currentRun === runId) {
Object.assign(state.connection, connectionData);
// Feed the Globalping picker + IP history.
store.updateAllIPs([{ ip: connectionData.ip, country: connectionData.loc || '', location: connectionData.country || '' }]);
}
}
};
testEngine.onRunningChange = () => { state.speedTest.status = 'running'; };
const setupTestEngine = () => {
testEngine.onPhaseChange = (phase) => { state.speedTest.phase = phase; };
testEngine.onRunningChange = (running) => {
state.speedTest.status = running ? 'running' : 'paused';
// Show that startup is underway before the first measurement arrives.
if (running) state.speedTest.progress = Math.max(state.speedTest.progress, 5);
};
testEngine.onResultsChange = () => {
engineMethods.updateProgress();
engineMethods.updateSpeedInRealTime();
@@ -462,6 +503,7 @@ const setupTestEngine = async () => {
// Achievement rules for speed thresholds live in data/achievement-rules.js;
// the report collector consumes the full result (unmeasured fields keep
// their '-' placeholder — the builder drops non-numbers).
hasEverSettled.value = true;
emitAppEvent('speedtest:finished', {
downloadSpeed: state.speedTest.downloadSpeed,
uploadSpeed: state.speedTest.uploadSpeed,
@@ -506,29 +548,40 @@ const speedTestController = async () => {
return;
}
const currentRun = ++runId;
testEngine?.destroy();
resetChartData();
destroyCharts();
await initStartingPoints();
Object.assign(state.speedTest, {
downloadSpeed: 0, uploadSpeed: 0, latency: 0, jitter: 0,
downLoadedLatency: '-', upLoadedLatency: '-',
streamingScore: '-', gamingScore: '-', rtcScore: '-',
hasScores: false, progress: 0,
hasScores: false, progress: 0, phase: 'probe',
});
testEngine = engineMethods.reset();
if (!testEngine) { console.error('Failed to initialize test engine'); return; }
await setupTestEngine();
trackEvent('Section', 'StartClick', 'SpeedTest');
setupTestEngine();
testEngine.play();
// Resources enrich the running test; neither can delay the first request.
void nextTick().then(() => {
if (currentRun === runId) return initStartingPoints();
}).catch(error => console.error('Error initializing speed test charts:', error));
void loadConnection(currentRun).catch(error => console.error('Error loading speed test connection:', error));
trackEvent('Section', 'StartClick', 'SpeedTest');
} catch (error) {
console.error('Error in speedTestController:', error);
state.speedTest.status = 'error';
}
};
// Command owner: run / pause / resume toggle, same semantics as the section's
// own button. Resolves when the toggle applies, not when the test finishes.
useAppCommand('speedtest:toggle', () => speedTestController());
// --- Lifecycle ----------------------------------------------------------
onMounted(() => { store.setMountingStatus('SpeedTest', true); });
@@ -537,17 +590,17 @@ onMounted(() => { store.setMountingStatus('SpeedTest', true); });
// SpeedTestEngine would still try to write state refs that no longer exist.
// No-ops, not null: the engine's queued timers call these unconditionally.
onUnmounted(() => {
runId += 1;
if (testEngine) {
testEngine.onRunningChange = () => {};
testEngine.onResultsChange = () => {};
testEngine.onFinish = () => {};
testEngine.onError = () => {};
testEngine.destroy();
testEngine = null;
}
destroyCharts();
});
defineExpose({ speedTestController });
</script>
<style scoped>
@@ -1,49 +0,0 @@
<template>
<!-- Slim sticky header shared by the standalone pages (/tools/:slug and
/privacy). Brand → home, an optional breadcrumb title, and a back-to-home
button. This is NOT the homepage nav (Nav.vue) — that one carries the full
navigation, user menu, and mobile drawer; this is the minimal page chrome
those standalone layouts wrap their content in. -->
<header class="sticky top-0 z-40 border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur">
<div class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-4 h-14">
<RouterLink to="/"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-lg font-semibold text-foreground no-underline hover:opacity-80 transition-opacity"
aria-label="IPCheck.ing">
<brandIcon />
<span class="tracking-tight">
<span class="font-bold">IP</span><span class="font-extralight">Check.</span><span
class="font-extralight">ing</span>
</span>
</RouterLink>
<!-- Breadcrumb: " / <title>" — only when the page passes a title. -->
<template v-if="title">
<span class="text-muted-foreground" aria-hidden="true">/</span>
<span class="min-w-0 truncate font-medium">{{ title }}</span>
</template>
<!-- Button `as-child` renders the RouterLink as its root, so this is a
single <a> styled as a button — not a <button> nested in an <a>. -->
<Button as-child variant="default" class="ml-auto shrink-0 cursor-pointer">
<RouterLink to="/">
<ArrowLeft class="size-4" /> {{ t('advancedtools.BackToHome') }}
</RouterLink>
</Button>
</div>
</header>
</template>
<script setup>
// Shared slim header for standalone pages. The breadcrumb label is passed in as
// `title` (already localized by the caller); everything else is fixed chrome.
import { useI18n } from 'vue-i18n';
import brandIcon from '@/components/svgicons/Brand.vue';
import { ArrowLeft } from '@lucide/vue';
import { Button } from '@/components/ui/button';
defineProps({
// Breadcrumb text shown after the brand (e.g. "🌐 MTR Test" or "Privacy").
// Empty string renders just the brand + back button.
title: { type: String, default: '' },
});
const { t } = useI18n();
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<!-- Standalone page for a single advanced tool, reached at /tools/:slug
(new-window / shareable / crawlable). Renders the exact same tool
component the drawer does, just inside a minimal page chrome instead of
the homepage + drawer. -->
<div class="flex min-h-screen flex-col">
<!-- Slim header: brand → home, current tool breadcrumb, back link. -->
<StandalonePageHeader :title="tool ? `${tool.emoji} ${t(tool.titleKey)}` : ''" />
<!-- Content: an <h1> for the tool (SEO), then the tool body itself -->
<main class="flex-1">
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
<h1 v-if="tool" class="mb-4 flex items-center gap-2 text-2xl md:text-3xl font-semibold tracking-tight">
<span aria-hidden="true">{{ tool.emoji }}</span>
{{ t(tool.titleKey) }}
</h1>
<component :is="toolComponent" v-if="toolComponent" />
</div>
</main>
<Footer />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
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';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const tool = computed(() => TOOL_BY_SLUG.get(route.params.slug) || null);
const toolComponent = computed(() => (tool.value ? defineAsyncComponent(tool.value.component) : null));
// Per-tool head: localized title + description, self-referential canonical.
useDocumentMeta(() => {
if (!tool.value) return {};
return {
title: `${t(tool.value.titleKey)} · IPCheck.ing`,
description: t(tool.value.noteKey),
canonical: `${window.location.origin}/tools/${tool.value.slug}`,
};
});
// Unknown slug → bounce to the homepage rather than show an empty shell.
if (!tool.value) router.replace('/');
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<!-- The page of one advanced tool, /tools/:slug. At least a viewport tall
below the fixed Nav, so the footer sits at the bottom of a short page. -->
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<main class="flex-1">
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
<PageBreadcrumb v-if="tool" :items="breadcrumb" />
<h1 v-if="tool" class="mb-4 flex items-center gap-2 text-2xl md:text-3xl font-semibold tracking-tight">
<span aria-hidden="true">{{ tool.emoji }}</span>
{{ t(tool.titleKey) }}
</h1>
<!-- One cached instance per visited slug -->
<KeepAlive :key="authEpoch" :max="8">
<component :is="toolComponent" v-if="toolComponent" :key="tool.slug" />
</KeepAlive>
</div>
</main>
<Footer />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import { shouldDropToolCache } from '@/utils/tool-cache.js';
import Footer from '@/components/Footer.vue';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
import PageBreadcrumb from '@/components/PageBreadcrumb.vue';
defineOptions({ name: 'ToolPage' });
// One async wrapper per slug, built once: a new wrapper would remount the tool
// and lose its cached state. `delay` keeps fast loads skeleton-free.
const asyncToolCache = new Map();
const asyncToolFor = (tool) => {
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent({
loader: tool.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
}));
}
return asyncToolCache.get(tool.slug);
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useMainStore();
// Held while another route shows: the cached page keeps rendering its tool.
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
const slug = computed((previous) => routeSlug.value ?? previous ?? null);
const tool = computed(() => TOOL_BY_SLUG.get(slug.value) || null);
const breadcrumb = computed(() => [
{ label: t('advancedtools.Title'), section: 'AdvancedTools' },
]);
const toolComponent = computed(() => (tool.value ? asyncToolFor(tool.value) : null));
// Per-tool head: localized title + description, self-referential canonical.
useDocumentMeta(() => {
if (!tool.value) return {};
return {
title: `${t(tool.value.titleKey)} · IPCheck.ing`,
description: t(tool.value.noteKey),
canonical: `${window.location.origin}/tools/${tool.value.slug}`,
};
});
// Unknown slug → homepage, on every arrival (a cached instance included).
watch(routeSlug, (next) => {
if (next && !tool.value) router.replace('/');
}, { immediate: true });
// Re-keys the KeepAlive when the account goes away (utils/tool-cache.js), so
// a sign-out or account switch drops every cached tool.
const authEpoch = ref(0);
watch(() => store.user?.uid ?? null, (nextUid, prevUid) => {
if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1;
});
</script>
+170 -33
View File
@@ -1,43 +1,130 @@
<template>
<!-- User Benefits Dialog -->
<!-- User account dialog: Benefits + Usage merged behind Tabs. Signed-in
visitors land on the Usage tab (their own numbers are what they came
for); signed-out visitors land on Benefits. -->
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent :title="t('user.Benefits.Title')" class="max-w-2xl">
<DialogHeader :icon="HeartHandshake" :title="t('user.Benefits.Title')" />
<div class="space-y-4">
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('user.Benefits.Note1') }}</p>
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('user.Benefits.Note2') }}</p>
<Tabs v-model="activeTab">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="benefits" class="cursor-pointer">{{ t('user.Benefits.Benefit') }}</TabsTrigger>
<TabsTrigger value="usage" class="cursor-pointer">{{ t('user.Usage.Title') }}</TabsTrigger>
</TabsList>
<!-- Benefits List -->
<ul class="rounded-lg border bg-card divide-y">
<li v-for="n in 6" :key="n" class="flex items-start gap-3 p-3 text-sm">
<span
class="shrink-0 inline-flex items-center justify-center size-5 rounded-full bg-success/15 text-success mt-0.5">
<CircleCheck class="size-3.5" />
</span>
<span class="leading-relaxed">{{ t('user.Benefits.Benefit' + n) }}</span>
</li>
</ul>
<!-- Benefits tab: one card per tier, stacked single-column on
every viewport. Each tier's first item is "everything in
the previous tier" so the lists stay short; the sponsor
CTA is a button under its card, not a feature row. -->
<TabsContent value="benefits" class="space-y-3 pt-3">
<p class="text-sm text-muted-foreground leading-relaxed">
{{ t('user.Benefits.Note1') }} {{ t('user.Benefits.Note2') }}
</p>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('user.Benefits.FootNote') }}</p>
</div>
<section v-for="tier in benefitTiers" :key="tier.key"
class="flex flex-col rounded-lg border bg-card p-3"
:class="tier.current && 'border-success/60'">
<header class="flex items-center gap-1.5 mb-2">
<component :is="tier.icon" class="size-4 text-muted-foreground shrink-0" />
<h4 class="text-sm font-semibold m-0 truncate">
{{ t(`user.Benefits.Tiers.${tier.key}.Name`) }}
</h4>
<Badge v-if="tier.current" variant="success"
class="ml-auto text-[10px] px-1.5 py-0 h-4 shrink-0">
{{ t('user.Benefits.Current') }}
</Badge>
</header>
<ul class="space-y-1.5 list-none p-0 m-0">
<li v-for="n in tier.count" :key="n"
class="flex items-start gap-1.5 text-xs leading-relaxed">
<!-- Item1 of the higher tiers = "everything in the
previous tier" — rendered muted with a plus so
real additions stand out. -->
<component :is="tier.key !== 'Visitor' && n === 1 ? Plus : Check"
class="size-3.5 shrink-0 mt-0.5"
:class="tier.key !== 'Visitor' && n === 1 ? 'text-muted-foreground' : 'text-success'" />
<span :class="tier.key !== 'Visitor' && n === 1 ? 'text-muted-foreground' : ''">
{{ t(`user.Benefits.Tiers.${tier.key}.Item${n}`) }}
</span>
</li>
</ul>
<Button v-if="tier.key === 'Sponsor' && !isSponsor" as-child size="sm" variant="action"
class="mt-3 w-full">
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener">
<Heart class="size-3.5" />
{{ t('user.QuotaSponsorCta') }}
</a>
</Button>
</section>
</TabsContent>
<!-- Usage tab: share of the monthly quota used per advanced
feature — percentages only, the backend never sends raw
counts. Refetched on dialog open so the numbers are fresh. -->
<TabsContent value="usage" class="space-y-4 pt-3">
<div v-if="!isSignedIn"
class="flex items-start gap-2 p-3 rounded-md border border-info/30 bg-info/10 text-sm text-info">
<Info class="size-4 mt-0.5 shrink-0" />
<span>{{ t('user.SignInToUse') }}</span>
</div>
<template v-else-if="usageRows.length">
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('user.Usage.Note') }}</p>
<ul class="rounded-lg border bg-card divide-y list-none">
<li v-for="row in usageRows" :key="row.key" class="p-3 space-y-1.5">
<div class="flex items-baseline justify-between gap-2 text-sm">
<span>{{ t(row.labelKey) }}</span>
<span class="font-mono tabular-nums text-muted-foreground">
{{ t('user.Usage.PercentUsed', { percent: row.percent }) }}
</span>
</div>
<Progress :model-value="row.percent" class="h-1.5"
:indicator-class="dotClass(row.tone)" />
</li>
</ul>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ t('user.Usage.SponsorNote') }}
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener"
class="underline underline-offset-2 hover:text-foreground">{{ t('user.QuotaSponsorCta')
}}</a>
</p>
</template>
<div v-else class="text-sm text-muted-foreground flex items-center justify-center gap-2 py-10">
<Spinner /> {{ t('user.Fields.Fetching') }}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { authenticatedFetch } from '@/utils/authenticated-fetch';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { CircleCheck, HeartHandshake } from '@lucide/vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Spinner } from '@/components/ui/spinner';
import { Check, Heart, HeartHandshake, Info, Plus, UserRound, UserRoundCheck } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const { dotClass } = useStatusTone();
// `matched` is empty until the first navigation resolves the route.
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
const isSignedIn = computed(() => store.isSignedIn);
const remoteUserInfo = computed(() => store.remoteUserInfo);
@@ -51,25 +138,71 @@ const achievementToUpdate = computed(() => store.achievementToUpdate);
const isUpdateAchievementsSuccess = ref(false);
const isOpen = ref(false);
const activeTab = ref('benefits');
// Three user tiers shown on the Benefits tab. `count` = number of ItemN keys
// 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 },
{ key: 'Sponsor', icon: HeartHandshake, count: 3, current: isSignedIn.value && isSponsor.value },
]);
const openUserBenefits = () => {
// Signed-in visitors open straight onto their usage numbers.
activeTab.value = isSignedIn.value ? 'usage' : 'benefits';
isOpen.value = true;
store.triggerUserBenefits = false;
if (isSignedIn.value) refreshUserInfo();
trackEvent('Nav', 'NavClick', 'UserBenefits');
};
// Fetch user info
// Feature key → display label. Tool features reuse their page titles; the
// ipinfo advanced-data feature has no tool page, so it gets its own key.
const USAGE_LABEL_KEYS = {
ipinfo: 'user.Usage.IpinfoFeature',
invisibility_test: 'invisibilitytest.Title',
dns_leak_test: 'enhanceddnsleaktest.Title',
persona_check: 'personacheck.Title',
ip_hitlist: 'ipblocklist.Title',
};
// Rows for the usage dialog. Empty until the (re)fetched user info lands or
// when the backend predates the quota field — the template then shows the
// fetching placeholder instead of a broken table.
const usageRows = computed(() => {
const features = remoteUserInfo.value?.quota?.features;
if (!features) return [];
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 };
});
});
// Refetch on every open (bypassing the one-shot page-load guard) so the
// numbers reflect what the visitor just consumed in this session.
const refreshUserInfo = async () => {
if (!isSignedIn.value) return;
try {
store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`);
} catch (error) {
console.error('Error refreshing user info:', error);
}
};
// Fetch user info. `remoteUserInfoFetched` flips only on success, so a failed
// round stays refetchable from Nav / Achievements on their next open.
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
try {
const response = await authenticatedFetch(`/api/getuserinfo`);
const data = response;
store.remoteUserInfo = data;
store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`);
store.remoteUserInfoFetched = true;
initUserAchievements();
} catch (error) {
console.error('Error fetching user info:', error);
}
store.remoteUserInfoFetched = true;
};
// Initialize user achievements
@@ -115,23 +248,27 @@ const updateUserAchievement = async (achievementName) => {
}
};
watch(() => store.allHasLoaded, (newVal) => {
if (newVal) getUserInfo();
});
// The unknown-auth-hint boot resolves sign-in AFTER allHasLoaded (background
// probe in main.js), so the watcher above already ran and skipped; retry when
// the signed-in state lands. getUserInfo self-guards against double fetches.
watch(isSignedIn, (signed) => {
if (signed && store.allHasLoaded) getUserInfo();
});
// User info loads once signed in and, on Home, after its tests (allHasLoaded);
// other pages never run them. getUserInfo self-guards against double fetches.
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
if (ready) getUserInfo();
}, { immediate: true });
watch(() => triggerUserBenefits.value, (newVal) => {
if (newVal) openUserBenefits();
});
// A page change closes the dialog.
watch(() => route.path, () => {
isOpen.value = false;
});
// One-shot trigger: cleared here so the next request from UserMenu / Achievements
// is a fresh false → true edge rather than a no-op write.
watch(() => triggerRemoteUserInfo.value, (newVal) => {
if (newVal) getUserInfo();
if (!newVal) return;
store.triggerRemoteUserInfo = false;
getUserInfo();
});
watch(() => triggerUpdateAchievements.value, (newVal) => {
+184
View File
@@ -0,0 +1,184 @@
<template>
<!-- Account menu at the right of the site Nav: a preferences cog on a
Firebase-less instance, the user dropdown otherwise. -->
<!-- Firebase-less: standalone preferences cog. -->
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@click="openPreferences">
<Cog />
</Button>
</JnTooltip>
<!-- Sign In / User Dropdown -->
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<!-- Not signed in: the solid block reads as the "sign in"
call-to-action, and the menu opens on the sign-in options, so
the affordance is self-explaining one click deep. -->
<Button v-if="!isSignedIn" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1.5 cursor-pointer"
aria-label="User menu">
<UserRound class="size-5" />
<ChevronDown class="opacity-60" />
</Button>
<!-- Signed in: avatar + chevron -->
<Button v-else variant="ghost" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1 cursor-pointer"
aria-label="User menu">
<span class="inline-flex size-7 overflow-hidden rounded-full">
<img :src="userPhotoURL" :alt="userName" :title="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<ChevronDown class="opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 shadow-md">
<!-- Signed in: account card + achievements -->
<template v-if="isSignedIn">
<div class="px-2 pt-2 pb-3">
<div class="flex items-center gap-3">
<span class="inline-flex size-10 overflow-hidden rounded-full shrink-0">
<img :src="userPhotoURL" :alt="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-sm font-semibold leading-none">{{ userName }}</span>
<span v-if="remoteUserInfoFetched && remoteUserInfo.userLevel">
<Badge :class="levelBadgeClass"
class="border-transparent text-[10px] font-medium px-1.5 py-0 h-4">
{{ t('user.Level.' + remoteUserInfo.userLevel) }}
</Badge>
</span>
<span v-else-if="!remoteUserInfoFetched" class="text-xs text-muted-foreground">{{
t('user.Fields.Fetching') }}</span>
</div>
</div>
<dl class="mt-3 space-y-1 text-xs">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.CreatedAt') }}</dt>
<dd class="font-medium">{{ userCreatedAt }}</dd>
</div>
<!-- How this account signs in. One account per email
address, so this is also the only way in. -->
<div v-if="linkedProviders.length" class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.SignInMethods') }}</dt>
<dd class="flex min-w-0 items-center gap-1.5 font-medium">
<span v-for="provider in linkedProviders" :key="provider.providerId"
class="inline-flex items-center gap-1" :title="provider.label">
<Icon v-if="provider.icon" :icon="provider.icon" class="size-3.5 shrink-0" />
<span>{{ provider.label }}</span>
</span>
</dd>
</div>
</dl>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="openAchievements">
<Award />
<span>{{ t('user.MyAchievements') }}</span>
</DropdownMenuItem>
</template>
<!-- Not signed in: sign-in providers -->
<template v-else>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGoogle">
<Icon icon="ri:google-line" />
<span>{{ t('user.SignInWithGoogle') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGithub">
<Icon icon="ri:github-line" />
<span>{{ t('user.SignInWithGithub') }}</span>
</DropdownMenuItem>
</template>
<!-- Every state: preferences + benefits & usage -->
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="openPreferences">
<Cog />
<span>{{ t('nav.preferences.title') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerUserBenefits(true)">
<HeartHandshake />
<span>{{ t('user.Benefits.Title') }}</span>
</DropdownMenuItem>
<template v-if="isSignedIn">
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.signOut">
<LogOut />
<span>{{ t('user.SignOut') }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
// What the menu opens is raised through store triggers (Preferences / User
// live in App.vue, Achievements on Home).
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { unixToDateTime } from '@/utils/time-utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { JnTooltip } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Award, ChevronDown, Cog, HeartHandshake, LogOut, UserRound } from '@lucide/vue';
import { Icon } from '@iconify/vue';
const { t, locale } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
// Firebase / User
const isFireBaseSet = computed(() => store.isFireBaseSet);
const isSignedIn = computed(() => store.isSignedIn);
const userName = computed(() => store.user?.displayName);
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(() => {
const level = remoteUserInfo.value?.userLevel;
switch (level) {
case 'Premium': return 'bg-action text-action-foreground';
case 'Owner': return 'bg-foreground text-background';
case 'Developer': return 'bg-success text-success-foreground';
case 'HonoraryMember': return 'bg-warning text-warning-foreground';
case 'Standard':
default: return 'bg-muted-foreground text-background';
}
});
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
store.setTriggerRemoteUserInfo(true);
};
const openPreferences = () => {
store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences');
};
// Achievements live on Home only, so go there first.
const openAchievements = async () => {
if (route.name !== 'home') {
const failure = await router.push('/');
if (failure) return;
}
store.setTriggerAchievements(true);
};
</script>
+24 -12
View File
@@ -112,6 +112,9 @@
</CardContent>
</Card>
</div>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="webrtc" :settled="hasEverSettled" />
</section>
</template>
@@ -120,7 +123,8 @@ import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue'
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -132,6 +136,7 @@ import { Play, MapPin, EthernetPort, Flower, Network, RotateCw, FileText, Chevro
import { Icon } from '@iconify/vue';
import FitText from '@/components/widgets/FitText.vue';
import CopyButton from '@/components/widgets/CopyButton.vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
const { t } = useI18n();
@@ -144,6 +149,9 @@ const { dotClass, textClass } = useStatusTone();
const { lookupMaxmind } = useMaxmind();
const isStarted = ref(false);
// Sticky settled flag for the section's banner slot: true once a full STUN
// pass finishes (including the WebRTC-unavailable path).
const hasEverSettled = ref(false);
const IPArray = ref([]);
const stunServers = reactive([
{ id: 'google', url: 'stun.l.google.com:19302', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '', org: t('webrtc.StatusWait'), sdpLog: [], sdpOpen: false },
@@ -171,7 +179,7 @@ const isWebRtcAvailable = typeof RTCPeerConnection === 'function';
const toneOf = (stun) => stun.ip === t('webrtc.StatusUnavailable')
? 'ok-fast'
: ipFieldTone(stun.ip, {
waitLabels: t('webrtc.StatusWait'),
waitLabels: [t('webrtc.StatusWait'), t('webrtc.StatusTesting')],
errorLabels: t('webrtc.StatusError'),
});
@@ -179,7 +187,7 @@ const toneOf = (stun) => stun.ip === t('webrtc.StatusUnavailable')
// Fields may fail independently (e.g. IP success but country lookup fails),
// so the check is run per-field in the template.
const isFieldPending = (value) => isFieldPendingShared(value, {
waitLabels: t('webrtc.StatusWait'),
waitLabels: [t('webrtc.StatusWait'), t('webrtc.StatusTesting')],
errorLabels: [t('webrtc.StatusError'), t('webrtc.StatusUnavailable')],
});
@@ -382,6 +390,7 @@ const natTypeCodeOf = (candidate) => {
// Domain event: snapshot of all STUN cards for the report collector (servers
// still waiting carry no natTypeCode and are dropped by the builder).
const emitWebrtcFinished = () => {
hasEverSettled.value = true;
emitAppEvent('webrtc:finished', {
servers: stunServers.map((server) => ({
id: server.id,
@@ -418,12 +427,12 @@ const checkAllWebRTC = async (isRefresh) => {
}
const promises = stunServers.map((server) => {
server.ip = t('webrtc.StatusWait');
server.natType = t('webrtc.StatusWait');
server.ip = t('webrtc.StatusTesting');
server.natType = t('webrtc.StatusTesting');
server.natTypeCode = undefined;
server.country = t('webrtc.StatusWait');
server.country = t('webrtc.StatusTesting');
server.country_code = '';
server.org = t('webrtc.StatusWait');
server.org = t('webrtc.StatusTesting');
return checkSTUNServer(server);
});
@@ -435,6 +444,14 @@ const checkAllWebRTC = async (isRefresh) => {
});
};
// Command owner: run all STUN checks. Resolves with the next webrtc:finished
// snapshot (also emitted when WebRTC is unavailable in this browser).
useAppCommand('webrtc:run', ({ isRefresh = false } = {}) => {
const finished = waitForAppEvent('webrtc:finished');
checkAllWebRTC(isRefresh);
return finished;
});
onMounted(() => {
store.setMountingStatus('WebRTC', true);
});
@@ -450,9 +467,4 @@ onBeforeUnmount(() => {
watch(IPArray, () => {
store.updateAllIPs(IPArray.value);
}, { deep: true });
defineExpose({
checkAllWebRTC,
stunServers,
});
</script>
@@ -0,0 +1,380 @@
<template>
<div class="adblock-test-section my-4 space-y-4">
<!-- Top note: what it tests, how to read it, what leaves the browser -->
<div class="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<p>{{ t('adblocktest.Note') }}</p>
<p>{{ t('adblocktest.Note2') }}</p>
<p>{{ t('adblocktest.Note3') }}</p>
</div>
<!-- Probe picker (left) + results (right), GlobalLatencyTest's layout -->
<Card>
<CardContent class="p-0">
<div class="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x">
<!-- Left: tally + bulk actions on top (sticky, as in the
Globalping picker), then one section per group whose
header doubles as select-all. Once results exist the
column matches their height. -->
<div class="col-span-1" :class="hasResults ? 'md:relative md:min-h-96' : ''">
<div class="max-h-72 overflow-y-auto"
:class="hasResults ? 'md:max-h-none md:absolute md:inset-0' : 'md:max-h-128'">
<div class="px-4 py-3 space-y-4">
<div
class="sticky top-0 z-10 -mt-3 pt-3 bg-card pb-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
{{ t('adblocktest.Selected') }}
<span class="tabular-nums">{{ selectedTargetCount }}</span>
</span>
<span class="flex items-center gap-3">
<button type="button" class="cursor-pointer hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="isRunning" @click="selectAll">
{{ t('adblocktest.SelectAll') }}
</button>
<button type="button" class="cursor-pointer hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="isRunning" @click="invert">
{{ t('adblocktest.Invert') }}
</button>
</span>
</div>
<div v-for="group in ADBLOCK_PROBE_GROUPS" :key="group.id" class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium cursor-pointer">
<Checkbox :model-value="groupState(group)" :disabled="isRunning"
@update:model-value="(v) => toggleGroup(group, v)">
<Minus v-if="groupState(group) === 'indeterminate'" class="size-3" />
<Check v-else class="size-3.5" />
</Checkbox>
{{ t(`adblocktest.group.${group.id}.title`) }}
</label>
<div class="flex flex-col gap-1.5 pl-6">
<label v-for="vendor in group.vendors" :key="vendor.id"
class="flex items-center gap-2 text-sm min-w-0"
:class="isRunning ? 'opacity-50' : 'cursor-pointer'">
<Checkbox :model-value="isSelected(group, vendor)" :disabled="isRunning"
@update:model-value="(v) => setVendor(group, vendor, v)" />
<span class="truncate">{{ vendorName(group, vendor) }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Right: toolbar (hint + run / run again), then results -->
<div class="col-span-3 min-w-0 flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-b">
<span class="text-sm text-muted-foreground">{{ t('adblocktest.Hint') }}</span>
<Button variant="action" :disabled="!canRun" class="cursor-pointer ml-auto" @click="startTest">
<Spinner v-if="isRunning" />
<RotateCw v-else-if="hasFinished" class="size-4 shrink-0" />
<Play v-else class="size-4 shrink-0" />
{{ t(hasFinished ? 'adblocktest.Rerun' : 'adblocktest.Run') }}
</Button>
</div>
<div v-if="hasResults" class="p-4 space-y-5">
<!-- Summary: score, counts, where the blocking happens -->
<div class="space-y-3">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="rounded-md bg-muted/50 px-3 py-2">
<div class="text-xs text-muted-foreground">{{ t('adblocktest.Score') }}</div>
<div class="text-2xl font-semibold tabular-nums">
{{ requestTally.score ?? '—' }}<span class="text-base">%</span>
</div>
</div>
<div v-for="key in STATUS_KEYS" :key="key" class="rounded-md bg-muted/50 px-3 py-2">
<div class="flex items-center gap-1.5 text-xs" :class="textClass(statusTone(key))">
<component :is="STATUS_ICON[key]" class="size-3.5 shrink-0" />
{{ t(`adblocktest.count.${key}`) }}
</div>
<div class="text-2xl font-semibold tabular-nums"
:class="requestTally[key] > 0 ? textClass(statusTone(key)) : 'text-muted-foreground'">
{{ requestTally[key] }}
</div>
</div>
</div>
<p v-if="isRunning" class="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner class="size-4" />
{{ t('adblocktest.Progress', { done: answeredCount, total: runPlan.length }) }}
</p>
<Alert v-else-if="layer">
<Info />
<AlertDescription>{{ t(`adblocktest.layer.${layer}`) }}</AlertDescription>
</Alert>
</div>
<!-- Filter tags -->
<ToggleGroup :model-value="filter" type="single" variant="outline" :spacing="2"
class="w-full flex-wrap justify-start"
@update:model-value="(v) => v && (filter = v)">
<ToggleGroupItem v-for="f in FILTERS" :key="f" :value="f" :class="tagClass">
{{ t(`adblocktest.filter.${f}`) }}
</ToggleGroupItem>
</ToggleGroup>
<!-- One block per tested group -->
<section v-for="group in visibleGroups" :key="group.id" class="space-y-2">
<header class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
<h3 class="text-sm font-semibold m-0">{{ t(`adblocktest.group.${group.id}.title`) }}</h3>
<span class="text-xs text-muted-foreground tabular-nums">
{{ t('adblocktest.GroupCount', { blocked: group.counts.blocked, total: group.counts.total }) }}
</span>
<p class="basis-full text-xs text-muted-foreground m-0">
{{ t(`adblocktest.group.${group.id}.desc`) }}
</p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2">
<div v-for="vendor in group.vendors" :key="vendor.key"
class="rounded-lg border bg-card min-w-0">
<!-- Tile header: name + a count per status (no blended
color); toggles the per-address list -->
<button type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm cursor-pointer"
:aria-expanded="isExpanded(vendor)"
@click="toggleExpanded(vendor)">
<span class="truncate flex-1 font-medium">{{ vendor.name }}</span>
<Spinner v-if="isRunning && vendor.counts.pending > 0"
class="size-3.5 text-muted-foreground" />
<template v-for="key in STATUS_KEYS" :key="key">
<span v-if="vendor.counts[key] > 0"
class="flex items-center gap-0.5 text-xs tabular-nums shrink-0"
:class="textClass(statusTone(key))"
:title="t(`adblocktest.status.${key}`)">
<component :is="STATUS_ICON[key]" class="size-3.5" />
{{ vendor.counts[key] }}
</span>
</template>
<ChevronDown class="size-3.5 shrink-0 text-muted-foreground transition-transform"
:class="isExpanded(vendor) ? 'rotate-180' : ''" />
</button>
<ul v-if="isExpanded(vendor)" class="border-t px-3 py-2 space-y-1">
<li v-for="item in vendor.items" :key="item.id"
class="flex items-center gap-2 text-xs min-w-0"
:title="item.url ? `${item.url} · ${t(`adblocktest.status.${statusOf(item)}`)}` : ''">
<Spinner v-if="statusOf(item) === 'pending'" class="size-3.5 text-muted-foreground" />
<component v-else :is="STATUS_ICON[statusOf(item)]" class="size-3.5 shrink-0"
:class="textClass(statusTone(statusOf(item)))" />
<span v-if="item.kind === 'cosmetic'">
{{ t(`adblocktest.cosmeticStatus.${statusOf(item)}`) }}
</span>
<span v-else class="font-mono truncate">{{ item.target }}</span>
</li>
</ul>
</div>
</div>
</section>
<p v-if="!visibleGroups.length" class="text-sm text-muted-foreground text-center py-4">
{{ t(filter === 'blocked' ? 'adblocktest.NothingBlocked' : 'adblocktest.NothingOpen') }}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Probe list version + what the probes cannot tell -->
<div v-if="hasResults" class="text-xs text-muted-foreground m-0 space-y-1">
<p class="text-xs text-muted-foreground m-0">
{{ t('adblocktest.Version', { version: ADBLOCK_PROBES_VERSION }) }}
</p>
<p class="text-xs text-muted-foreground mt-1">{{ t('adblocktest.Limitation') }}</p>
</div>
<!-- Cosmetic baits live here during a run, off-screen but laid out -->
<div ref="baitHost" aria-hidden="true" class="pointer-events-none fixed left-[-10000px] top-0 w-24"></div>
</div>
</template>
<script setup>
// Ad & Tracker Blocking Test (/tools/adblocktest): checks what the visitor's
// blocker — extension, browser, DNS service, VPN or router — lets through.
// Nothing runs on open; every probe group starts selected. Pure logic lives
// in utils/features/adblock-test.js, the probe list in data/adblock-probes.js.
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { ADBLOCK_PROBE_GROUPS, ADBLOCK_PROBES_VERSION } from '@/data/adblock-probes.js';
import {
COSMETIC_BAITS, COSMETIC_POLL_MS, COSMETIC_WAIT_MS,
allVendorKeys, blockingLayer, buildPlan, invertSelection, isBaitHidden,
probeLimits, probeRequest, runWithConcurrency, statusTone, tally, vendorKey,
} from '@/utils/features/adblock-test.js';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Spinner } from '@/components/ui/spinner';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Ban, Check, ChevronDown, CircleArrowRight, CircleQuestionMark, Info, Minus, Play, RotateCw,
} from '@lucide/vue';
const { t } = useI18n();
const { textClass } = useStatusTone();
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
const FILTERS = ['all', 'open', 'blocked'];
// Answered statuses, in display order (tiles, tile counts).
const STATUS_KEYS = ['blocked', 'open', 'unknown'];
const STATUS_ICON = { blocked: Ban, open: CircleArrowRight, unknown: CircleQuestionMark };
/* ---------- Selection (left column) ---------- */
const ALL_KEYS = allVendorKeys(ADBLOCK_PROBE_GROUPS);
const selected = ref(new Set(ALL_KEYS));
const isSelected = (group, vendor) => selected.value.has(vendorKey(group.id, vendor.id));
const setVendor = (group, vendor, checked) => {
const next = new Set(selected.value);
if (checked) next.add(vendorKey(group.id, vendor.id));
else next.delete(vendorKey(group.id, vendor.id));
selected.value = next;
};
const groupState = (group) => {
const on = group.vendors.filter((v) => isSelected(group, v)).length;
if (on === group.vendors.length) return true;
return on > 0 ? 'indeterminate' : false;
};
const toggleGroup = (group, checked) => {
const next = new Set(selected.value);
for (const v of group.vendors) {
if (checked === true) next.add(vendorKey(group.id, v.id));
else next.delete(vendorKey(group.id, v.id));
}
selected.value = next;
};
const selectAll = () => { selected.value = new Set(ALL_KEYS); };
const invert = () => { selected.value = new Set(invertSelection(ALL_KEYS, selected.value)); };
const vendorName = (group, vendor) => (group.kind === 'cosmetic'
? t(`adblocktest.cosmetic.${vendor.id}`)
: vendor.name);
const selectedTargetCount = computed(() => buildPlan(ADBLOCK_PROBE_GROUPS, selected.value).length);
/* ---------- Run state ---------- */
// 'idle' | 'running' | 'finished'
const status = ref('idle');
const isRunning = computed(() => status.value === 'running');
const canRun = computed(() => !isRunning.value && selectedTargetCount.value > 0);
const runPlan = ref([]);
// Set by the first completed run; the run button then reads "run again".
const hasFinished = ref(false);
const statuses = ref({}); // plan item id → probe status
const hasResults = computed(() => runPlan.value.length > 0);
const statusOf = (item) => statuses.value[item.id] ?? 'pending';
const requestItems = computed(() => runPlan.value.filter((p) => p.kind !== 'cosmetic'));
const cosmeticItems = computed(() => runPlan.value.filter((p) => p.kind === 'cosmetic'));
const requestTally = computed(() => tally(requestItems.value.map(statusOf)));
const cosmeticTally = computed(() => tally(cosmeticItems.value.map(statusOf)));
const answeredCount = computed(() => runPlan.value.length
- requestTally.value.pending - cosmeticTally.value.pending);
const layer = computed(() => (status.value === 'finished'
? blockingLayer({ requests: requestTally.value, cosmetic: cosmeticTally.value })
: null));
/* ---------- Results (right column) ---------- */
const filter = ref('all');
const expanded = ref(new Set());
const isExpanded = (vendor) => filter.value !== 'all' || expanded.value.has(vendor.key);
const toggleExpanded = (vendor) => {
const next = new Set(expanded.value);
if (next.has(vendor.key)) next.delete(vendor.key);
else next.add(vendor.key);
expanded.value = next;
};
// Groups → vendors → items of the last run, with counts; the "open" /
// "blocked" filters keep only items of that status (and drop what is left empty).
const visibleGroups = computed(() => ADBLOCK_PROBE_GROUPS.map((group) => {
const vendors = group.vendors.map((vendor) => {
const all = runPlan.value.filter((p) => p.groupId === group.id && p.vendorId === vendor.id);
const items = filter.value === 'all' ? all : all.filter((p) => statusOf(p) === filter.value);
return {
key: vendorKey(group.id, vendor.id),
name: vendorName(group, vendor),
items,
counts: tally(all.map(statusOf)),
};
}).filter((v) => v.items.length > 0);
const all = runPlan.value.filter((p) => p.groupId === group.id);
return { id: group.id, vendors, counts: tally(all.map(statusOf)) };
}).filter((g) => g.vendors.length > 0));
/* ---------- Probes ---------- */
const baitHost = ref(null);
// Place every selected bait, then watch until each is hidden or the wait
// runs out. A host no longer in the document (the page was left mid-run)
// proves nothing, so its baits read unknown.
const runCosmetic = async (items) => {
const host = baitHost.value;
if (!items.length || !host) return;
const baits = items.map((item) => {
const spec = COSMETIC_BAITS[item.target];
const el = document.createElement(spec.tag);
el.className = spec.className;
for (const [k, v] of Object.entries(spec.attrs)) el.setAttribute(k, v);
el.style.cssText = 'display:block;width:80px;height:20px;';
el.innerHTML = '&nbsp;';
host.appendChild(el);
return { item, el };
});
const hidden = (el) => {
const style = getComputedStyle(el);
return isBaitHidden({
connected: el.isConnected,
offsetHeight: el.offsetHeight,
display: style.display,
visibility: style.visibility,
});
};
const started = Date.now();
let pending = baits;
while (pending.length && Date.now() - started < COSMETIC_WAIT_MS) {
await new Promise((resolve) => setTimeout(resolve, COSMETIC_POLL_MS));
if (!host.isConnected) break;
pending = pending.filter(({ item, el }) => {
if (!hidden(el)) return true;
statuses.value[item.id] = 'blocked';
return false;
});
}
for (const { item } of pending) statuses.value[item.id] = host.isConnected ? 'open' : 'unknown';
host.replaceChildren();
};
const startTest = async () => {
if (!canRun.value) return;
trackEvent('Section', 'StartClick', 'AdblockTest');
status.value = 'running';
filter.value = 'all';
expanded.value = new Set();
const plan = buildPlan(ADBLOCK_PROBE_GROUPS, selected.value);
runPlan.value = plan;
statuses.value = {};
const { timeoutMs, concurrency } = probeLimits(navigator.userAgent, navigator.platform);
const tasks = plan.filter((p) => p.kind !== 'cosmetic').map((item) => async () => {
statuses.value[item.id] = await probeRequest(item.url, { timeoutMs });
});
await Promise.all([
runCosmetic(plan.filter((p) => p.kind === 'cosmetic')),
runWithConcurrency(tasks, concurrency),
]);
status.value = 'finished';
hasFinished.value = true;
// Domain event: per-address outcome for the report collector.
emitAppEvent('adblocktest:finished', {
items: plan.map((p) => ({ groupId: p.groupId, kind: p.kind, status: statusOf(p) })),
});
};
</script>
@@ -0,0 +1,685 @@
<!-- ASN Profile (advanced tool, slug `asn`): everything known about one AS
number, from one request to /api/asn-profile (the backend gathers every
source and answers once; see server/asn-profile.js). The page shows one
loading state, then renders whole; a section whose source had nothing or
failed is simply left out.
Order: hero (identity, key facts, registration) beside a compact
reputation card when there is one → traffic & quality beside top
countries → prefixes → connectivity (+ customer cone, topology) →
peering & facilities (PeeringDB, self-reported).
One source per concept: relationships = local CAIDA (graph, counts and
lists alike); prefixes, addresses and RPKI = the pfx2as list; identity =
Radar → RDAP → ASRank. The query rides the URL as `?q=` (shareable,
written back on every run). Shaping lives in utils/ip/asn-profile-view.js. -->
<template>
<div class="my-4 space-y-4">
<!-- Top note -->
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('asnprofile.Note') }}</p>
<!-- Input row -->
<div class="space-y-2">
<Label for="asnProfileQuery">{{ t('asnprofile.InputLabel') }}</Label>
<div class="flex items-center gap-2">
<Input type="text" id="asnProfileQuery" name="asnProfileQuery" class="font-mono"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore
data-lpignore="true" :placeholder="t('asnprofile.Placeholder')" v-model="query"
:disabled="loading" @keyup.enter="onSubmit" :aria-invalid="errorMsg !== ''" />
<Button variant="action" class="cursor-pointer" :disabled="loading || !query.trim()"
:aria-label="t('asnprofile.Lookup')" @click="onSubmit">
<Spinner v-if="loading" />
<Search v-else class="size-4 shrink-0" />
</Button>
</div>
<p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
</div>
<!-- ============ Loading: one placeholder for the whole page ============ -->
<div v-if="state === 'loading'" class="space-y-4" aria-busy="true">
<div class="rounded-lg border bg-card p-4 md:p-5 space-y-4">
<div class="font-mono text-3xl md:text-4xl font-semibold tracking-tight">AS{{ profileAsn }}</div>
<div class="h-4 w-56 max-w-full rounded bg-muted animate-pulse" />
<div class="grid grid-cols-2 gap-2.5 md:grid-cols-4">
<div v-for="i in 4" :key="i" class="h-16 rounded-md bg-muted/60 animate-pulse" />
</div>
<div class="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
<div v-for="i in 6" :key="i" class="h-4 rounded bg-muted animate-pulse" />
</div>
</div>
<div class="rounded-lg border bg-card p-4 space-y-3">
<div class="h-5 w-40 rounded bg-muted animate-pulse" />
<div v-for="i in 4" :key="i" class="h-3.5 rounded bg-muted animate-pulse" />
</div>
</div>
<!-- The request itself failed (network, or every source down) -->
<p v-else-if="state === 'error'" class="rounded-lg border bg-card p-4 text-sm text-destructive">
{{ t('asnprofile.loadError') }}</p>
<div v-else-if="state === 'done'" class="space-y-4">
<!-- Hero row: the hero alone, or hero 2 : reputation 1 from lg up -->
<div class="grid gap-4" :class="reputation && 'lg:grid-cols-3'">
<!-- ============ Hero: identity → key facts → registration ============ -->
<div class="rounded-lg border bg-card p-4 md:p-5 space-y-4 min-w-0" :class="reputation && 'lg:col-span-2'">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<div class="font-mono text-3xl md:text-4xl font-semibold tracking-tight">AS{{ profileAsn }}</div>
<div v-if="identity.name || identity.org" class="text-sm text-muted-foreground wrap-break-word">
<span v-if="identity.name" class="font-medium text-foreground">{{ identity.name }}</span>
<template v-if="identity.name && identity.org"> · </template>{{ identity.org }}
</div>
</div>
<span v-if="identity.country" class="inline-flex shrink-0 items-center gap-1.5 text-sm">
<Icon :icon="'circle-flags:' + identity.country.toLowerCase()" class="size-4 shrink-0" />
{{ getCountryName(identity.country, locale) }}
</span>
</div>
<!-- Tier 1 + PeeringDB network types, scope and website; each only when present -->
<div v-if="isTier1 || peerHero.types.length || peerHero.scope || peerHero.website"
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm">
<div v-if="isTier1 || peerHero.types.length" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="isTier1" variant="secondary">{{ t('asnprofile.tier1') }}</Badge>
<Badge v-for="type in peerHero.types" :key="type" variant="outline" class="font-normal">
{{ enumLabel('types', type) }}</Badge>
</div>
<span v-if="peerHero.scope" class="inline-flex items-center gap-1.5 text-muted-foreground"
:title="t('asnprofile.peering.scope')">
<Globe class="size-3.5 shrink-0" />{{ enumLabel('scopes', peerHero.scope) }}</span>
<a v-if="peerHero.website" :href="peerHero.website.href" target="_blank" rel="noopener"
class="inline-flex min-w-0 items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
:title="peerHero.website.href" :aria-label="t('asnprofile.peering.website')">
<ExternalLink class="size-3.5 shrink-0" /><span class="truncate">{{ peerHero.website.label }}</span></a>
</div>
<!-- Key facts -->
<div v-if="facts.length" class="grid grid-cols-2 gap-2.5 md:grid-cols-4">
<div v-for="fact in facts" :key="fact.key" class="rounded-md bg-muted/40 p-3 min-w-0">
<div class="text-lg font-semibold tabular-nums truncate">{{ fact.value }}</div>
<div class="text-xs text-muted-foreground">{{ fact.label }}</div>
<div v-if="fact.sub" class="text-[11px] text-muted-foreground/80">{{ fact.sub }}</div>
</div>
</div>
<!-- Registration (RDAP autnum): label / value pairs, two columns
from sm up; name, org and country already sit above. -->
<template v-if="registration.length || rawRecord">
<Separator />
<div class="space-y-3">
<h3 class="text-xs font-medium text-muted-foreground">{{ t('asnprofile.registration.title') }}</h3>
<dl v-if="registration.length" class="grid gap-x-8 gap-y-2 sm:grid-cols-2">
<div v-for="field in registration" :key="field.key" class="flex min-w-0 items-baseline gap-3"
:class="field.wide && 'sm:col-span-2'">
<dt class="w-28 shrink-0 text-xs text-muted-foreground">{{ t(`asnprofile.registration.${field.key}`) }}</dt>
<dd class="min-w-0 text-sm wrap-break-word" :class="field.mono && 'font-mono text-xs'">{{ field.value }}</dd>
</div>
</dl>
<!-- Raw record, collapsed. Trigger, copy button and <pre>
share the px-3 edges; hover only recolours text. -->
<Collapsible v-if="rawRecord" v-model:open="rawOpen" class="rounded-md border">
<div class="flex items-center gap-2 px-3">
<CollapsibleTrigger>
<button type="button"
class="group flex flex-1 items-center gap-2 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground data-[state=open]:text-foreground cursor-pointer">
<ChevronRight class="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
{{ t('asnprofile.registration.raw') }}
</button>
</CollapsibleTrigger>
<CopyButton :value="() => rawRecord" :aria-label="t('asnprofile.registration.copyRaw')"
class="-mr-1.5 text-muted-foreground hover:bg-transparent hover:text-foreground"
icon-class="size-3.5" />
</div>
<CollapsibleContent>
<pre class="mx-3 mb-3 max-h-72 overflow-auto rounded-md bg-muted p-3 font-mono text-xs leading-relaxed">{{ rawRecord }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
</div>
<!-- ============ Reputation (backend sends it on the official site only) ============ -->
<div v-if="reputation" class="rounded-lg border bg-card p-4 space-y-4 min-w-0">
<div class="space-y-1">
<div class="flex items-start justify-between gap-2">
<h2 class="text-base font-semibold">{{ t('asnprofile.reputation.title') }}</h2>
<Badge variant="outline" class="shrink-0 text-[10px] font-medium text-muted-foreground">
{{ t('asnprofile.reputation.source') }}</Badge>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.reputation.desc') }}</p>
</div>
<!-- Verdict from the scoring level -->
<Badge v-if="verdict" :variant="verdict.variant" class="gap-1" :class="verdict.extra">
<component :is="verdict.icon" class="size-3.5" />{{ verdict.label }}
</Badge>
<!-- Abuse / VPN as a multiple of the baseline: log bar, tick = 1× -->
<div v-for="meter in meters" :key="meter.key">
<div class="flex flex-wrap items-baseline justify-between gap-x-2 text-sm">
<span>{{ t(`asnprofile.reputation.${meter.key}`) }}</span>
<span class="text-xs font-medium tabular-nums" :class="textClass(meter.tone)">{{ ratioNote(meter.ratio) }}</span>
</div>
<div class="relative mt-1.5 h-2 rounded-full bg-muted">
<div class="absolute inset-y-0 left-0 rounded-full" :class="dotClass(meter.tone)"
:style="{ width: meter.pos + '%' }" />
<div class="absolute -inset-y-1 w-0.5 -translate-x-1/2 rounded bg-foreground/70"
:style="{ left: BASELINE_POSITION + '%' }" />
</div>
</div>
<!-- Proxies detected: the listed count, when there is one -->
<div v-if="proxyListed !== null" class="flex flex-wrap items-baseline justify-between gap-x-2 text-sm">
<span>{{ t('asnprofile.reputation.proxy') }}</span>
<span class="font-medium tabular-nums">{{ nf.format(proxyListed) }}</span>
</div>
<!-- Evidence tag, only when listed -->
<div v-if="reputation.dropListed"
class="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
<ShieldX class="size-3.5 shrink-0" />{{ t('asnprofile.reputation.drop') }}
</div>
<p v-if="reputationDate" class="text-[11px] text-muted-foreground">
{{ t('asnprofile.reputation.updated', { date: reputationDate }) }}</p>
</div>
</div>
<!-- Unknown AS: one notice instead of a column of empty cards -->
<p v-if="empty" class="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
{{ t('asnprofile.noData', { asn: profileAsn }) }}</p>
<template v-else>
<!-- Traffic | distribution: one row from lg up; a lone card spans it -->
<div v-if="trafficVisible || topCountries.length" class="grid gap-4 lg:grid-cols-2">
<!-- ============ Traffic profile & connection quality ============ -->
<div v-if="trafficVisible" class="rounded-lg border bg-card p-4 space-y-3 min-w-0"
:class="!topCountries.length && 'lg:col-span-2'">
<div class="space-y-1">
<h2 class="text-base font-semibold">{{ t('asnprofile.traffic.title') }}</h2>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.traffic.desc') }}</p>
</div>
<!-- Stacked: reads at half width; quality keeps its own two columns -->
<div class="space-y-4">
<AsnTrafficShares :info="radar" />
<AsnConnectionQuality :info="radar" />
</div>
</div>
<!-- ============ Global IP distribution (top countries) ============ -->
<div v-if="topCountries.length" class="rounded-lg border bg-card p-4 space-y-3 min-w-0"
:class="!trafficVisible && 'lg:col-span-2'">
<div class="space-y-1">
<h2 class="text-base font-semibold">{{ t('asnprofile.map.title') }}</h2>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.map.desc') }}</p>
</div>
<ul class="space-y-1.5">
<li v-for="row in topCountries" :key="row.cc" class="flex items-center gap-2 text-sm">
<Icon :icon="'circle-flags:' + row.cc.toLowerCase()" class="size-4 shrink-0" />
<span class="w-32 truncate">{{ getCountryName(row.cc, locale) }}</span>
<div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-info" :style="{ width: row.bar + '%' }" />
</div>
<span class="w-12 text-right font-mono text-xs tabular-nums">{{ nf.format(row.pct) }}%</span>
</li>
</ul>
</div>
</div>
<!-- ============ Announced prefixes (+ RPKI routing summary) ============ -->
<div v-if="rows.length" class="rounded-lg border bg-card p-4 space-y-4 min-w-0">
<div class="space-y-1">
<h2 class="text-base font-semibold">{{ t('asnprofile.prefixes.title') }}</h2>
<div class="text-xs text-muted-foreground tabular-nums">
{{ t('asnprofile.prefixes.counts', { v4: nf.format(counts.v4), v6: nf.format(counts.v6) }) }}</div>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.prefixes.desc') }}</p>
</div>
<!-- RPKI route-origin validation -->
<div v-if="rpki" class="space-y-1.5">
<div class="flex items-baseline justify-between gap-2 text-sm">
<span>{{ t('asnprofile.prefixes.rpki') }}</span>
<span class="font-mono tabular-nums">{{ t('asnprofile.prefixes.rpkiValid', { pct: nf.format(rpki.validPct) }) }}</span>
</div>
<div class="flex h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div v-for="rpkiState in RPKI_STATES" :key="rpkiState" :class="RPKI_DOT[rpkiState]"
:style="{ width: rpki.pct[rpkiState] + '%' }" />
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span v-for="rpkiState in RPKI_STATES" :key="rpkiState" class="inline-flex items-center gap-1.5">
<span class="size-2 rounded-full" :class="RPKI_DOT[rpkiState]" />
<span class="text-muted-foreground">{{ t(`asnprofile.rpki.${rpkiState}`) }}</span>
<span class="font-medium tabular-nums">{{ nf.format(rpki.counts[rpkiState]) }}</span>
</span>
</div>
</div>
<!-- Family filter + export of the filtered set -->
<div class="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup :model-value="familyFilter" type="single" variant="outline" :spacing="2"
class="flex-wrap justify-start" :aria-label="t('asnprofile.prefixes.family')"
@update:model-value="(v) => v && (familyFilter = v)">
<ToggleGroupItem v-for="opt in familyOptions" :key="opt.value" :value="opt.value"
:class="tagClass">{{ opt.label }}</ToggleGroupItem>
</ToggleGroup>
<div class="flex items-center gap-1">
<CopyButton :value="filteredText" :tooltip="t('asnprofile.prefixes.copy')"
tooltip-side="top" icon-class="size-3.5" />
<Button variant="ghost" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs"
:disabled="!filtered.length" @click="downloadFiltered">
<Download class="size-3.5" />{{ t('asnprofile.prefixes.download') }}</Button>
</div>
</div>
<!-- Prefix table; horizontal overflow stays in here -->
<div class="overflow-x-auto rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-muted/40">
<tr class="text-left text-xs text-muted-foreground">
<th scope="col" class="px-3 py-2 font-medium">{{ t('asnprofile.prefixes.prefix') }}</th>
<th scope="col" class="px-3 py-2 font-medium text-right">{{ t('asnprofile.prefixes.size') }}</th>
<th scope="col" class="px-3 py-2 font-medium">{{ t('asnprofile.prefixes.rpkiColumn') }}</th>
<th scope="col" class="px-3 py-2 font-medium text-right">{{ t('asnprofile.prefixes.visibility') }}</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="row in shownRows" :key="row.prefix">
<td class="px-3 py-1.5 font-mono whitespace-nowrap">{{ row.prefix }}</td>
<td class="px-3 py-1.5 text-right font-mono text-xs tabular-nums whitespace-nowrap">
{{ sizeLabel(row) }}</td>
<td class="px-3 py-1.5 whitespace-nowrap">
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="size-1.5 rounded-full shrink-0" :class="RPKI_DOT[row.rpki]" />
{{ t(`asnprofile.rpki.${row.rpki}`) }}
</span>
</td>
<td class="px-3 py-1.5 text-right font-mono text-xs tabular-nums">{{ nf.format(row.peers) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Paging over the one response: 10 rows first, then +50 a click -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted-foreground tabular-nums">
{{ t('asnprofile.prefixes.shown', { shown: nf.format(shownRows.length), total: nf.format(filtered.length) }) }}</span>
<Button v-if="filtered.length > limit" variant="outline" size="sm"
class="h-7 cursor-pointer text-xs" @click="limit += PAGE">
{{ t('asnprofile.prefixes.showMore', { count: nf.format(Math.min(PAGE, filtered.length - limit)) }) }}</Button>
</div>
</div>
<!-- ============ Connectivity (+ customer cone, topology) ============ -->
<div v-if="graph || cone !== null" class="rounded-lg border bg-card p-4 space-y-4 min-w-0">
<div class="space-y-1">
<h2 class="text-base font-semibold">{{ t('asnprofile.connectivity.title') }}</h2>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.connectivity.desc') }}</p>
</div>
<!-- One row: the three neighbour counts (CAIDA, same reading as the
graph and lists below) + the ASRank customer-cone size -->
<div class="grid grid-cols-2 gap-2.5 text-center sm:grid-cols-4">
<template v-if="graph">
<div v-for="group in groups" :key="group.kind" class="rounded-md bg-muted/40 px-2 py-2 min-w-0">
<div class="text-lg font-semibold tabular-nums">{{ nf.format(group.count) }}</div>
<div class="text-xs text-muted-foreground">{{ t(`asnprofile.connectivity.${group.kind}`) }}</div>
</div>
</template>
<div v-if="cone !== null" class="rounded-md bg-muted/40 px-2 py-2 min-w-0">
<div class="text-lg font-semibold tabular-nums">{{ nf.format(cone) }}</div>
<div class="text-xs text-muted-foreground">{{ t('asnprofile.connectivity.cone') }}</div>
</div>
</div>
<!-- Upstream topology: the IP-card component on its cache
contract, borderless (this card draws the border), with
its expand-to-drawer for the full-width graph. -->
<ASNConnectivity v-if="hasTopology(graph)" :asn="String(profileAsn)"
:asnConnectivityInfos="connectivityInfos" :expandable="true" :bordered="false" />
<div v-if="graph" class="grid gap-4 md:grid-cols-3">
<div v-for="group in groups" :key="group.kind" class="min-w-0">
<div class="text-xs text-muted-foreground mb-1">{{ t(`asnprofile.connectivity.${group.kind}`) }}</div>
<div v-if="group.names.length" class="flex flex-wrap gap-1.5">
<Badge v-for="item in group.names" :key="item.asn" variant="outline"
class="font-normal max-w-full" :title="`AS${item.asn}`">
<span class="min-w-0 truncate">{{ item.label }}</span></Badge>
<span v-if="group.more" class="self-center text-xs text-muted-foreground">
+{{ nf.format(group.more) }}</span>
</div>
<p v-else class="text-xs text-muted-foreground">{{ t('asnprofile.connectivity.none') }}</p>
</div>
</div>
</div>
<!-- ============ Peering & facilities (PeeringDB, self-reported) ============ -->
<div v-if="peeringCard" class="rounded-lg border bg-card p-4 space-y-4 min-w-0">
<div class="space-y-1">
<h2 class="text-base font-semibold">{{ t('asnprofile.peering.title') }}</h2>
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('asnprofile.peering.desc') }}</p>
</div>
<!-- Policy / traffic level / ratio / IRR as-set -->
<dl v-if="peerFacts.length" class="grid grid-cols-2 gap-2.5 md:grid-cols-4">
<div v-for="fact in peerFacts" :key="fact.key" class="rounded-md bg-muted/40 p-3 min-w-0">
<dt class="text-xs text-muted-foreground">{{ t(`asnprofile.peering.${fact.key}`) }}</dt>
<dd class="mt-0.5 text-sm font-medium wrap-break-word" :class="fact.mono && 'font-mono text-xs'">
{{ fact.label }}</dd>
</div>
</dl>
<!-- IX | facility lists: side by side from lg up; a lone list spans the row -->
<div v-if="exchanges.length || facilities.length" class="grid gap-4"
:class="exchanges.length && facilities.length && 'lg:grid-cols-2'">
<!-- Exchanges: flag, name, city, RS badge, port capacity summed per IX -->
<section v-if="exchanges.length" class="space-y-2 min-w-0">
<h3 class="text-xs font-medium text-muted-foreground">
{{ t('asnprofile.peering.exchanges', { count: nf.format(exchanges.length) }) }}</h3>
<ul class="rounded-lg border bg-card divide-y">
<li v-for="(row, i) in shownExchanges" :key="i" class="flex items-center gap-2 px-3 py-1.5 text-sm">
<Icon v-if="row.cc" :icon="'circle-flags:' + row.cc.toLowerCase()" class="size-4 shrink-0" />
<span v-else class="size-4 shrink-0" />
<div class="min-w-0 flex-1">
<div class="truncate" :title="row.name">{{ row.name }}</div>
<div v-if="row.city" class="truncate text-xs text-muted-foreground" :title="row.city">{{ row.city }}</div>
</div>
<Badge v-if="row.rsPeer" variant="outline" :title="t('asnprofile.peering.rsTitle')"
class="shrink-0 px-1.5 text-[10px] font-medium text-muted-foreground">{{ t('asnprofile.peering.rs') }}</Badge>
<span class="w-14 shrink-0 text-right font-mono text-xs tabular-nums">{{ formatPortSpeed(row.speed, nf.format) }}</span>
</li>
</ul>
<Button v-if="exchanges.length > FIRST_ROWS" variant="outline" size="sm" class="h-7 cursor-pointer text-xs"
@click="showAllExchanges = !showAllExchanges">
{{ showAllExchanges ? t('asnprofile.peering.showLess') : t('asnprofile.peering.showAll', { count: nf.format(exchanges.length) }) }}</Button>
</section>
<!-- Facilities: flag, name, city -->
<section v-if="facilities.length" class="space-y-2 min-w-0">
<h3 class="text-xs font-medium text-muted-foreground">
{{ t('asnprofile.peering.facilities', { count: nf.format(facilities.length) }) }}</h3>
<ul class="rounded-lg border bg-card divide-y">
<li v-for="(row, i) in shownFacilities" :key="i" class="flex items-center gap-2 px-3 py-1.5 text-sm">
<Icon v-if="row.cc" :icon="'circle-flags:' + row.cc.toLowerCase()" class="size-4 shrink-0" />
<span v-else class="size-4 shrink-0" />
<div class="min-w-0 flex-1">
<div class="truncate" :title="row.name">{{ row.name }}</div>
<div v-if="row.city" class="truncate text-xs text-muted-foreground" :title="row.city">{{ row.city }}</div>
</div>
</li>
</ul>
<Button v-if="facilities.length > FIRST_ROWS" variant="outline" size="sm" class="h-7 cursor-pointer text-xs"
@click="showAllFacilities = !showAllFacilities">
{{ showAllFacilities ? t('asnprofile.peering.showLess') : t('asnprofile.peering.showAll', { count: nf.format(facilities.length) }) }}</Button>
</section>
</div>
</div>
</template>
<!-- Sources that didn't answer this time (one quiet line) -->
<p v-if="failed.length" class="text-xs text-muted-foreground">
{{ t('asnprofile.partial', { list: failed.join(', ') }) }}</p>
</div>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, ref, shallowRef, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Icon } from '@iconify/vue';
import { ChevronRight, Download, ExternalLink, Globe, Search, ShieldAlert, ShieldCheck, ShieldX } from '@lucide/vue';
import { trackEvent } from '@/utils/analytics';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { useActiveValue } from '@/composables/use-route-active.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseAsnInput } from '@/utils/ip/asn-input.js';
import { buildTrafficPairs, pickConnectionQuality } from '@/utils/ip/asn-metrics.js';
import {
asnProfileUrl, ASN_PROFILE_TIMEOUT_MS, sectionData, isProfileEmpty, failedSources,
hasTopology, isTier1Origin, heroIdentity, registrationFields, keyFacts, coneAsns,
RPKI_STATES, shapePrefixRows, familyCounts, filterPrefixRows, rpkiSummary, prefixListText,
countryShareRows, neighbourGroups,
BASELINE_POSITION, reputationMeters, proxyListedCount, verdictLevel, VERDICT_TONE,
peeringEnumKey, peeringHero, peeringFacts, formatPortSpeed, exchangeRows, facilityRows, hasPeeringCard, visibleRows,
} from '@/utils/ip/asn-profile-view.js';
import { formatIsoDate } from '@/utils/time-utils.js';
import getCountryName from '@/data/country-name.js';
import AsnConnectionQuality from '@/components/ip-infos/AsnConnectionQuality.vue';
import AsnTrafficShares from '@/components/ip-infos/AsnTrafficShares.vue';
import CopyButton from '@/components/widgets/CopyButton.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
// Heavy (dagre + SVG) — async, as IpAsnBlock loads it.
const ASNConnectivity = defineAsyncComponent(() => import('@/components/ip-infos/ASNConnectivity.vue'));
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const { dotClass, textClass } = useStatusTone();
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
const nf = computed(() => new Intl.NumberFormat(locale.value, { maximumFractionDigits: 1 }));
const compact = computed(() => new Intl.NumberFormat(locale.value, { notation: 'compact', maximumFractionDigits: 1 }));
// ---- run state -------------------------------------------------------------
// state: 'idle' | 'loading' | 'done' | 'error'. A response from an older run
// is dropped.
const query = ref('');
const errorMsg = ref('');
const profileAsn = ref(null);
const state = ref('idle');
const profile = shallowRef(null);
const rawOpen = ref(false);
let runId = 0;
const FIRST_ROWS = 10;
const PAGE = 50;
const familyFilter = ref('all');
const limit = ref(FIRST_ROWS);
const showAllExchanges = ref(false);
const showAllFacilities = ref(false);
const loading = computed(() => state.value === 'loading');
const run = async (asn) => {
const id = ++runId;
profileAsn.value = asn;
profile.value = null;
state.value = 'loading';
rawOpen.value = false;
familyFilter.value = 'all';
limit.value = FIRST_ROWS;
showAllExchanges.value = false;
showAllFacilities.value = false;
try {
const response = await fetchWithTimeout(asnProfileUrl(asn), { timeoutMs: ASN_PROFILE_TIMEOUT_MS });
const body = response.ok ? await response.json() : null;
if (id !== runId) return;
profile.value = body?.status ? body : null;
state.value = profile.value ? 'done' : 'error';
} catch (error) {
if (id !== runId) return;
console.warn('ASN Profile request failed:', error);
state.value = 'error';
}
};
// ---- input + shareable ?q= -------------------------------------------------
// Keep `?q=` in step with the last run without growing history.
const syncQuery = (q) => {
if (route.query.q === q) return;
router.replace({ query: { ...route.query, q } });
};
const start = (raw, { track = true } = {}) => {
const asn = parseAsnInput(raw);
if (asn === null) {
errorMsg.value = t('asnprofile.invalidInput');
return;
}
errorMsg.value = '';
query.value = `AS${asn}`;
if (track) trackEvent('Section', 'StartClick', 'AsnProfile');
syncQuery(query.value);
run(asn);
};
const onSubmit = () => {
if (query.value.trim()) start(query.value);
};
// Shareable `?q=` (use-route-active.js), including an ASN link followed from
// inside the page; our own write-back equals `query`, so it doesn't run twice.
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q !== query.value) {
query.value = q;
start(q, { track: false });
}
}, { immediate: true });
// ---- sections --------------------------------------------------------------
const radar = computed(() => sectionData(profile.value, 'radar'));
const prefixesData = computed(() => sectionData(profile.value, 'prefixes'));
const graph = computed(() => sectionData(profile.value, 'connectivity'));
const whois = computed(() => sectionData(profile.value, 'whois'));
const rank = computed(() => sectionData(profile.value, 'rank'));
const reputation = computed(() => sectionData(profile.value, 'reputation'));
const peeringdb = computed(() => sectionData(profile.value, 'peeringdb'));
const empty = computed(() => isProfileEmpty(profile.value));
const failed = computed(() => failedSources(profile.value));
// ---- hero ------------------------------------------------------------------
const identity = computed(() => heroIdentity({ radar: radar.value, whois: whois.value, rank: rank.value }));
const isTier1 = computed(() => isTier1Origin(graph.value));
const rows = computed(() => shapePrefixRows(prefixesData.value?.prefixes));
const FACT_FORMAT = {
rank: (fact) => ({ value: `#${nf.value.format(fact.value)}`, sub: t('asnprofile.facts.rankSource') }),
announcedIpv4: (fact) => ({
value: nf.value.format(fact.value),
sub: t('asnprofile.facts.ipv4Share', {
share: new Intl.NumberFormat(locale.value, { maximumSignificantDigits: 2 }).format(fact.share),
}),
}),
prefixes: (fact) => ({ value: `${nf.value.format(fact.v4)} · ${nf.value.format(fact.v6)}` }),
users: (fact) => ({ value: compact.value.format(fact.value) }),
};
const facts = computed(() => keyFacts({ radar: radar.value, rank: rank.value, rows: rows.value })
.map((fact) => ({ key: fact.key, label: t(`asnprofile.facts.${fact.key}`), ...FACT_FORMAT[fact.key](fact) })));
const registration = computed(() => registrationFields(whois.value, (day) => formatIsoDate(day, locale.value)));
const rawRecord = computed(() => whois.value?.__raw || '');
// Verdict badge: the scoring level's wording, coloured by tone.
const VERDICT_STYLE = {
'ok-fast': { variant: 'success', icon: ShieldCheck },
'ok-slow': { variant: 'outline', icon: ShieldAlert, extra: 'border-transparent bg-warning text-warning-foreground' },
fail: { variant: 'destructive', icon: ShieldX },
};
const verdict = computed(() => {
const level = verdictLevel(reputation.value);
if (!level) return null;
return { ...VERDICT_STYLE[VERDICT_TONE[level]], label: t(`asnprofile.reputation.level.${level}`) };
});
// ---- reputation ------------------------------------------------------------
const meters = computed(() => reputationMeters(reputation.value));
const proxyListed = computed(() => proxyListedCount(reputation.value));
const ratioNote = (ratio) => (ratio > 1
? t('asnprofile.reputation.ratio', { ratio: ratio >= 10 ? nf.value.format(Math.round(ratio)) : nf.value.format(ratio) })
: t('asnprofile.reputation.belowBaseline'));
const reputationDate = computed(() => {
const day = /^\d{4}-\d{2}-\d{2}/.exec(reputation.value?.updatedAt || '');
return day ? formatIsoDate(day[0], locale.value) : '';
});
// ---- traffic ---------------------------------------------------------------
const trafficVisible = computed(() => buildTrafficPairs(radar.value).length > 0
|| Object.keys(pickConnectionQuality(radar.value)).length > 0);
// ---- prefixes + RPKI -------------------------------------------------------
const RPKI_DOT = { valid: 'bg-success', invalid: 'bg-destructive', unknown: 'bg-muted-foreground/40' };
const familyOptions = computed(() => [
{ value: 'all', label: t('asnprofile.prefixes.all') },
{ value: 'v4', label: t('ipInfos.ASNInfo.IPv4_Pct') },
{ value: 'v6', label: t('ipInfos.ASNInfo.IPv6_Pct') },
]);
const counts = computed(() => familyCounts(rows.value));
const filtered = computed(() => filterPrefixRows(rows.value, familyFilter.value));
const shownRows = computed(() => filtered.value.slice(0, limit.value));
const filteredText = () => prefixListText(filtered.value);
watch(familyFilter, () => { limit.value = FIRST_ROWS; });
// Counted over the prefix rows, so the bar always matches the table.
const rpki = computed(() => rpkiSummary(rows.value));
// v4 → address count; v6 → how many /48s the prefix spans.
const sizeLabel = (row) => {
if (row.family === 4) return nf.value.format(row.count);
return row.count === null ? `/${row.length}` : `${nf.value.format(row.count)} × /48`;
};
const downloadFiltered = () => {
const url = URL.createObjectURL(new Blob([`${filteredText()}\n`], { type: 'text/plain' }));
const suffix = familyFilter.value === 'all' ? '' : `-${familyFilter.value}`;
const link = document.createElement('a');
link.href = url;
link.download = `AS${profileAsn.value}-prefixes${suffix}.txt`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
// ---- country distribution --------------------------------------------------
const topCountries = computed(() => countryShareRows(prefixesData.value?.countries, 10));
// ---- connectivity ----------------------------------------------------------
const NAME_PREVIEW = 8;
const groups = computed(() => neighbourGroups(graph.value?.neighbours, NAME_PREVIEW));
// ASNConnectivity reads the IP-card cache shape: { [numeric asn]: { graph } }.
const connectivityInfos = computed(() => (graph.value ? { [String(profileAsn.value)]: { graph: graph.value } } : {}));
const cone = computed(() => coneAsns(rank.value));
// ---- peering & facilities (PeeringDB) --------------------------------------
// PeeringDB vocabulary → translated label; anything else as written.
const enumLabel = (group, value) => {
const key = peeringEnumKey(group, value);
return key ? t(key) : value;
};
const peerHero = computed(() => peeringHero(peeringdb.value));
const peerFacts = computed(() => peeringFacts(peeringdb.value)
.map((fact) => ({ ...fact, label: fact.group ? enumLabel(fact.group, fact.value) : fact.value })));
const exchanges = computed(() => exchangeRows(peeringdb.value));
const facilities = computed(() => facilityRows(peeringdb.value));
const peeringCard = computed(() => hasPeeringCard(peeringdb.value));
const shownExchanges = computed(() => visibleRows(exchanges.value, showAllExchanges.value, FIRST_ROWS));
const shownFacilities = computed(() => visibleRows(facilities.value, showAllFacilities.value, FIRST_ROWS));
</script>
@@ -7,13 +7,10 @@
</div>
<Transition name="slide-fade" mode="out-in">
<!-- Loading / error state -->
<div v-if="checkingStatus !== 'finished'"
class="flex items-center justify-center gap-2 py-8 text-sm">
<template v-if="checkingStatus === 'running'">
<Spinner class="text-info" />
<span class="text-muted-foreground">{{ t('browserinfo.calculating') }}</span>
</template>
<!-- Loading / error state; the chunk phase before it shows
ToolLoadingSkeleton instead. -->
<div v-if="checkingStatus !== 'finished'" class="flex justify-center py-12 text-sm">
<Spinner v-if="checkingStatus === 'running'" class="size-6 text-info" />
<p v-else-if="checkingStatus === 'error'" class="text-destructive">{{ errorMsg }}</p>
</div>
@@ -367,7 +364,7 @@ const getAll = async () => {
onMounted(() => {
checkingStatus.value = 'running';
setTimeout(() => { getAll(); }, 1000);
getAll();
});
watch(excludeOptions, () => { getFingerPrint(); }, { immediate: true, deep: true });
@@ -37,8 +37,8 @@
<CardContent class="p-0">
<header class="flex items-center justify-between gap-2 px-4 py-3 border-b">
<h3 class="text-sm font-semibold m-0">{{ t('censorshipcheck.HistoryTitle') }}</h3>
<span class="text-xs text-muted-foreground font-mono">{{ ooniData.since }} → {{
ooniData.until }}</span>
<span class="text-xs text-muted-foreground">{{ formatIsoDate(ooniData.since, locale) }} → {{
formatIsoDate(ooniData.until, locale) }}</span>
</header>
<ul class="divide-y">
<li v-for="c in flagged" :key="c.country" class="px-4 py-2.5 space-y-1.5">
@@ -104,14 +104,16 @@
<div class="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x">
<!-- Left: suggestion sections (flagged / high-risk / open) +
the picker's own continent catalog. -->
<div class="col-span-1" :class="runStarted
? 'max-h-72 overflow-y-auto md:max-h-none md:overflow-visible md:relative md:min-h-96'
: 'max-h-72 md:max-h-128 overflow-y-auto'">
<div class="px-4 py-3"
:class="runStarted ? 'md:absolute md:inset-0 md:overflow-y-auto' : ''">
<GlobalpingCountryPicker v-model="selectedCountries" :sections="pickerSections"
:max="MAX_TEST_COUNTRIES" :disabled="running" />
the picker's own continent catalog. Scroll container
stays padding-free (sticky top-0 can't cover a
scroll container's own padding) -->
<div class="col-span-1" :class="runStarted ? 'md:relative md:min-h-96' : ''">
<div class="max-h-72 overflow-y-auto"
:class="runStarted ? 'md:max-h-none md:absolute md:inset-0' : 'md:max-h-128'">
<div class="px-4 py-3">
<GlobalpingCountryPicker v-model="selectedCountries" :sections="pickerSections"
:max="MAX_TEST_COUNTRIES" :disabled="running" />
</div>
</div>
</div>
@@ -184,8 +186,9 @@ import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { useGlobalpingMeasurement } from '@/composables/use-globalping-measurement';
import GlobalpingCountryPicker from './GlobalpingCountryPicker.vue';
import { isValidDomain } from '@/utils/valid-ip.js';
import { isValidDomain } from '@/utils/ip/valid-ip.js';
import getCountryName from '@/data/country-name.js';
import { formatIsoDate } from '@/utils/time-utils.js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -197,7 +200,7 @@ import {
} from '@lucide/vue';
import { Label } from '@/components/ui/label';
const { t } = useI18n();
const { t, locale } = useI18n();
const store = useMainStore();
const lang = computed(() => store.lang);
@@ -210,7 +213,7 @@ const MAX_TEST_COUNTRIES = 15;
const PROBES_PER_COUNTRY = 2;
// OONI tier / blocking-method display maps (tiers computed by the backend,
// see common/ooni-blocking.js).
// see server/ooni-blocking.js).
const TIER_META = {
confirmed: { labelKey: 'TierConfirmed', badgeClass: 'bg-destructive/15 text-destructive border-transparent' },
likely: { labelKey: 'TierLikely', badgeClass: 'bg-warning/15 text-warning border-transparent' },
Loaded 100 of 672 files, more files were not shown because too many files have changed in this diff. Show more