diff --git a/.env.example b/.env.example index 476124224..c8bcf9d5f 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,9 @@ VITE_GOOGLE_ANALYTICS_ID="" MAXMIND_ACCOUNT_ID="" MAXMIND_LICENSE_KEY="" MAXMIND_AUTO_UPDATE="false" +# Logging — LOG_LEVEL: debug/info/warn/error (default info) +# LOG_FORMAT: "json" for log shippers, anything else = pretty +# LOG_HTTP: "true" to enable per-request /api logging (off by default) +LOG_LEVEL="" +LOG_FORMAT="" +LOG_HTTP="" diff --git a/.gitignore b/.gitignore index db2a47a90..1e0b7389d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ common/maxmind-db/*.bak common/maxmind-db/*.next common/maxmind-db/*.mmdb .learnings/ +docs/ diff --git a/AGENTS.md b/AGENTS.md index 3ffa8c117..75d8ce02c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se | Bottom drawer | `vaul-vue` | | Toast | `vue-sonner` | | Backend | Express 5 | +| Logger | `pino` + `pino-pretty` (dev) + `pino-http` (request logs) — singleton at `common/logger.js` | | Auth | Firebase Auth (optional, env-gated) | | PWA | Serwist | | Tests | Node built-in test runner (`node --test`) | @@ -84,6 +85,14 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se - Any feature that surfaces copy must land in **all four locales** (`en` / `zh` / `fr` / `tr`) in the same change. No English-only or Chinese-only keys slipping through. - Same rule applies to `frontend/data/changelog.json` — every entry's `change` object must have all four languages. `tests/changelog.test.js` enforces this. +### Logging (backend) + +- **Use the shared logger from `common/logger.js`** in every backend file (`backend-server.js`, `frontend-server.js`, `api/*`, `common/*`). It's a `pino` singleton — pretty-printed via `pino-pretty` by default; set `LOG_FORMAT=json` in `.env` to emit raw JSON for log aggregators. Log level defaults to `warn`; override with `LOG_LEVEL` env in `.env` (`debug` / `info` / `warn` / `error`). At the default `warn`, pino-http's 2xx/3xx request lines are filtered out (they log at level `info`); 4xx become visible warns and 5xx errors. No `NODE_ENV` dependency — the project doesn't use that variable anywhere else. +- **Bare `console.*` is banned** in backend code — it bypasses level filtering and dumps unstructured text into the prod log stream. Frontend code (`frontend/`) is unaffected; browser code keeps using `console.*`. +- **Pino's first-arg-is-context convention.** Errors: `logger.error({ err: error, ip, ... }, 'short message')`. Pino has a built-in serializer for the `err` key that formats stack traces nicely. +- **Startup-only lines (called once at boot)** lead with an emoji for at-a-glance scanning when the dev terminal is busy: 🚀 listening, 📦 ready, 📥 downloading, 🛡️ security on, 🐢 throttling on, 🗓️ schedule, ⚠️ recoverable warning, ❌ failure. Per-request and per-handler logs stay plain. +- **HTTP request logging** is **off by default** to keep pm2 logs from bloating. Set `LOG_HTTP=true` in `.env` to mount `pino-http` on `/api`; when on, 2xx/3xx log as `info`, 4xx as `warn`, 5xx as `error`. Handlers never log incoming requests themselves — they log domain-specific events / errors only, regardless of this flag. + ## Testing - **Test runner:** Node built-in (`node --test`), no third-party framework. Specs live in `tests/*.test.js`. @@ -102,6 +111,7 @@ The backend enforces access control and timeouts through shared middleware rathe ## Workflow +- **Branch discipline — `dev` in, `dev` out.** All work starts from `dev` and lands on `dev`. `main` is only updated via PRs that merge `dev` → `main`; never base a branch on `main`, never push directly to `main`. When an AI assistant operates from a worktree and needs to fast-forward `dev`, use `git push . HEAD:dev` (the repo has `receive.denyCurrentBranch=updateInstead` set, so git syncs the main worktree's files too when it's clean) rather than `git update-ref`, which leaves the main worktree's files out of sync with HEAD. - **Do not commit without explicit user approval.** The flow is: AI edits → user reviews → user tests → user says "commit" → AI commits. Silent commits are a breach of trust. - **One concern per commit.** Don't mix unrelated changes into a single commit. Split at the right seam. - **Self-test before handing off.** Run `npm run check` (or at least `npm test`) for every change. If the change is visual (UI layout, styling, interactions) and can't be verified headless, say so explicitly so the user can test it in `npm run dev`. diff --git a/README.md b/README.md index 36df1a612..efdeb08d5 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ Download `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` from your MaxMind account | `SECURITY_RATE_LIMIT` | No | `"0"` | Controls the number of requests an IP can make to the backend server every 60 minutes (set to 0 for no limit) | | `SECURITY_DELAY_AFTER` | No | `"0"` | Controls the first X requests from an IP every 20 minutes that are not subject to speed limits, and after X requests, the delay will increase | | `SECURITY_BLACKLIST_LOG_FILE_PATH` | No | `"logs/blacklist-ip.log"` | Path setting. Records the list of IPs that triggered the limit after SECURITY_RATE_LIMIT is enabled | +| `LOG_LEVEL` | No | `"info"` | Minimum log level (`debug` / `info` / `warn` / `error`). Lower-level messages are suppressed. | +| `LOG_FORMAT` | No | pretty | Set to `"json"` to emit one JSON event per line (for log aggregators / jq). Any other value (or unset) keeps the colored pretty output used in dev and pm2 log tails. | +| `LOG_HTTP` | No | `"false"` | Set to `"true"` to enable per-request HTTP logging on `/api/*` (method, URL, status, response time). Off by default to keep pm2 logs lean. Handler-level 4xx/5xx errors are always logged regardless of this flag. | | `ALLOWED_DOMAINS` | No | `""` | Allowed domains for access, separated by commas, used to prevent misuse of the backend API | | `GOOGLE_MAP_API_KEY` | No | `""` | API Key for Google Maps, used to display the location of the IP on a map | | `IPCHECKING_API_ENDPOINT` | No | `""` | API endpoint for IPCheck.ing database, used to obtain accurate IP geolocation information | diff --git a/README_FR.md b/README_FR.md index 4421c113c..1b31bf70f 100644 --- a/README_FR.md +++ b/README_FR.md @@ -131,6 +131,9 @@ Téléchargez `GeoLite2-City.mmdb` et `GeoLite2-ASN.mmdb` depuis votre compte Ma | `SECURITY_RATE_LIMIT` | Non | `"0"` | Contrôle le nombre de requêtes qu'une adresse IP peut faire au serveur backend toutes les 60 minutes (réglé sur 0 pour aucune limite) | | `SECURITY_DELAY_AFTER` | Non | `"0"` | Contrôle les premières X requêtes d'une adresse IP toutes les 20 minutes qui ne sont pas soumises à des limites de vitesse, et après X requêtes, le délai augmentera | | `SECURITY_BLACKLIST_LOG_FILE_PATH` | Non | `"logs/blacklist-ip.log"` | Paramètre de chemin. Enregistre la liste des adresses IP qui ont déclenché la limite après que `SECURITY_RATE_LIMIT` soit activé | +| `LOG_LEVEL` | Non | `"info"` | Niveau minimum des journaux (`debug` / `info` / `warn` / `error`). Les messages de niveau inférieur sont supprimés. | +| `LOG_FORMAT` | Non | pretty | Définir sur `"json"` pour émettre un événement JSON par ligne (agrégateurs de logs / jq). Toute autre valeur (ou non défini) conserve la sortie colorée lisible utilisée en dev et lors du tail des logs pm2. | +| `LOG_HTTP` | Non | `"false"` | Définir sur `"true"` pour activer la journalisation par requête HTTP sur `/api/*` (méthode, URL, statut, temps de réponse). Désactivé par défaut pour garder les logs pm2 légers. Les erreurs 4xx/5xx côté handler sont toujours loguées, que ce drapeau soit activé ou non. | | `ALLOWED_DOMAINS` | Non | `""` | Domaines autorisés pour l'accès, séparés par des virgules, utilisés pour empêcher une utilisation abusive de l'API backend | | `GOOGLE_MAP_API_KEY` | Non | `""` | Clé API pour Google Maps, utilisée pour afficher l'emplacement de l'adresse IP sur une carte | | `IPCHECKING_API_ENDPOINT` | Non | `""` | endpoint de l'API pour IPCheck.ing database, utilisée pour obtenir des informations de géolocalisation précises sur l'adresse IP | diff --git a/README_TR.md b/README_TR.md index c49155e81..9406e8d29 100644 --- a/README_TR.md +++ b/README_TR.md @@ -131,6 +131,9 @@ MaxMind hesabınızdan `GeoLite2-City.mmdb` ve `GeoLite2-ASN.mmdb` dosyalarını | `SECURITY_RATE_LIMIT` | Hayır | `"0"` | Bir IP'nin backend sunucusuna 60 dakikada yapabileceği istek sayısını kontrol eder (sınır yok için 0) | | `SECURITY_DELAY_AFTER` | Hayır | `"0"` | 20 dakikada bir IP'den gelen ilk X isteğin hız sınırına tabi olmadığını kontrol eder; X'ten sonra gecikme artar | | `SECURITY_BLACKLIST_LOG_FILE_PATH` | Hayır | `"logs/blacklist-ip.log"` | Yol ayarı. SECURITY_RATE_LIMIT etkinleştirildiğinde limit tetikleyen IP'leri kaydeder | +| `LOG_LEVEL` | Hayır | `"info"` | Minimum log seviyesi (`debug` / `info` / `warn` / `error`). Daha düşük seviyedeki mesajlar bastırılır. | +| `LOG_FORMAT` | Hayır | pretty | `"json"` olarak ayarlandığında satır başına bir JSON olayı çıkarır (log toplayıcılar / jq için). Diğer değerler (veya ayarlanmamışsa) dev ortamında ve pm2 log tail sırasında kullanılan renkli güzel biçimli çıktıyı korur. | +| `LOG_HTTP` | Hayır | `"false"` | `"true"` yapıldığında `/api/*` üzerinde istek başı HTTP loglamasını etkinleştirir (metod, URL, durum, yanıt süresi). pm2 loglarını küçük tutmak için varsayılan olarak kapalıdır. Bu bayrak kapalı olsa bile handler düzeyindeki 4xx/5xx hataları her zaman loglanır. | | `ALLOWED_DOMAINS` | Hayır | `""` | Erişime izin verilen alan adları, virgülle ayrılmış; backend API kötüye kullanımını önlemek için kullanılır | | `GOOGLE_MAP_API_KEY` | Hayır | `""` | IP'nin konumunu haritada göstermek için Google Maps API Anahtarı | | `IPCHECKING_API_ENDPOINT` | Hayır | `""` | IPCheck.ing veritabanı API uç noktası, doğru IP konum bilgisi almak için | diff --git a/README_ZH.md b/README_ZH.md index 0a2341f54..e4d8f8e9b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -131,6 +131,9 @@ MyIP 依赖 MaxMind 提供的免费 **GeoLite2** 数据库(City + ASN)来进 | `SECURITY_RATE_LIMIT` | 否 | `"0"` | 控制每 60 分钟一个 IP 可以对后端服务器请求的次数(设置为 0 则为不限制) | | `SECURITY_DELAY_AFTER` | 否 | `"0"` | 控制每 20 分钟一个 IP 的前 X 次请求不受速度限制,超过 X 次后会逐次增加延迟 | | `SECURITY_BLACKLIST_LOG_FILE_PATH` | 否 | `"logs/blacklist-ip.log"` | 路径设置。记录由 SECURITY_RATE_LIMIT 开启后,触发限制的 IP 列表 | +| `LOG_LEVEL` | 否 | `"info"` | 日志最低级别(`debug` / `info` / `warn` / `error`),低于该级别的日志会被过滤 | +| `LOG_FORMAT` | 否 | pretty | 设置为 `"json"` 时每行输出一个 JSON 事件(给日志聚合器 / jq 使用);其它值(或未设置)则使用带颜色的 pretty 格式,适合开发及 pm2 log tail | +| `LOG_HTTP` | 否 | `"false"` | 设置为 `"true"` 时启用 `/api/*` 的按请求日志(方法、URL、状态码、响应时间)。默认关闭以控制 pm2 日志体积。即使关闭,handler 层的 4xx/5xx 错误日志依然会被记录 | | `ALLOWED_DOMAINS` | 否 | `""` | 允许访问的域名,用逗号分隔,用于防止后端 API 被滥用 | | `GOOGLE_MAP_API_KEY` | 否 | `""` | Google 地图的 API Key,用于展示 IP 所在地的地图 | | `IPCHECKING_API_ENDPOINT` | 否 | `""` | IPCheck.ing 数据库的 API 端点 URL | diff --git a/api/AGENTS.md b/api/AGENTS.md index 783fa7f46..96b2ff1a6 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -22,19 +22,25 @@ api/ │ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source) ├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint ├── mac-checker.js ← /api/macchecker — MAC vendor lookup -├── get-whois.js ← /api/whois — whoiser wrapper +├── get-whois.js ← /api/whois — whoiser primary + RDAP fallback for new gTLDs ├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar ├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query +├── dns-leak-test.js ← /api/dnsleaktest/session/:token — proxy to private +│ IPCheck.ing endpoint (Firebase-gated) that drives the +│ in-depth DNS Leak Test advanced tool ├── get-user-info.js ← /api/getuserinfo — user-profile proxy └── update-user-achievement.js ← /api/updateuserachievement — user-achievement proxy common/ ├── fetch-with-timeout.js ← fetchWithTimeout (5s default) + fetchUpstream (8s preset) ├── guards.js ← requireReferer + requireValidIP Express middleware +├── logger.js ← shared pino logger (pretty in dev, JSON in prod) ├── referer-check.js ← low-level referer allow-list check ├── valid-ip.js ← IPv4 / IPv6 validator (also re-exported from frontend) +├── rdap.js ← RDAP client (domain fallback when whoiser returns no __raw) ├── maxmind-service.js ← mmdb reader + lookup -├── maxmind-updater.js ← scheduled mmdb auto-update +├── maxmind-updater.js ← mmdb bootstrap download at boot + +│ scheduled auto-update └── maxmind-db/ ← GeoLite2-ASN.mmdb + GeoLite2-City.mmdb ``` @@ -54,6 +60,7 @@ common/ Default timeout is 8s. Never add a bare `fetch()` or `https.get()` — if a provider hangs, the Express connection should time out, not pin indefinitely. - **Error shape.** `res.status(500).json({ error: error.message })` on upstream failures; `res.status(400).json({ error: '…' })` on bad input. Be terse — the frontend doesn't display these error strings verbatim. - **Response shape.** IP-geolocation handlers normalize their upstream's response into the canonical shape consumed by the frontend (`ip` / `country` / `country_name` / `country_code` / `latitude` / `longitude` / `asn` / `org` / …). If you add a new source, match the existing shape. +- **Logging.** `import logger from '../common/logger.js'` and use `logger.error({ err: error, ...context }, 'short message')` on upstream failures, never bare `console.*` (banned project-wide for backend code — see root AGENTS.md "Logging"). The `pino-http` middleware mounted on `/api` already records the request line + status + response time, so handlers should only log domain-specific events / errors, not "received request" lines. ## Security & Boundaries @@ -65,7 +72,7 @@ common/ ### Private-API header pass-through (intentional exception) -Handlers that call our own private IPCheck.ing API (`ipcheck-ing.js`, `invisibility-test.js`, `update-user-achievement.js`, `get-user-info.js`) forward the caller's request headers to the upstream: +Handlers that call our own private IPCheck.ing API (`ipcheck-ing.js`, `invisibility-test.js`, `update-user-achievement.js`, `get-user-info.js`, `dns-leak-test.js`) forward the caller's request headers to the upstream: ```js const apiResponse = await fetchUpstream(url, { headers: { ...req.headers } }); diff --git a/api/cf-radar.js b/api/cf-radar.js index 323f544db..b8d88a51c 100644 --- a/api/cf-radar.js +++ b/api/cf-radar.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; // Common fetch request function async function fetchFromCloudflare(endpoint) { @@ -17,7 +18,7 @@ async function getASNInfo(asn) { try { return await fetchFromCloudflare(`/radar/entities/asns/${asn}`); } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch ASN info'); throw new Error('Failed to fetch ASN info'); } }; @@ -27,7 +28,7 @@ async function getASNIPVersion(asn) { try { return await fetchFromCloudflare(`/radar/http/summary/ip_version?asn=${asn}&dateRange=7d`); } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch ASN IP version'); throw new Error('Failed to fetch ASN IP version'); } }; @@ -37,7 +38,7 @@ async function getASNHTTPProtocol(asn) { try { return await fetchFromCloudflare(`/radar/http/summary/http_protocol?asn=${asn}&dateRange=7d`); } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch ASN HTTP protocol'); throw new Error('Failed to fetch ASN HTTP protocol'); } }; @@ -47,7 +48,7 @@ async function getASNDeviceType(asn) { try { return await fetchFromCloudflare(`/radar/http/summary/device_type?asn=${asn}&dateRange=7d`); } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch ASN device type'); throw new Error('Failed to fetch ASN device type'); } }; @@ -57,7 +58,7 @@ async function getASNBotType(asn) { try { return await fetchFromCloudflare(`/radar/http/summary/bot_class?asn=${asn}&dateRange=7d`); } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch ASN bot type'); throw new Error('Failed to fetch ASN bot type'); } }; @@ -74,7 +75,7 @@ async function getAllASNData(asn) { ]); return { asnInfo, ipVersion, httpProtocol, deviceType, botType }; } catch (error) { - console.error(error); + logger.error({ err: error }, 'Failed to fetch all ASN data'); throw new Error('Failed to fetch all ASN data'); } } @@ -155,7 +156,7 @@ export default async (req, res) => { res.json(finalResponse); } catch (error) { - console.error(error); + logger.error({ err: error, asn }, 'cf-radar handler failed'); res.status(500).json({ error: 'Internal server error' }); } } \ No newline at end of file diff --git a/api/dns-leak-test.js b/api/dns-leak-test.js new file mode 100644 index 000000000..080346db5 --- /dev/null +++ b/api/dns-leak-test.js @@ -0,0 +1,54 @@ +// Enhanced DNS leak detection — thin proxy to the main IPCheck.ing API. +// +// - validates the 32-hex token locally (fail fast before any network hop), +// - forwards request headers (notably Authorization: Bearer ), +// - attaches the apikey query param, +// - 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'; + +const TOKEN_RE = /^[0-9a-f]{32}$/; +const SUPPORTED_LANGS = ['zh-CN', 'en', 'fr', 'tr']; + +export async function getSessionResult(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + const token = req.params?.token; + if (!token || !TOKEN_RE.test(token)) { + return res.status(400).json({ error: 'Invalid token' }); + } + + 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 lang = SUPPORTED_LANGS.includes(req.query.lang) ? req.query.lang : 'zh-CN'; + + const url = new URL(`${apiEndpoint}/dnsleaktest/session/${token}`); + url.searchParams.set('apikey', apiKey); + url.searchParams.set('lang', lang); + + try { + const apiResponse = await fetchUpstream(url, { + headers: { ...req.headers }, + }); + + // Parse as JSON if we can, otherwise just keep an empty object so the + // client still gets a proper status code. + const data = await apiResponse.json().catch(() => ({})); + + res.set('Cache-Control', 'no-store'); + res.status(apiResponse.status).json(data); + } catch (error) { + logger.error({ err: error }, 'dnsleaktest upstream fetch failed'); + res.status(502).json({ error: 'Upstream fetch failed', detail: error.message }); + } +} diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 6c87e3d2d..f787a1651 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -1,6 +1,15 @@ // api/dnsresolver.js import { Resolver } from 'dns'; import { promisify } from 'util'; +import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; + +// Bound each upstream lookup so the slowest server doesn't pin the +// overall response. 3s for UDP DNS (`Resolver` rejects on first +// timeout because `tries: 1`); 5s for DoH via fetchUpstream's per-call +// override. +const DNS_TIMEOUT_MS = 3000; +const DOH_TIMEOUT_MS = 5000; // Normal DNS server list const dnsServers = { @@ -26,7 +35,7 @@ const dohServers = { }; const resolveDns = async (hostname, type, name, server) => { - const resolver = new Resolver(); + 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)); @@ -71,14 +80,18 @@ const resolveDns = async (hostname, type, name, server) => { return { [name]: addresses }; } catch (error) { - console.log(error.message); + // 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` }; } }; const resolveDoh = async (hostname, type, name, url) => { try { - const response = await fetch(`${url}name=${hostname}&type=${type}`, { + const response = await fetchUpstream(`${url}name=${hostname}&type=${type}`, { + timeoutMs: DOH_TIMEOUT_MS, headers: { 'Accept': 'application/dns-json' } }); const data = await response.json(); @@ -88,7 +101,7 @@ const resolveDoh = async (hostname, type, name, url) => { } return { [name]: addresses }; } catch (error) { - console.log(error.message); + logger.debug({ err: error, server: name }, 'DoH resolver: lookup failed, returning N/A'); return { [name]: `N/A` }; } }; diff --git a/api/get-user-info.js b/api/get-user-info.js index 732f0d4a7..e158cd084 100644 --- a/api/get-user-info.js +++ b/api/get-user-info.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { const key = process.env.IPCHECKING_API_KEY; @@ -25,7 +26,7 @@ export default async (req, res) => { const data = await apiResponse.json(); res.json(data); } catch (error) { - console.error("Error during API request:", error); + logger.error({ err: error }, 'get-user-info upstream request failed'); res.status(500).json({ error: error.message }); } } \ No newline at end of file diff --git a/api/get-whois.js b/api/get-whois.js index 93a82308a..4037b3b9d 100644 --- a/api/get-whois.js +++ b/api/get-whois.js @@ -1,35 +1,68 @@ import whoiser from 'whoiser'; import { isValidIP } from '../common/valid-ip.js'; +import { rdapDomain } from '../common/rdap.js'; +import logger from '../common/logger.js'; function isValidDomain(domain) { const domainPattern = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i; return domainPattern.test(domain); } +// A whoiser.domain() response is considered usable if at least one of +// its WHOIS-server keys carries a non-empty `__raw` text block. An +// empty object, or one with only metadata but no raw text, means the +// TLD doesn't have a reachable port-43 server (common for newer gTLDs +// like .ing / .app) — that's when we fall back to RDAP. +function domainHasWhoisText(result) { + if (!result || typeof result !== 'object') return false; + return Object.values(result).some( + (v) => v && typeof v === 'object' && typeof v.__raw === 'string' && v.__raw.length > 0, + ); +} + export default async (req, res) => { const query = req.query.q; if (!query) { return res.status(400).json({ error: 'No address provided' }); } - - // Check if address is valid IP or domain if (!isValidIP(query) && !isValidDomain(query)) { return res.status(400).json({ error: 'Invalid IP or address' }); } if (isValidIP(query)) { try { - const ipinfo = await whoiser.ip(query, { timeout: 5000,raw: true}); - res.json(ipinfo); - } catch (e) { - res.status(500).json({ error: e.message }); - } - } else { - try { - const domaininfo = await whoiser.domain(query, { ignorePrivacy: false, timeout: 5000, follow: 2,raw: true}); - res.json(domaininfo); + const ipinfo = await whoiser.ip(query, { timeout: 5000, raw: true }); + return res.json(ipinfo); } catch (e) { - res.status(500).json({ error: e.message }); + logger.error({ err: e, query }, 'Failed to get IP info'); + return res.status(500).json({ error: e.message }); } } -}; \ No newline at end of file + + // Domain path: whoiser first (rich port-43 data for legacy gTLDs), + // fall back to RDAP only when whoiser returned nothing useful. + let domaininfo = null; + try { + domaininfo = await whoiser.domain(query, { + ignorePrivacy: false, + timeout: 5000, + follow: 2, + raw: true, + }); + } catch { + // Swallow — we'll attempt RDAP next; only bubble up if that + // fails too. + } + + if (domainHasWhoisText(domaininfo)) { + return res.json(domaininfo); + } + + try { + const rdap = await rdapDomain(query); + return res.json(rdap); + } catch (e) { + logger.error({ err: e, query }, 'Failed to get RDAP info'); + return res.status(500).json({ error: e.message }); + } +}; diff --git a/api/google-map.js b/api/google-map.js index c12730f1f..8498560b9 100644 --- a/api/google-map.js +++ b/api/google-map.js @@ -1,5 +1,6 @@ import { Readable } from 'node:stream'; import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; // Validate request legitimacy function isValidRequest(req) { @@ -80,6 +81,7 @@ export default async (req, res) => { res.setHeader('Content-Type', apiRes.headers.get('content-type') || 'image/jpeg'); Readable.fromWeb(apiRes.body).pipe(res); } catch (e) { + logger.error({ err: e, latitude, longitude, language, CanvasMode }, 'google-map handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/invisibility-test.js b/api/invisibility-test.js index a62205406..99e3d8c4b 100644 --- a/api/invisibility-test.js +++ b/api/invisibility-test.js @@ -1,15 +1,10 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; // If length is not 28 and is not a combination of letters and numbers, return false function isValidUserID(userID) { - if (typeof userID !== 'string') { - console.error("Invalid type for userID"); - return false; - } - if (userID.length !== 28 || !/^[a-zA-Z0-9]+$/.test(userID)) { - console.error("Invalid userID format"); - return false; - } + if (typeof userID !== 'string') return false; + if (userID.length !== 28 || !/^[a-zA-Z0-9]+$/.test(userID)) return false; return true; } @@ -55,7 +50,7 @@ export default async (req, res) => { const data = await apiResponse.json(); res.json(data); } catch (error) { - console.error("Error during API request:", error); + logger.error({ err: error }, 'invisibility-test upstream request failed'); res.status(500).json({ error: error.message }); } diff --git a/api/ip-sb.js b/api/ip-sb.js index 90a423348..b2d91ff95 100644 --- a/api/ip-sb.js +++ b/api/ip-sb.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -11,6 +12,7 @@ export default async (req, res) => { const json = await apiRes.json(); res.json(modifyJsonForIPSB(json)); } catch (e) { + logger.error({ err: e, ip: ipAddress }, 'ip-sb handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/ip2location-io.js b/api/ip2location-io.js index 859e00013..66113d35a 100644 --- a/api/ip2location-io.js +++ b/api/ip2location-io.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -13,6 +14,7 @@ export default async (req, res) => { const json = await apiRes.json(); res.json(modifyJsonForIPAPI(json)); } catch (e) { + logger.error({ err: e, ip: ipAddress }, 'ip2location-io handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/ipapi-com.js b/api/ipapi-com.js index 43ed83ba4..139f08c21 100644 --- a/api/ipapi-com.js +++ b/api/ipapi-com.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -13,6 +14,7 @@ export default async (req, res) => { const json = await apiRes.json(); res.json(modifyJsonForIPAPI(json)); } catch (e) { + logger.error({ err: e, ip: ipAddress, lang }, 'ipapi-com handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/ipapi-is.js b/api/ipapi-is.js index 1898cf12e..534401842 100644 --- a/api/ipapi-is.js +++ b/api/ipapi-is.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -13,6 +14,7 @@ export default async (req, res) => { const json = await apiRes.json(); res.json(modifyJsonForIPAPI(json)); } catch (e) { + logger.error({ err: e, ip: ipAddress }, 'ipapi-is handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/ipcheck-ing.js b/api/ipcheck-ing.js index 462d8aa6a..2c65c8383 100644 --- a/api/ipcheck-ing.js +++ b/api/ipcheck-ing.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -29,7 +30,7 @@ export default async (req, res) => { const data = await apiResponse.json(); res.json(data); } catch (error) { - console.error("Error during API request:", error); + logger.error({ err: error, ip: ipAddress, lang }, 'ipcheck-ing handler failed'); res.status(500).json({ error: error.message }); } } \ No newline at end of file diff --git a/api/ipinfo-io.js b/api/ipinfo-io.js index 6030bc96c..57802e049 100644 --- a/api/ipinfo-io.js +++ b/api/ipinfo-io.js @@ -1,5 +1,6 @@ import countryLookup from 'country-code-lookup'; import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -18,6 +19,7 @@ export default async (req, res) => { const json = await apiRes.json(); res.json(modifyJson(json)); } catch (e) { + logger.error({ err: e, ip: ipAddress }, 'ipinfo-io handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/mac-checker.js b/api/mac-checker.js index b7cbc04fa..70a3bd863 100644 --- a/api/mac-checker.js +++ b/api/mac-checker.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; // A canonical MAC address is 48 bits = 12 hex chars. Accepting shorter // strings let the upstream API receive partial prefixes and return @@ -37,6 +38,7 @@ export default async (req, res) => { } res.json(modifyData(json)); } catch (e) { + logger.error({ err: e, mac: macAddress }, 'mac-checker handler failed'); res.status(500).json({ error: e.message }); } }; diff --git a/api/maxmind.js b/api/maxmind.js index 997ff6787..ed1951c9e 100644 --- a/api/maxmind.js +++ b/api/maxmind.js @@ -1,4 +1,5 @@ import { lookupMaxMind } from '../common/maxmind-service.js'; +import logger from '../common/logger.js'; export default (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. @@ -11,6 +12,7 @@ export default (req, res) => { try { res.json(lookupMaxMind(ip, lang)); } catch (e) { + logger.error({ err: e, ip, lang }, 'maxmind handler failed'); res.status(e.statusCode || 500).json({ error: e.message }); } } diff --git a/api/update-user-achievement.js b/api/update-user-achievement.js index 62d832a99..e8fb7a24b 100644 --- a/api/update-user-achievement.js +++ b/api/update-user-achievement.js @@ -1,4 +1,5 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; export default async (req, res) => { // defensive; app.put() in backend-server.js already gates method @@ -37,7 +38,7 @@ export default async (req, res) => { const data = await apiResponse.json(); res.json(data); } catch (error) { - console.error("Error during API request:", error); + logger.error({ err: error }, 'update-user-achievement upstream request failed'); res.status(500).json({ error: error.message }); } diff --git a/backend-server.js b/backend-server.js index b6efe880f..31ba7f760 100644 --- a/backend-server.js +++ b/backend-server.js @@ -5,6 +5,8 @@ import fs from 'fs'; 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, requireValidIP } from './common/guards.js'; // Backend APIs @@ -20,6 +22,7 @@ import maxmindHandler from './api/maxmind.js'; // Others import cfHander from './api/cf-radar.js'; import dnsResolver from './api/dns-resolver.js'; +import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js'; import getWhois from './api/get-whois.js'; import invisibilitytestHandler from './api/invisibility-test.js'; import macChecker from './api/mac-checker.js'; @@ -28,18 +31,9 @@ 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 } from './common/maxmind-updater.js'; +import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js'; -dotenv.config(); - -// Load the bundled MaxMind databases during startup without blocking the server boot. -reloadMaxMindDatabases('startup').catch(() => { - console.error('MaxMind API will return 503 until databases are loaded successfully'); -}); -// Watch database file changes so pm2 or another process can publish updates safely. -startMaxMindFileWatcher(); -// Schedule credential-gated MaxMind updates when MAXMIND_AUTO_UPDATE is enabled. -startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases }); +dotenv.config({ quiet: true }); const app = express(); const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10); @@ -49,6 +43,27 @@ const speedLimitSet = parseInt(process.env.SECURITY_DELAY_AFTER || 0, 10); app.set('trust proxy', 1); +// HTTP request logging on /api/* — off by default to keep pm2 logs lean. +// Set LOG_HTTP=true in .env to enable. Mounted before the rate limiter +// so 429s are also logged when enabled. +if (process.env.LOG_HTTP === 'true') { + app.use('/api', pinoHttp({ + logger, + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + customSuccessMessage: (req, res) => `${req.method} ${req.url} → ${res.statusCode}`, + customErrorMessage: (req, res, err) => `${req.method} ${req.url} → ${res.statusCode}: ${err.message}`, + serializers: { + req: (req) => ({ method: req.method, url: req.url }), + res: (res) => ({ statusCode: res.statusCode }), + }, + })); + logger.info('📝 HTTP request logging enabled (LOG_HTTP=true)'); +} + // Helper function to get client IP function getClientIp(req) { const cfIp = req.headers['cf-connecting-ip']; // Cloudflare IP @@ -70,13 +85,13 @@ function logLimitedIP(ip) { const logDir = path.dirname(logPath); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); - console.log('Created log directory:', logDir); + logger.info({ logDir }, 'Created log directory'); } // Read log file, update IP count, create new log file if it does not exist fs.readFile(logPath, 'utf8', (err, data) => { if (err && err.code !== 'ENOENT') { - console.error('Error reading the log file:', err); + logger.error({ err }, 'Error reading the log file'); return; } @@ -92,7 +107,7 @@ function logLimitedIP(ip) { if (currentIp === ip) { newCount = parseInt(count, 10) + 1; logExists = true; - console.log(`IP ${ip} has been limited ${newCount} times`); + logger.warn({ ip, count: newCount }, 'Rate-limited IP hit again'); return `${ip},${newCount},${timestamp}`; // Update count but keep the original timestamp } return line; @@ -102,12 +117,12 @@ function logLimitedIP(ip) { if (!logExists) { const newLine = `${ip},${newCount},${formatDate(now)}`; updatedData += (updatedData ? '\n' : '') + newLine; - console.log(`IP ${ip} has been limited for the first time`); + logger.warn({ ip }, 'IP rate-limited for the first time'); } fs.writeFile(logPath, updatedData, 'utf8', err => { if (err) { - console.error('Failed to write to log file:', err); + logger.error({ err }, 'Failed to write to log file'); } }); }); @@ -137,13 +152,13 @@ const speedLimiter = slowDown({ // If rateLimitSet is 0, do not enable rate limiting if (rateLimitSet !== 0) { app.use('/api', rateLimiter); - console.log('Rate limiter is enabled, limit:', rateLimitSet, 'requests per 60 minutes'); + logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 60 minutes`); } // If delayAfter is 0, do not enable delay if (speedLimitSet !== 0) { app.use('/api', speedLimiter); - console.log('Speed limiter is enabled, slowing down after:', speedLimitSet, 'requests'); + logger.info(`🐢 Speed limiter enabled — slow down after ${speedLimitSet} requests`); } app.use(express.json()); @@ -161,6 +176,7 @@ app.get('/api/ipchecking', requireValidIP(), ipCheckingHandler); app.get('/api/ipsb', requireValidIP(), ipsbHandler); app.get('/api/cfradar', cfHander); app.get('/api/dnsresolver', dnsResolver); +app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult); app.get('/api/whois', getWhois); app.get('/api/ipapiis', requireValidIP(), ipapiisHandler); app.get('/api/ip2location', requireValidIP(), ip2locationHandler); @@ -179,8 +195,32 @@ const __dirname = path.dirname(__filename); app.use(express.static(path.join(__dirname, './dist'))); -// Start server -app.listen(backEndPort, () => { - // Output listening address, for local running and process manager log troubleshooting - console.log(`Backend server running on http://localhost:${backEndPort}`); -}); +// Bootstrap the MaxMind layer before accepting traffic. The sequence is: +// 1. bootstrapMaxMindIfMissing — if the .mmdb files are absent and +// credentials are configured, download them synchronously (capped at +// 5 min). Never throws; logs a warning and moves on if it can't. +// 2. reloadMaxMindDatabases — load readers into memory. Also non-fatal; +// if the files still aren't there, MaxMind API will return 503. +// 3. startMaxMindFileWatcher — pick up files that arrive later (manual +// drop-in or a background process publishing updates). +// 4. startMaxMindAutoUpdate — schedule credential-gated periodic updates +// when MAXMIND_AUTO_UPDATE is enabled. +// 5. app.listen — only after all of the above, so the server never accepts +// requests mid-download and log order stays readable. +async function bootBackend() { + await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases }); + + await reloadMaxMindDatabases('startup').catch(() => { + logger.error('❌ MaxMind API will return 503 until databases are loaded successfully'); + }); + + startMaxMindFileWatcher(); + startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases }); + + app.listen(backEndPort, () => { + // Output listening address, for local running and process manager log troubleshooting + logger.info(`🚀 Backend server ready on http://localhost:${backEndPort}`); + }); +} + +bootBackend(); diff --git a/common/logger.js b/common/logger.js new file mode 100644 index 000000000..d3b201c0b --- /dev/null +++ b/common/logger.js @@ -0,0 +1,32 @@ +// common/logger.js — shared pino logger for all backend code. +// +// LOG_LEVEL defaults to 'warn'; LOG_FORMAT=json switches off pino-pretty +// for log shippers. No NODE_ENV dependency. +// +// ES module imports are hoisted, so backend-server.js's dotenv.config() +// runs after this file — we load .env ourselves here so LOG_LEVEL is +// honored. dotenv is idempotent; `quiet: true` avoids double banners. + +import dotenv from 'dotenv'; +import pino from 'pino'; + +dotenv.config({ quiet: true }); + +const useJson = process.env.LOG_FORMAT === 'json'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + ...(useJson ? {} : { + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss.l', + ignore: 'pid,hostname', + singleLine: true, + }, + }, + }), +}); + +export default logger; diff --git a/common/maxmind-service.js b/common/maxmind-service.js index 09ef3f30b..a7411dca7 100644 --- a/common/maxmind-service.js +++ b/common/maxmind-service.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import maxmind from 'maxmind'; +import logger from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -61,11 +62,11 @@ export async function reloadMaxMindDatabases(reason = 'manual') { .then(({ city, asn }) => { cityLookup = city; asnLookup = asn; - console.log(`MaxMind databases loaded (${reason})`); + logger.info(`📦 MaxMind databases loaded (${reason})`); return true; }) .catch(error => { - console.error(`Failed to load MaxMind databases (${reason}):`, error.message); + logger.error({ err: error, reason }, 'Failed to load MaxMind databases'); throw error; }) .finally(() => { diff --git a/common/maxmind-updater.js b/common/maxmind-updater.js index 0636b47be..64864d5e5 100644 --- a/common/maxmind-updater.js +++ b/common/maxmind-updater.js @@ -6,6 +6,7 @@ import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; import * as tar from 'tar'; import maxmind from 'maxmind'; +import logger from './logger.js'; import { getMaxMindDbPaths, MAXMIND_ASN_DB, @@ -15,6 +16,12 @@ import { 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 = [ @@ -52,19 +59,19 @@ export function startMaxMindAutoUpdate({ reload } = {}) { } if (!isAutoUpdateEnabled()) { - console.log('MaxMind auto update plan: disabled'); + logger.info('MaxMind auto update plan: disabled'); return; } if (!hasDownloadCredentials()) { - console.log('MaxMind auto update skipped: MAXMIND_ACCOUNT_ID or MAXMIND_LICENSE_KEY is missing'); + 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. const run = () => { updateMaxMindDatabases({ reload }).catch(error => { - console.error('MaxMind auto update failed:', error.message); + logger.error({ err: error }, 'MaxMind auto update failed'); }); }; @@ -77,10 +84,86 @@ export function startMaxMindAutoUpdate({ reload } = {}) { 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 } = {}) { +export async function updateMaxMindDatabases({ reload, signal, reloadReason = 'auto update' } = {}) { if (updateInProgress) { return { updated: false, reason: 'already-running' }; } @@ -99,10 +182,10 @@ export async function updateMaxMindDatabases({ reload } = {}) { const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'myip-maxmind-')); try { - const result = await downloadAndReplaceDatabases(dbDir, tempDir); + const result = await downloadAndReplaceDatabases(dbDir, tempDir, { signal }); if (result.updated && reload) { - await reload('auto update'); + await reload(reloadReason); } return result; @@ -139,7 +222,7 @@ function hasAnyUpdateEnvironment() { */ function logUpdatePlan() { const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS); - console.log(`MaxMind auto update plan: next check at ${formatScheduleTime(nextRunAt)}, then every ${formatDuration(UPDATE_INTERVAL_MS)}`); + logger.info(`🗓️ MaxMind auto update plan: next check at ${formatScheduleTime(nextRunAt)}, then every ${formatDuration(UPDATE_INTERVAL_MS)}`); } /** @@ -169,19 +252,19 @@ function formatDuration(milliseconds) { /** * Download all required newer editions, validate them, then publish them together. */ -async function downloadAndReplaceDatabases(dbDir, tempDir) { +async function downloadAndReplaceDatabases(dbDir, tempDir, { signal } = {}) { const state = await readUpdateState(dbDir); const plannedUpdates = []; for (const edition of editions) { - const remoteInfo = await getRemoteInfo(edition); + 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); + const downloadedPath = await downloadAndExtractDatabase(edition, tempDir, { signal }); plannedUpdates.push({ ...edition, lastModified: remoteInfo.lastModified, @@ -206,7 +289,7 @@ async function downloadAndReplaceDatabases(dbDir, tempDir) { } await writeUpdateState(dbDir, state); - console.log(`MaxMind databases updated: ${plannedUpdates.map(update => update.editionId).join(', ')}`); + logger.info({ editions: plannedUpdates.map(update => update.editionId) }, 'MaxMind databases updated'); return { updated: true, @@ -217,11 +300,12 @@ async function downloadAndReplaceDatabases(dbDir, tempDir) { /** * Fetch remote metadata for a MaxMind edition without downloading the archive. */ -async function getRemoteInfo(edition) { +async function getRemoteInfo(edition, { signal } = {}) { const response = await fetch(getDownloadUrl(edition.editionId), { method: 'HEAD', headers: getAuthHeaders(), redirect: 'follow', + signal, }); if (!response.ok) { @@ -261,7 +345,7 @@ function shouldDownloadDatabase(targetPath, state, editionId, lastModified) { /** * Download a MaxMind archive, extract it, and return the matching .mmdb file path. */ -async function downloadAndExtractDatabase(edition, tempDir) { +async function downloadAndExtractDatabase(edition, tempDir, { signal } = {}) { const editionDir = path.join(tempDir, edition.editionId); const archivePath = path.join(tempDir, `${edition.editionId}.tar.gz`); @@ -270,13 +354,14 @@ async function downloadAndExtractDatabase(edition, tempDir) { 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)); + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal }); await tar.x({ file: archivePath, cwd: editionDir }); const mmdbPath = await findExtractedDatabase(editionDir, edition.fileName); @@ -434,7 +519,7 @@ async function acquireUpdateLock(dbDir) { return acquireUpdateLock(dbDir); } - console.log('MaxMind update skipped: another process is updating databases'); + logger.info('MaxMind update skipped: another process is updating databases'); return null; } } diff --git a/common/rdap.js b/common/rdap.js new file mode 100644 index 000000000..dad9ca227 --- /dev/null +++ b/common/rdap.js @@ -0,0 +1,157 @@ +// RDAP fallback for domain WHOIS. whoiser covers legacy gTLDs well +// through port 43, but newer ones (.ing / .app / .dev / …) expose RDAP +// only and return no WHOIS at all — this module fills that gap. +// +// Public API: +// rdapDomain(name) → { [host]: { __raw, ...rdapJson } } +// Same outer shape as whoiser.domain() so the handler can splice +// the result in without any frontend change. +// +// Bootstrap (IANA's TLD → RDAP endpoint map) is cached in-memory for +// 24h. Upstream calls go through `fetchUpstream` so they inherit the +// project's timeout convention. + +import { fetchUpstream } from './fetch-with-timeout.js'; +import logger from './logger.js'; + +const BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +let bootstrapCache = null; // { data, expiresAt } + +async function loadBootstrap() { + if (bootstrapCache && bootstrapCache.expiresAt > Date.now()) { + return bootstrapCache.data; + } + const res = await fetchUpstream(BOOTSTRAP_URL); + if (!res.ok) { + logger.error({ status: res.status }, 'RDAP bootstrap failed'); + throw new Error(`RDAP bootstrap failed: ${res.status}`); + } + const data = await res.json(); + bootstrapCache = { data, expiresAt: Date.now() + CACHE_TTL_MS }; + return data; +} + +function findEndpoint(services, tld) { + const needle = tld.toLowerCase(); + for (const [tlds, urls] of services) { + if (tlds.some(t => t.toLowerCase() === needle)) return urls[0]; + } + return null; +} + +const trimSlash = (u) => u.replace(/\/$/, ''); + +export async function rdapDomain(domain, { timeoutMs = 5000 } = {}) { + const bootstrap = await loadBootstrap(); + const tld = domain.split('.').pop(); + const base = findEndpoint(bootstrap.services, tld); + if (!base) { + throw new Error(`No RDAP endpoint for .${tld}`); + } + + const host = new URL(base).hostname; + const url = `${trimSlash(base)}/domain/${encodeURIComponent(domain)}`; + const res = await fetchUpstream(url, { timeoutMs }); + if (res.status === 404) { + throw new Error(`Domain not found: ${domain}`); + } + if (!res.ok) { + logger.error({ domain, status: res.status }, 'RDAP query failed'); + throw new Error(`RDAP query failed: ${res.status}`); + } + const data = await res.json(); + + return { [host]: { ...data, __raw: formatDomain(data) } }; +} + +// -- Format RDAP JSON into a WHOIS-like text block ------------------------ + +function extractVcard(entity) { + const props = entity?.vcardArray?.[1] || []; + const out = {}; + for (const [name, , , value] of props) { + if (name === 'version') continue; + if (!out[name]) out[name] = []; + out[name].push(value); + } + return out; +} + +function formatEntity(entity, indent) { + const lines = []; + const pad = ' '.repeat(indent); + if (entity.handle) lines.push(`${pad}Handle: ${entity.handle}`); + const v = extractVcard(entity); + if (v.fn) lines.push(`${pad}Name: ${v.fn[0]}`); + if (v.org) lines.push(`${pad}Org: ${Array.isArray(v.org[0]) ? v.org[0].join(' ') : v.org[0]}`); + if (v.email) lines.push(`${pad}Email: ${v.email.join(', ')}`); + if (v.tel) lines.push(`${pad}Phone: ${v.tel.join(', ')}`); + if (v.adr) { + const addr = v.adr[0]; + if (Array.isArray(addr)) { + const s = addr.filter(Boolean).join(', '); + if (s) lines.push(`${pad}Address: ${s}`); + } + } + return lines; +} + +function formatDomain(data) { + const lines = []; + lines.push(`Domain Name: ${data.ldhName || 'N/A'}`); + if (data.unicodeName && data.unicodeName !== data.ldhName) { + lines.push(`Unicode Name: ${data.unicodeName}`); + } + if (data.handle) lines.push(`Registry Domain ID: ${data.handle}`); + + const ev = {}; + for (const e of data.events || []) ev[e.eventAction] = e.eventDate; + if (ev.registration) lines.push(`Created: ${ev.registration}`); + if (ev['last changed']) lines.push(`Updated: ${ev['last changed']}`); + if (ev.expiration) lines.push(`Expires: ${ev.expiration}`); + if (ev['last update of RDAP database']) lines.push(`RDAP Last Refresh: ${ev['last update of RDAP database']}`); + + if (data.status?.length) { + lines.push('Status:'); + for (const s of data.status) lines.push(` ${s}`); + } + + const order = ['registrar', 'registrant', 'administrative', 'technical', 'abuse', 'reseller']; + const byRole = new Map(); + for (const e of data.entities || []) { + for (const role of (e.roles?.length ? e.roles : ['unknown'])) { + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push(e); + } + } + const seen = new Set(); + for (const role of order) { + for (const e of byRole.get(role) || []) { + seen.add(e); + lines.push(''); + lines.push(`${role[0].toUpperCase()}${role.slice(1)}:`); + lines.push(...formatEntity(e, 2)); + } + } + for (const [role, es] of byRole) { + if (order.includes(role)) continue; + for (const e of es) { + if (seen.has(e)) continue; + lines.push(''); + lines.push(`${role}:`); + lines.push(...formatEntity(e, 2)); + } + } + + if (data.nameservers?.length) { + lines.push(''); + lines.push('Name Servers:'); + for (const ns of data.nameservers) lines.push(` ${ns.ldhName || 'N/A'}`); + } + if (data.secureDNS) { + lines.push(''); + lines.push(`DNSSEC: ${data.secureDNS.delegationSigned ? 'signed' : 'unsigned'}`); + } + return lines.join('\n'); +} diff --git a/common/valid-ip.js b/common/valid-ip.js index 8130a2f98..f561f0910 100644 --- a/common/valid-ip.js +++ b/common/valid-ip.js @@ -28,4 +28,15 @@ function isValidIP(ip) { return hasCompressedGroup ? groups.length < 8 : groups.length === 8; }; -export { isValidIP }; +// 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. +function isValidDomain(domain) { + if (typeof domain !== 'string') return false; + return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(domain); +} + +export { isValidIP, isValidDomain }; diff --git a/frontend-server.js b/frontend-server.js index 03a7b2687..9535aca30 100644 --- a/frontend-server.js +++ b/frontend-server.js @@ -4,8 +4,9 @@ import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import { createProxyMiddleware } from 'http-proxy-middleware'; +import logger from './common/logger.js'; -dotenv.config(); +dotenv.config({ quiet: true }); const frontendApp = express(); const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10); @@ -25,5 +26,5 @@ frontendApp.use(express.static(path.join(__dirname, './dist'))); // Start static file server frontendApp.listen(frontEndPort, () => { - console.log(`Static file server running on port http://localhost:${frontEndPort}`); + logger.info(`🚀 Static file server ready on http://localhost:${frontEndPort}`); }); diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ce60ed3be..fdf37ec16 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -25,8 +25,10 @@ frontend/ │ default-preferences / changelog) ├── utils/ ← pure helpers │ (valid-ip / getips / transform-ip-data / -│ hero-ip-size / fetch-with-timeout / …) +│ fetch-with-timeout / …) ├── composables/ ← reusable composition logic +│ ├── use-fit-text.js ← auto-fit font-size picker (+ HERO_TIERS / INLINE_TIERS presets) +│ ├── use-globalping-measurement.js ← shared POST+poll orchestrator for the Globalping tools │ ├── use-info-mask.js │ ├── use-refresh-orchestrator.js │ ├── use-scroll-to.js @@ -38,11 +40,12 @@ frontend/ │ / SpeedTest / Advanced / Footer / Nav / Achievements / User / │ Additional) ├── ip-infos/ ← IP-card subcomponents (IPCard / IpDetailPanel / ASNInfo / DataPairBar) - ├── advanced-tools/ ← 10 tool subpages opened inside the bottom Drawer + ├── advanced-tools/ ← 11 tool subpages opened inside the bottom Drawer │ (MtrTest / GlobalLatencyTest / RuleTest / DnsResolver / - │ CensorshipCheck / Whois / MacChecker / BrowserInfo / - │ InvisibilityTest / SecurityChecklist + Empty) - ├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast) + │ EnhancedDnsLeakTest / CensorshipCheck / Whois / + │ MacChecker / BrowserInfo / InvisibilityTest / + │ SecurityChecklist + Empty) + ├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast / FitText) ├── svgicons/ ← a few inline SVGs └── ui/ ← shadcn-vue copy-in primitives (see "UI system" below) ``` @@ -65,9 +68,9 @@ Before hand-rolling a UI component (button, dialog, popover, list, etc.): ### Primitives -Located at `frontend/components/ui/`. 22 primitives copied in: +Located at `frontend/components/ui/`. 23 primitives copied in: -`accordion` · `badge` · `button` · `button-group` · `card` · `collapsible` · `dialog` (with `DialogHeader`) · `drawer` (vaul-vue) · `dropdown-menu` · `input` · `input-group` (with `InputGroupAddon` / `InputGroupButton` / `InputGroupInput` / `InputGroupText` / `InputGroupTextarea`) · `progress` · `select` · `separator` · `sheet` · `sonner` · `spinner` · `switch` · `tabs` · `textarea` · `toggle-group` · `tooltip` +`accordion` · `badge` · `button` · `button-group` · `card` · `collapsible` · `dialog` (with `DialogHeader`) · `drawer` (vaul-vue) · `dropdown-menu` · `input` · `input-group` (with `InputGroupAddon` / `InputGroupButton` / `InputGroupInput` / `InputGroupText` / `InputGroupTextarea`) · `progress` · `select` · `separator` · `sheet` · `sonner` · `spinner` · `switch` · `table` (with `TableHeader` / `TableBody` / `TableRow` / `TableHead` / `TableCell`) · `tabs` · `textarea` · `toggle-group` · `tooltip` Two are project-specific, not in stock shadcn-vue: @@ -99,6 +102,8 @@ Badge adds `success` to the defaults. Badge hover is globally disabled — Badge All "business state → visual color" mapping goes through `frontend/composables/use-status-tone.js`. It exposes four tones: `wait` / `ok-fast` / `ok-slow` / `fail`. +For the common "status string → tone" shape (WebRTC / DnsLeak / RuleTest / Connectivity style), use the `ipFieldTone(value, { waitLabels, errorLabels, isSuccess?, time?, fastMs? })` helper from the same module rather than hand-rolling the switch locally. Default `isSuccess` treats any string containing `.` or `:` as a successful IP-shaped payload; pass a custom predicate when the success signal is elsewhere (Connectivity uses the localized "Available" label). + Rule: any new module that surfaces a status reuses these tones. Do not hand-write `switch` statements that map states to hex codes or Tailwind classes. ### Canonical patterns @@ -116,7 +121,9 @@ Rule: any new module that surfaces a status reuses these tones. Do not hand-writ ```vue
- +
``` +Every free-form Input that takes a URL / IP / MAC / domain / custom identifier carries the same six attributes shown above: `autocomplete="off"`, `autocorrect="off"`, `autocapitalize="off"`, `spellcheck="false"`, `data-1p-ignore`, `data-lpignore="true"`. iOS Safari's QuickType bar uses placeholder + nearby label text to offer address / email / password AutoFill — without these attributes it will push iCloud-address or password suggestions onto a plain IP/URL input. Keep placeholder copy free of "address / 地址 / adresse / adresi" style words where possible — iOS heuristics trigger on the word itself even with `autocomplete="off"`. + The `input-group` primitive (stock shadcn-vue, with `InputGroupInput` / `InputGroupAddon` / `InputGroupButton` / `InputGroupText` / `InputGroupTextarea` sub-parts) is available if you need a genuinely merged border / ring around a composite input — but the current convention above is what every consumer uses today. **Status card** — homepage status cards (Connectivity / WebRTC / DnsLeak / IPCard / RuleTest) use: @@ -143,6 +152,19 @@ The `input-group` primitive (stock shadcn-vue, with `InputGroupInput` / `InputGr ``` +**Fit-to-width IP text** — any span that renders an IP, MAC, or similar variable-length token goes through ``. Pass `HERO_TIERS` for the big hero rows (IPCard / QueryIP) and `INLINE_TIERS` for the compact test-card rows (WebRTC / DnsLeak / RuleTest). Do not write a length-threshold helper per component — that pattern existed historically (`heroIpSizeClass`, `fitOneLineClass`) and was replaced because it couldn't account for actual card width. + +```vue + + + +``` + +`:max-lines="2"` is opt-in — hero rows use it so a long IPv6 wraps rather than shrinking to 12 px. The `#prefix` slot keeps a decorative icon riding the first line (not the center of a 2-line block). Action buttons like Copy stay as flex siblings of `` so the ellipsis clips only the IP itself, not the button. + **Tables vs lists** — two or three columns with independent header semantics → `` with `thead text-xs uppercase tracking-wide text-muted-foreground` + `tbody divide-y` + row `hover:bg-muted/50`. Mobile-friendly rows or asymmetric content with no header meaning → `
    ` + `
  • `. **Dialog header** — use the `` primitive to get an icon, title, and DialogClose in one element, matching the Sheet / Drawer header treatment. diff --git a/frontend/App.vue b/frontend/App.vue index 788f5b644..3773154e1 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -84,9 +84,29 @@ const connectivityRef = ref(null); const webRTCRef = ref(null); const dnsLeaksRef = ref(null); -// Hide loading mask on first screen +// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html. +// Stages: text fade → logo shrink → remove overlay + reveal #app. const loadingElement = document.getElementById('jn-loading'); -if (loadingElement) loadingElement.style.display = 'none'; +const appElement = document.getElementById('app'); + +const revealApp = () => { + document.documentElement.removeAttribute('data-booting'); + if (appElement) { + requestAnimationFrame(() => appElement.classList.add('jn-app-enter')); + } +}; + +if (loadingElement) { + requestAnimationFrame(() => loadingElement.classList.add('jn-loading-stage-1')); + setTimeout(() => loadingElement.classList.add('jn-loading-stage-2'), 160); + setTimeout(() => { + loadingElement.remove(); + revealApp(); + }, 380); +} else { + // Overlay already gone (e.g. HMR remount) — still reveal the app. + revealApp(); +} // Info mask const { infoMaskLevel, isInfosLoaded, showMaskButton, toggleInfoMask } = useInfoMask({ diff --git a/frontend/components/Advanced.vue b/frontend/components/Advanced.vue index 5e3f09afa..1056077b6 100644 --- a/frontend/components/Advanced.vue +++ b/frontend/components/Advanced.vue @@ -18,7 +18,7 @@ @keydown.enter.prevent="navigateAndToggleOffcanvas(card.path)" @keydown.space.prevent="navigateAndToggleOffcanvas(card.path)"> -

    +

    {{ t(card.titleKey) }} @@ -65,14 +65,14 @@ + + diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index 7135aa9ff..92c9fca46 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -14,7 +14,8 @@
    -
    -

    +

    🚀 {{ t('speedtest.Title') }}

    {{ t('speedtest.Note') }}

    @@ -22,7 +23,7 @@ - + @@ -109,30 +110,34 @@
    -
    -
    +
    - - {{ t('speedtest.connectionFrom') }} - {{ state.connection.ip }} - ( {{ state.connection.country }} ) - {{ t('speedtest.connectionTo') }} - {{ state.connection.colo }} - ( {{ state.connection.coloCity }}, {{ state.connection.coloCountry }} ) - {{ t('speedtest.connectionEnd') }} - {{ t('speedtest.score') }} - {{ t('speedtest.videoStreaming') }} - {{ t('speedtest.quality.' + - state.speedTest.streamingQuality) }} - {{ t('speedtest.gaming') }} - - {{ t('speedtest.quality.' + state.speedTest.gamingQuality) }} - - {{ t('speedtest.rtc') }} - - {{ t('speedtest.quality.' + state.speedTest.rtcQuality) }} - + +

    + {{ t('speedtest.connectionFrom') }} + {{ state.connection.ip }} + ( {{ state.connection.country }} ) + {{ t('speedtest.connectionTo') }} + {{ state.connection.colo }} + ( {{ state.connection.coloCity }}, {{ state.connection.coloCountry }} ) + {{ t('speedtest.connectionEnd') }} +

    +

    + {{ t('speedtest.score') }} + {{ t('speedtest.videoStreaming') }} + {{ t('speedtest.quality.' + + state.speedTest.streamingQuality) }} + {{ t('speedtest.gaming') }} + + {{ t('speedtest.quality.' + state.speedTest.gamingQuality) }} + + {{ t('speedtest.rtc') }} + + {{ t('speedtest.quality.' + state.speedTest.rtcQuality) }} + +

    @@ -146,6 +151,7 @@ import { reactive, computed, onMounted, markRaw, onUnmounted } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/use-analytics'; +import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; import { isValidIP } from '@/utils/valid-ip.js'; import getCountryName from '@/data/country-name.js'; import SpeedTestEngine from '@cloudflare/speedtest'; @@ -157,7 +163,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { - ArrowLeftRight, CalendarCheck2, ChevronRight, CloudDownload, CloudUpload, + ArrowLeftRight, CalendarCheck2, Play, CloudDownload, CloudUpload, Globe, Pause, PersonStanding, RotateCw, } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; @@ -214,7 +220,7 @@ const isError = computed(() => state.speedTest.status === 'error'); const ctaIcon = computed(() => { if (isRunning.value) return Pause; if (isFinished.value || isError.value) return RotateCw; - return ChevronRight; + return Play; }); @@ -254,7 +260,7 @@ const qualityBadgeClass = (score) => { const connectionMethods = { async getIPFromSpeedTest() { try { - const response = await fetch('https://speed.cloudflare.com/cdn-cgi/trace'); + const response = await fetchWithTimeout('https://speed.cloudflare.com/cdn-cgi/trace'); const data = await response.text(); const lines = data.split('\n'); const ip = lines.find((l) => l.startsWith('ip='))?.split('=')[1]; @@ -517,7 +523,19 @@ const speedTestController = async () => { // --- Lifecycle ---------------------------------------------------------- onMounted(() => { store.setMountingStatus('speedtest', true); }); -onUnmounted(() => { if (testEngine) testEngine = null; destroyCharts(); }); +// If the user navigates away mid-test, detach the engine's callbacks before +// dropping the reference — otherwise any in-flight async work inside the +// SpeedTestEngine would still try to write state refs that no longer exist. +onUnmounted(() => { + if (testEngine) { + testEngine.onRunningChange = null; + testEngine.onResultsChange = null; + testEngine.onFinish = null; + testEngine.onError = null; + testEngine = null; + } + destroyCharts(); +}); defineExpose({ speedTestController }); diff --git a/frontend/components/User.vue b/frontend/components/User.vue index fbf223a96..281b19bf5 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -10,7 +10,7 @@
      -
    • +
    • diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index 39d17b532..3d2a48c92 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -10,7 +10,7 @@
    @@ -26,7 +26,7 @@
    - + {{ stun.name }}
    @@ -42,8 +42,9 @@ class="absolute inline-flex size-2 rounded-full bg-info opacity-75 animate-ping"> - {{ stun.ip }} +
    @@ -79,18 +80,21 @@ diff --git a/frontend/components/advanced-tools/GlobalLatencyTest.vue b/frontend/components/advanced-tools/GlobalLatencyTest.vue index 0088eb1d9..8d66637cc 100644 --- a/frontend/components/advanced-tools/GlobalLatencyTest.vue +++ b/frontend/components/advanced-tools/GlobalLatencyTest.vue @@ -43,7 +43,7 @@

@@ -102,6 +102,7 @@ import { ref, computed } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/use-analytics'; +import { useGlobalpingMeasurement } from '@/composables/use-globalping-measurement'; import getCountryName from '@/data/country-name.js'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; @@ -110,10 +111,6 @@ import { Spinner } from '@/components/ui/spinner'; import { Icon } from '@iconify/vue'; import { Info,Play } from 'lucide-vue-next'; -// svgmap CSS:静态 side-effect import。原本在 drawMap() 里 await import('svgmap/style.min') -// 动态引入 —— dev 下能跑,build 下 Rollup 把 CSS chunk 当 JS 模块去解 export 导致 -// 'svg_map_min_exports is not defined' 运行时错误。 -// 这个组件本身走 router 懒加载,CSS 会跟着组件 chunk 一起按需加载,不影响首屏。 import 'svgmap/style.min'; const { t } = useI18n(); @@ -128,7 +125,11 @@ const allIPs = computed(() => { const selectedIP = ref(''); const pingResults = ref([]); -const pingCheckStatus = ref('idle'); +// status: 'idle' | 'running' | 'finished' | 'error' — driven by the composable +const { status: pingCheckStatus, start: runMeasurement } = useGlobalpingMeasurement({ + pollInterval: 1000, + maxRetries: 4, +}); // Header configuration: Region left aligned, all numbers right aligned (tabular-nums aligns decimal points more neatly) const headers = [ @@ -153,61 +154,24 @@ const startPingCheck = () => { trackEvent('Section', 'StartClick', 'GlobalLatency'); pingResults.value = []; cleanMap(); - let tryCount = 0; - - const sendPingRequest = async () => { - pingCheckStatus.value = 'running'; - try { - const response = await fetch('https://api.globalping.io/v1/measurements', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - limit: 16, - locations: [ - { country: 'HK' }, { country: 'TW' }, { country: 'CN' }, { country: 'JP' }, - { country: 'SG' }, { country: 'IN' }, { country: 'RU' }, { country: 'US' }, - { country: 'CA' }, { country: 'AU' }, { country: 'GB' }, { country: 'DE' }, - { country: 'FR' }, { country: 'BR' }, { country: 'ZA' }, { country: 'SA' }, - ], - target: selectedIP.value, - type: 'ping', - measurementOptions: { packets: 8 }, - }), - }); - - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - return await response.json(); - } catch (error) { - console.error('Error sending ping request:', error); - } - }; - - const fetchpingResults = async (id) => { - try { - const response = await fetch(`https://api.globalping.io/v1/measurements/${id}`); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); + runMeasurement({ + limit: 16, + locations: [ + { country: 'HK' }, { country: 'TW' }, { country: 'CN' }, { country: 'JP' }, + { country: 'SG' }, { country: 'IN' }, { country: 'RU' }, { country: 'US' }, + { country: 'CA' }, { country: 'AU' }, { country: 'GB' }, { country: 'DE' }, + { country: 'FR' }, { country: 'BR' }, { country: 'ZA' }, { country: 'SA' }, + ], + target: selectedIP.value, + type: 'ping', + measurementOptions: { packets: 8 }, + }, { + onResults: (data) => { processpingResults(data); - - if (data.status === 'in-progress' && tryCount < 4) { - setTimeout(() => fetchpingResults(id), 1000); - tryCount++; - } else { - if (pingResults.value.length === 0) { - pingCheckStatus.value = 'error'; - } else { - pingCheckStatus.value = 'finished'; - drawMap(); - } - } - } catch (error) { - console.error('Error fetching ping results:', error); - } - }; - - sendPingRequest().then(data => { - if (data && data.id) setTimeout(() => fetchpingResults(data.id), 1000); + return pingResults.value.length > 0; + }, + onFinish: () => drawMap(), }); }; diff --git a/frontend/components/advanced-tools/MacChecker.vue b/frontend/components/advanced-tools/MacChecker.vue index 08df24432..5b4d9d17d 100644 --- a/frontend/components/advanced-tools/MacChecker.vue +++ b/frontend/components/advanced-tools/MacChecker.vue @@ -1,16 +1,17 @@
{{ t('pingtest.' + header.key) }}