mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-06 17:05:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da04a81e58 | ||
|
|
e461bc168b | ||
|
|
863e333c53 | ||
|
|
2d244a129d | ||
|
|
56c68364bf | ||
|
|
80e6a5ca1a | ||
|
|
1b1cf9f68f | ||
|
|
31e8a5c306 | ||
|
|
ccd7a572a8 | ||
|
|
61efe98d1d | ||
|
|
d68328d7bf | ||
|
|
896494d895 | ||
|
|
825db1638f | ||
|
|
fd5e04fea6 | ||
|
|
e4ec314a6a | ||
|
|
0c3f05ca45 | ||
|
|
717356bd7c | ||
|
|
e31a9298d1 | ||
|
|
0446364836 | ||
|
|
2329294a8f | ||
|
|
4acf56b593 | ||
|
|
c0ae017f89 | ||
|
|
9aae391468 | ||
|
|
f6e979f52d | ||
|
|
1a9988388c | ||
|
|
720c4a1a64 | ||
|
|
4b8cceff4d | ||
|
|
5321720c5f | ||
|
|
13af650719 | ||
|
|
72a656d18b | ||
|
|
a98a6c812f | ||
|
|
a79e88cbb1 | ||
|
|
6fcaadc751 | ||
|
|
29bdac244c | ||
|
|
e766bd84ce | ||
|
|
88bb4fdaef | ||
|
|
7fbc782f1d | ||
|
|
9e4a0f5cb6 | ||
|
|
da1f3fe952 | ||
|
|
4c9ab04bc5 | ||
|
|
b7cf99fc36 | ||
|
|
f9f24ca707 | ||
|
|
11b4160767 | ||
|
|
9ee262fdc3 | ||
|
|
24380159a4 | ||
|
|
5ecd2ed089 | ||
|
|
c350f0d2d9 | ||
|
|
938dca96dd | ||
|
|
d5452c0fce | ||
|
|
e7bc4db293 | ||
|
|
49c3dfa887 | ||
|
|
5cfb73994a | ||
|
|
45f920248f | ||
|
|
50885c6317 | ||
|
|
7a608cb8ce | ||
|
|
d28fe239ae | ||
|
|
35e691da6e | ||
|
|
116a74b1a0 | ||
|
|
072a76cae0 | ||
|
|
4cc48d151b | ||
|
|
88423522e3 | ||
|
|
1163ea4a13 | ||
|
|
06402ce688 | ||
|
|
c2751d37f7 | ||
|
|
b84a1d3764 | ||
|
|
6e40a611d3 | ||
|
|
64ace80bf2 | ||
|
|
79a91171f7 | ||
|
|
f4d90fe44c | ||
|
|
70c1cbd2b9 | ||
|
|
5502169374 | ||
|
|
547e9a1e84 | ||
|
|
5d378c5e13 | ||
|
|
47dff987b1 | ||
|
|
6036a7c570 | ||
|
|
da9614c87e | ||
|
|
068ea5a795 | ||
|
|
6211b124b0 | ||
|
|
e23f00a818 | ||
|
|
2961d9ba8d | ||
|
|
a509b99207 | ||
|
|
187ede91a9 | ||
|
|
017e5c9af3 | ||
|
|
117ebaa65a | ||
|
|
89a103a3b7 | ||
|
|
6d7221538d | ||
|
|
8d7b53d1de | ||
|
|
015d6aef7b | ||
|
|
de9d3d2118 | ||
|
|
de6c5d730d | ||
|
|
f2d684faa8 | ||
|
|
26cb4ad15c | ||
|
|
7571e0c71e | ||
|
|
e39aed71e3 | ||
|
|
b616b7e00e | ||
|
|
8543879bbd | ||
|
|
78db2f8934 | ||
|
|
4429020d91 | ||
|
|
3e4a268ce5 | ||
|
|
65ec652135 | ||
|
|
ebe2c36ae3 | ||
|
|
435d9f6413 | ||
|
|
ae52320d3b | ||
|
|
96525d2fc7 | ||
|
|
47eca1a4f6 | ||
|
|
a4020442f0 | ||
|
|
2f27e62d19 | ||
|
|
563c55c6d8 | ||
|
|
34d69f8ec9 | ||
|
|
fd9b042019 | ||
|
|
2a360f0374 | ||
|
|
d6d80dac24 | ||
|
|
38a61fdf92 | ||
|
|
1c7e7c68cd | ||
|
|
156d0bff1b | ||
|
|
856f965101 | ||
|
|
1c3a36d253 | ||
|
|
e3eaef369f | ||
|
|
08473e3e54 | ||
|
|
f0d27fe510 | ||
|
|
2f5d3b426b | ||
|
|
728dd7d087 | ||
|
|
44e897a48b | ||
|
|
16a5ecd5b5 | ||
|
|
d8426e6017 | ||
|
|
bbcc53c518 | ||
|
|
4840c9ce01 | ||
|
|
13091bf5b5 | ||
|
|
b1875562fc | ||
|
|
1dd10fbd2f | ||
|
|
76833b91f3 | ||
|
|
f6903513dc | ||
|
|
a185d1844d | ||
|
|
fa7ec2108e | ||
|
|
deb7f11468 | ||
|
|
8c02ab3d14 | ||
|
|
c29e48687b | ||
|
|
ec4d536b60 | ||
|
|
d0e4023ce4 | ||
|
|
c0cd8418e3 | ||
|
|
95f4196c88 | ||
|
|
af45bcee18 | ||
|
|
a1bcc62965 | ||
|
|
0b4069b1b2 | ||
|
|
3072941edc | ||
|
|
d3df177df9 | ||
|
|
87d49cec3d | ||
|
|
000d5b4181 | ||
|
|
675ad8967b | ||
|
|
e57b9e331d | ||
|
|
7a52e8acea | ||
|
|
83cba6f3ba | ||
|
|
6a563e437f | ||
|
|
c29de4f97c | ||
|
|
971cc5203c | ||
|
|
f81fce0c02 | ||
|
|
f867e0d189 | ||
|
|
1db89e1dc4 | ||
|
|
ce21aa33bb | ||
|
|
c85c33d13a | ||
|
|
034f5177c6 | ||
|
|
5c0891a323 | ||
|
|
d84fefbbea | ||
|
|
02476aec7e | ||
|
|
65e8432768 | ||
|
|
b3cd1ceef7 | ||
|
|
287c50fe1a | ||
|
|
152906b762 | ||
|
|
dcff924d3d | ||
|
|
57f68bacb5 | ||
|
|
0054c9dde1 | ||
|
|
8ebe3ea0ea | ||
|
|
5ef0e48e8b | ||
|
|
8bcbb91624 | ||
|
|
846deb50a8 | ||
|
|
04079783af | ||
|
|
d5715104d5 | ||
|
|
a5d3cbf2c8 | ||
|
|
bf1e4a934a | ||
|
|
076a9e36d8 | ||
|
|
4eee98aa93 | ||
|
|
5ea781c513 | ||
|
|
901092ef66 | ||
|
|
6189a96498 | ||
|
|
c55634c8e0 | ||
|
|
0e6e411beb | ||
|
|
09d3cef4c0 | ||
|
|
3495204fc4 | ||
|
|
3dea2421d2 | ||
|
|
aedee4fd3d | ||
|
|
8ace7a2e30 | ||
|
|
701b610833 | ||
|
|
681051b1bc | ||
|
|
b24874b69e | ||
|
|
cf3bb69d8b | ||
|
|
74b2c69524 | ||
|
|
78d5f95238 | ||
|
|
5c59c6677d | ||
|
|
2b5485f316 | ||
|
|
d77342cc83 | ||
|
|
47446b3039 | ||
|
|
83d2679d61 | ||
|
|
aa9f31472e | ||
|
|
f7e332c15d | ||
|
|
7f3d38e147 | ||
|
|
5d764cff29 | ||
|
|
abd8e2b683 | ||
|
|
ab42ba6acf | ||
|
|
40342a9631 | ||
|
|
1adefb13d2 | ||
|
|
0c6e5f2c7a | ||
|
|
6de54d7e44 | ||
|
|
1ec87945ce | ||
|
|
39ae0ad528 | ||
|
|
45be0e711c | ||
|
|
65907c90e5 | ||
|
|
b2b98b519c | ||
|
|
7c146976ef | ||
|
|
d3ada7e6b8 | ||
|
|
7909cbfbf5 | ||
|
|
3edaaf5f79 | ||
|
|
7c5c3e801d | ||
|
|
beb5653b5f | ||
|
|
a3b334d218 | ||
|
|
a11539004c | ||
|
|
cc0053ac16 | ||
|
|
9d9de4ed22 | ||
|
|
a1ee760d88 | ||
|
|
020bfcedd6 | ||
|
|
c49cc4246a | ||
|
|
057e87269b | ||
|
|
ddd0a2bedd | ||
|
|
0b3b4ccd38 | ||
|
|
56de3a3b23 | ||
|
|
a62a017468 | ||
|
|
f7b0fb157c | ||
|
|
c596a15b30 | ||
|
|
e6949bd580 | ||
|
|
6cf3b58a6b | ||
|
|
a16cdf7852 | ||
|
|
3da73075a5 | ||
|
|
05f837df27 | ||
|
|
238ded1e1f | ||
|
|
2a9f62327a | ||
|
|
481661212f | ||
|
|
11536c9dc5 | ||
|
|
e1754e3efc | ||
|
|
d5ed6c9428 | ||
|
|
3cf534d6ee | ||
|
|
a63ef4521a | ||
|
|
65145ca5e8 | ||
|
|
647e03d963 | ||
|
|
eed1f985d1 | ||
|
|
e7074cc12c | ||
|
|
3b19e0253e | ||
|
|
e30fcb17e5 | ||
|
|
e6670b16ec | ||
|
|
6f4c96e64b | ||
|
|
70f058ed4f | ||
|
|
0199902495 | ||
|
|
ad1d6db208 | ||
|
|
ffdc9213d1 | ||
|
|
c72dd00984 | ||
|
|
675a600d1f | ||
|
|
1c797b30a5 | ||
|
|
8d84ec37bd | ||
|
|
244a121e98 | ||
|
|
a8f75bdb46 | ||
|
|
92f910872e | ||
|
|
111424e9f5 | ||
|
|
c2432a4dd0 | ||
|
|
53f3006070 | ||
|
|
6934bf9e5b | ||
|
|
948fdd47e3 | ||
|
|
8ab3675081 | ||
|
|
15f19a9103 | ||
|
|
b236dbb7e3 | ||
|
|
37b29c3975 | ||
|
|
7738a84dc0 | ||
|
|
da0363dd86 | ||
|
|
fd5aa25034 | ||
|
|
c4d2bbbafd | ||
|
|
51238f289a | ||
|
|
68591df044 | ||
|
|
097fca6965 | ||
|
|
b4994b9418 | ||
|
|
21311cc787 | ||
|
|
87765d54bf | ||
|
|
da1b81ec49 | ||
|
|
983382e628 | ||
|
|
78724bafb1 | ||
|
|
57af5ff327 | ||
|
|
40900aef3a | ||
|
|
244be604ab | ||
|
|
3c68dba490 | ||
|
|
4af258a069 | ||
|
|
d7e46c5dd5 | ||
|
|
0c2b2d2bbe | ||
|
|
b256cb6809 | ||
|
|
955ab60ff0 | ||
|
|
6da1e69d4a | ||
|
|
bf145173ac | ||
|
|
68e88bb709 | ||
|
|
00b21a73b0 | ||
|
|
2ce2f3298e | ||
|
|
f623c07eae | ||
|
|
338febf955 | ||
|
|
7796304dcd | ||
|
|
1332db40c0 | ||
|
|
2d5431af2d | ||
|
|
2e6594d99d | ||
|
|
7ed58d8330 | ||
|
|
70370ade01 | ||
|
|
6433784309 | ||
|
|
c09eefe920 | ||
|
|
0449c22e29 | ||
|
|
abb91611a3 | ||
|
|
3e82a51452 | ||
|
|
56f4413d00 | ||
|
|
aa8f6c3d14 | ||
|
|
9c0e36a74a | ||
|
|
cd5b25fec4 | ||
|
|
13ce7cb1c8 | ||
|
|
9b49bae493 | ||
|
|
c480efac90 | ||
|
|
855a6f82b8 | ||
|
|
107f6f8c99 | ||
|
|
017e2b8878 | ||
|
|
2cc732a027 | ||
|
|
7b3594efee | ||
|
|
eb16afb539 | ||
|
|
050be6ecda | ||
|
|
13f226fb72 | ||
|
|
7a3989fbb2 | ||
|
|
0828f0bb44 | ||
|
|
0208d23025 | ||
|
|
abe608f98e | ||
|
|
70f9b84648 | ||
|
|
a82a61292d | ||
|
|
9ab4505c60 | ||
|
|
c855c00e71 | ||
|
|
6eb5aad488 | ||
|
|
d920ddb69c | ||
|
|
0a0a739edd | ||
|
|
f2df6e669d | ||
|
|
3e3fb7bdb7 | ||
|
|
2faadd4527 | ||
|
|
d31785c6dd | ||
|
|
fa7dfc901d | ||
|
|
d05c6aac0c | ||
|
|
c122df73be | ||
|
|
aa497fb526 | ||
|
|
a13e89fbae | ||
|
|
820e2ff24d | ||
|
|
8e92e19919 | ||
|
|
79a5269fbc | ||
|
|
ffa5a0715c | ||
|
|
3543f6920b | ||
|
|
ca726d063d | ||
|
|
1611c6b701 | ||
|
|
f3a0d92a72 | ||
|
|
624656bd32 | ||
|
|
bb8caa6cc2 | ||
|
|
c9077af057 | ||
|
|
b3b11f26e3 | ||
|
|
3ae4ab74e5 | ||
|
|
8bb9dc777b | ||
|
|
cbc562a332 | ||
|
|
7f8051bdc7 | ||
|
|
ccfa0dcc74 | ||
|
|
3f80f9118e | ||
|
|
e9808e31db | ||
|
|
4c22558a3f | ||
|
|
dd759863df | ||
|
|
b56b748df6 | ||
|
|
40ccec123c | ||
|
|
2f0af8e241 | ||
|
|
5fe62ba000 | ||
|
|
58bd271d8b | ||
|
|
10b3368f67 | ||
|
|
5217815f54 | ||
|
|
16d3e1675c | ||
|
|
b411af164b | ||
|
|
09b63acc12 | ||
|
|
46a42b1e65 | ||
|
|
29ae0ff45f | ||
|
|
4a2085abac | ||
|
|
8f2751b15f | ||
|
|
89cbb35401 | ||
|
|
865f4c8c94 | ||
|
|
36d6177d15 | ||
|
|
da8698f308 | ||
|
|
c978bfb367 | ||
|
|
8c6e145513 | ||
|
|
030ce343c2 | ||
|
|
0b44f3c25e | ||
|
|
c92c13683d | ||
|
|
0549deca90 | ||
|
|
21792cbf11 | ||
|
|
9a534e6497 | ||
|
|
31077dd808 | ||
|
|
599d670e6c | ||
|
|
b6cf3132fc | ||
|
|
f38c169159 | ||
|
|
a9240b5e5b | ||
|
|
3d1e98884a | ||
|
|
e61a1992f3 | ||
|
|
084fe65eaa | ||
|
|
6301d8b838 | ||
|
|
30da86042f | ||
|
|
5bb9dd4113 | ||
|
|
daac0b7535 | ||
|
|
8b2e49b57e | ||
|
|
c291e07794 | ||
|
|
06d25f373b | ||
|
|
a28aaeba0f | ||
|
|
d325144f83 | ||
|
|
1e6608fd18 | ||
|
|
f6ef42f98f | ||
|
|
f0aa85588d | ||
|
|
0907d563af | ||
|
|
4ae3646700 | ||
|
|
60cac96e03 | ||
|
|
46ce6138e0 | ||
|
|
4df8b78ca6 | ||
|
|
f98ee53aa2 | ||
|
|
40929b7581 | ||
|
|
6c70d47941 | ||
|
|
41b635483d | ||
|
|
e4ae72cae7 | ||
|
|
d711b7be82 | ||
|
|
80c0d880d3 | ||
|
|
b5b60a0406 | ||
|
|
0f0160da99 | ||
|
|
d50befc22d | ||
|
|
df3771292d | ||
|
|
c85e370ece | ||
|
|
0e8db72855 | ||
|
|
3f9feb1143 | ||
|
|
95401e3100 | ||
|
|
8d94aaa14d | ||
|
|
cc2442fbea | ||
|
|
e2c0d8d1ca | ||
|
|
e757c619ea | ||
|
|
1776107065 | ||
|
|
2eddb7287e | ||
|
|
a703442ba8 | ||
|
|
89c4327969 | ||
|
|
ccac693c48 | ||
|
|
e548b6e1e9 | ||
|
|
0cc7efc3d2 | ||
|
|
802507ee6f | ||
|
|
f5d2601589 | ||
|
|
d6fb27deac | ||
|
|
93d867d206 | ||
|
|
fd3403a9af | ||
|
|
cd621c1a1f | ||
|
|
f2460db67e | ||
|
|
123f17fc5e | ||
|
|
8c78e37a39 | ||
|
|
30a3250faf | ||
|
|
3bb49db80e | ||
|
|
69e75a70cd | ||
|
|
26f744b463 | ||
|
|
ac06336979 | ||
|
|
020053ee56 | ||
|
|
337d03fb8a | ||
|
|
8d55118a78 | ||
|
|
918f667420 | ||
|
|
d377f1912e | ||
|
|
f6c8556c0d | ||
|
|
7becf39074 | ||
|
|
87d0f82e7d | ||
|
|
9d653651ed | ||
|
|
0ff2615bab | ||
|
|
228755d691 | ||
|
|
735555d29f | ||
|
|
42dfd92579 | ||
|
|
552763bead | ||
|
|
7cd879ffd8 | ||
|
|
33637f53de | ||
|
|
228d457a32 | ||
|
|
3841af0246 | ||
|
|
153f76a8b6 | ||
|
|
5f7dfe552f | ||
|
|
26ab6ace3b | ||
|
|
cc8e9a0a52 | ||
|
|
aa4524da40 | ||
|
|
34ac46ee44 | ||
|
|
7d009fb29f | ||
|
|
aab3cc56a0 | ||
|
|
fdd1281e66 | ||
|
|
a062bbcec2 |
No files matched your search
+3
-18
@@ -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/
|
||||
@@ -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
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
https://ipcheck.ing/funding.json
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/>.
|
||||
@@ -17,8 +17,11 @@
|
||||
|
||||

|
||||

|
||||
[](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
@@ -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">
|
||||
|
||||

|
||||
@@ -17,66 +20,93 @@
|
||||
|
||||

|
||||

|
||||
[](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.
|
||||
|
||||
[](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
@@ -0,0 +1,213 @@
|
||||
# 🧰 MyIP - एक बेहतर IP टूलबॉक्स
|
||||
|
||||
> [!NOTE]
|
||||
> यह अनुवाद समुदाय के लोग संभालते हैं. आधिकारिक स्रोत अंग्रेज़ी README है, और हो सकता है यह वर्शन उससे पीछे रह जाए.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
डेमो को बुकमार्क करें या अपना खुद का वर्शन डिप्लॉय करें.
|
||||
|
||||
[](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
@@ -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">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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.
|
||||
|
||||
[](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
@@ -1,127 +1,160 @@
|
||||
# 🧰 MyIP — удобный набор инструментов для работы с IP
|
||||
|
||||
> [!NOTE]
|
||||
> Это перевод, поддерживаемый сообществом; английская версия README является основной, и данная версия может отставать от неё.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
Добавьте демо-сайт в закладки или разверните собственный экземпляр.
|
||||
Добавьте демо в закладки или разверните собственный экземпляр.
|
||||
|
||||
[](https://hub.docker.com/r/jason5ng32/myip)
|
||||
[](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
@@ -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">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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.
|
||||
|
||||
[](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
@@ -0,0 +1,213 @@
|
||||
# 🧰 MyIP - กล่องเครื่องมือ IP ที่ดีกว่าเดิม
|
||||
|
||||
> [!NOTE]
|
||||
> นี่คือคำแปลที่ดูแลโดยชุมชน README ภาษาอังกฤษคือต้นฉบับหลัก ฉบับนี้อาจอัปเดตไม่ทันต้นฉบับ
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
จะบุ๊กมาร์กเว็บเดโมไว้ใช้ หรือติดตั้งเองก็ได้ตามสะดวก
|
||||
|
||||
[](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
@@ -0,0 +1,213 @@
|
||||
# 🧰 MyIP - 更好的 IP 工具箱
|
||||
|
||||
> [!NOTE]
|
||||
> 這是由社群維護的翻譯版本;英文版 README 為唯一權威來源,本頁內容可能落後於英文版。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
歡迎把展示站加入書籤,也可以自行部署一套。
|
||||
|
||||
[](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
@@ -1,4 +1,7 @@
|
||||
# 🧰 IP 工具箱
|
||||
# 🧰 MyIP - 更好的 IP 工具箱
|
||||
|
||||
> [!NOTE]
|
||||
> 这是由社区维护的翻译版本;英文版 README 为唯一权威来源,本页面内容可能滞后于英文版。
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -17,12 +20,15 @@
|
||||
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
你可以直接用我已经搭建好的服务,也可以自行搭建。
|
||||
欢迎收藏在线版本,也可以自行部署。
|
||||
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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) });
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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']);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ? [] : {});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = ' ';
|
||||
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
Reference in new issue
Block a user