diff --git a/.dockerignore b/.dockerignore index 0a2a58db9..b0cc816b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,23 @@ npm-debug.log* yarn-debug.log* pnpm-debug.log* +# Runtime-downloaded databases — 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b4a7d10..c85244fa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,20 +19,26 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - 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 + - name: Setup Node uses: actions/setup-node@v6 with: node-version: 24 - # Intentionally no `cache: npm` — that requires a package-lock.json, and this - # repo gitignores the lockfile by design. `npm install` below runs cold every - # run; dependency install on this project takes ~30s, which is acceptable - # versus the drift risk of committing a lockfile we don't otherwise use. + # The committed pnpm-lock.yaml lets setup-node cache the pnpm store keyed + # off the lockfile hash — installs are warm on every run after the first. + cache: pnpm - name: Install dependencies - run: npm install --no-audit --no-fund + # --frozen-lockfile: install strictly from the lockfile and fail if it is + # out of sync with package.json, rather than silently rewriting it. + run: pnpm install --frozen-lockfile - name: Run tests - run: npm test + run: pnpm test - name: Build - run: npm run build + run: pnpm run build diff --git a/.gitignore b/.gitignore index 1d4a1fbb1..5c6a3defa 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ public/llms.txt public/llms-full.txt public/tools/ public/robots.txt +public/ads.txt # Private AI context (per-machine, not for the public repo) local-context.md diff --git a/AGENTS.md b/AGENTS.md index 555796771..88ba677ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,12 +39,14 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se | Command | What it does | |---|---| -| `npm run dev` | Vite + backend (nodemon) together — front-end 5173, back-end 11966 | -| `npm run build` | Front-end production build | -| `npm run preview` | Vite preview of the build output | -| `npm run start` | Built front-end + backend (static file server) | -| `npm test` | Run all `tests/*.test.js` specs | -| `npm run check` | `test` + `build`, the pre-commit self-check | +| `pnpm dev` | Vite + backend (nodemon) together — front-end 5173, back-end 11966 | +| `pnpm build` | Front-end production build | +| `pnpm preview` | Vite preview of the build output | +| `pnpm start` | Built front-end + backend (static file server) | +| `pnpm test` | Run all `tests/*.test.js` specs | +| `pnpm check` | `test` + `build`, the pre-commit self-check | + +This project uses **pnpm** as its package manager (pinned via the `packageManager` field in `package.json`). The lockfile is `pnpm-lock.yaml` (committed); `pnpm-workspace.yaml` holds the `allowBuilds` approvals for the few dependencies whose install scripts are trusted to run. Do not use `npm` / `yarn` — they would produce a competing lockfile. ## Project layout @@ -61,7 +63,7 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se ├── tests/ ← Node test runner specs │ ├── backend-server.js ← Express app (default port 11966) -├── frontend-server.js ← static file server for `npm start` +├── frontend-server.js ← static file server for `pnpm start` ├── index.html ← Vite entry; #app mounts vaul-drawer-wrapper ├── vite.config.js ├── jsconfig.json ← JS project, alias @ → frontend/ @@ -96,10 +98,10 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se ## Testing - **Test runner:** Node built-in (`node --test`), no third-party framework. Specs live in `tests/*.test.js`. -- **Coverage expectation:** any non-visual logic that can be exercised without a network call — pure functions, composables with mockable inputs, transform utilities, validators — ships with a test in `tests/` and is wired into `npm test`. UI rendering, real network behavior, and browser-API-dependent code are out of scope. +- **Coverage expectation:** any non-visual logic that can be exercised without a network call — pure functions, composables with mockable inputs, transform utilities, validators — ships with a test in `tests/` and is wired into `pnpm test`. UI rendering, real network behavior, and browser-API-dependent code are out of scope. - **Big new features:** write the tests in the same change. Don't defer. - **Modifying a tested feature:** check the related tests; update them in the same change if behavior shifts. -- **Tests must pass locally before you hand off.** If `npm run check` is red, don't ask the user to review. +- **Tests must pass locally before you hand off.** If `pnpm check` is red, don't ask the user to review. ## Security & Boundaries @@ -114,7 +116,7 @@ The backend enforces access control and timeouts through shared middleware rathe - **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`. +- **Self-test before handing off.** Run `pnpm check` (or at least `pnpm 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 `pnpm dev`. - **Every commit is gated on user testing.** Even with tests green, a visual change needs the user to have looked at it before it lands. - **Add yourself as a co-author to the commit.** If you are an AI. - **Commit message style** follows recent `git log` — `Refactor(xxx): …` / `Fix(ui): …` / `Feat(xxx): …` / `Style: …` / `Chore: …` prefix. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a98df5246..6824564ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,12 +53,12 @@ To set up the development environment for this project, you'll need to: 1. Install Node.js, Vite, and Vue3. 2. Clone the repository. -3. Run `npm install` to install dependencies. +3. Run `pnpm install` to install dependencies. 4. Follow the instructions for Docker and Vercel deployment in our documentation if necessary. ### Testing -Ensure that all tests pass and, if applicable, add new tests for your changes. Run `npm test` to execute tests. +Ensure that all tests pass and, if applicable, add new tests for your changes. Run `pnpm test` to execute tests. ### Pull Request Guidelines diff --git a/Dockerfile b/Dockerfile index 94fc9c3e0..7a031a438 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,22 @@ # Build stage FROM node:24-alpine AS build-stage +# corepack ships with the node image and provisions pnpm at the version pinned +# by the `packageManager` field in package.json — no global npm install needed. +RUN corepack enable WORKDIR /app -COPY package*.json ./ -RUN npm install +# Copy the manifests first so this layer (and the install below) stays cached +# unless dependencies actually change. pnpm-workspace.yaml carries the +# allowBuilds approvals; pnpm-lock.yaml is required by --frozen-lockfile. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile COPY . . -RUN npm run build +RUN pnpm run build # Production stage FROM node:24-alpine AS production-stage WORKDIR /app +# node_modules is copied as-is from the build stage (pnpm's symlink farm into +# .pnpm is preserved within /app), so the runtime needs no install step. COPY --from=build-stage /app/node_modules ./node_modules COPY --from=build-stage /app/package.json ./ COPY --from=build-stage /app/dist ./dist diff --git a/README.md b/README.md index 1a89778ff..c6353d383 100644 --- a/README.md +++ b/README.md @@ -68,16 +68,17 @@ Clone the code: git clone https://github.com/jason5ng32/MyIP.git ``` -Install and build: +Install and build. This project uses pnpm — if you don't have it, install it first (npm ships with Node, so this command always works): ```bash -npm install && npm run build +npm install -g pnpm +pnpm install && pnpm run build ``` Run: ```bash -npm start +pnpm start ``` The program will run on port 18966. diff --git a/README_FR.md b/README_FR.md index 2bf6a78a9..9bfa13df9 100644 --- a/README_FR.md +++ b/README_FR.md @@ -68,16 +68,17 @@ Clonez le code : git clone https://github.com/jason5ng32/MyIP.git ``` -Installer & Construire : +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) : ```bash -npm install && npm run build +npm install -g pnpm +pnpm install && pnpm run build ``` Exécuter: ```bash -npm start +pnpm start ``` Le programme s'exécutera sur le port 18966. diff --git a/README_TR.md b/README_TR.md index 6d9bacef3..1aabf18d9 100644 --- a/README_TR.md +++ b/README_TR.md @@ -68,16 +68,17 @@ Kodu klonlayın: git clone https://github.com/jason5ng32/MyIP.git ``` -Kurun ve derleyin: +Kurun ve derleyin. Bu proje pnpm kullanır — eğer yoksa önce onu kurun (npm, Node ile birlikte gelir, bu yüzden bu komut her zaman çalışır): ```bash -npm install && npm run build +npm install -g pnpm +pnpm install && pnpm run build ``` Çalıştırın: ```bash -npm start +pnpm start ``` Uygulama 18966 portunda çalışacaktır. diff --git a/README_ZH.md b/README_ZH.md index ce85448d1..5a3905695 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -68,16 +68,17 @@ git clone https://github.com/jason5ng32/MyIP.git ``` -安装与编译: +安装与编译。本项目使用 pnpm,如果你还没有,请先安装(npm 随 Node 一起提供,这条命令一定能用): ```bash -npm install && npm run build +npm install -g pnpm +pnpm install && pnpm run build ``` 运行: ```bash -npm start +pnpm start ``` 程序会运行在 18966 端口。 diff --git a/api/asn-connectivity.js b/api/asn-connectivity.js index 7cbd10068..c506a499b 100644 --- a/api/asn-connectivity.js +++ b/api/asn-connectivity.js @@ -7,8 +7,7 @@ // 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. -import { fetchAsOverview, extractOrgFromHolder } from '../common/ripestat.js'; -import { lookupAsOrgName } from '../common/as-org-db.js'; +import { resolveAsnOrgName } from '../common/ripestat.js'; import { providersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js'; import logger from '../common/logger.js'; @@ -21,20 +20,10 @@ const MAX_DEPTH = 3; // as a proxy for "primary transit". const MAX_INTERMEDIATE_BRANCH = 3; -// Two-tier org name resolver: local CAIDA as2org first (µs), RIPEstat -// as-overview fallback when the snapshot doesn't have the ASN. -async function resolveOrgName(asn) { - const local = lookupAsOrgName(asn); - if (local) return local; - try { - const res = await fetchAsOverview(asn); - if (!res.ok) return null; - const payload = await res.json(); - return extractOrgFromHolder(payload?.data?.holder); - } catch { - return null; - } -} +// Two-tier org name resolver lives in common/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(); diff --git a/api/asn-history.js b/api/asn-history.js index f1d5da344..b5014e3fa 100644 --- a/api/asn-history.js +++ b/api/asn-history.js @@ -12,10 +12,8 @@ import { fetchRoutingHistory, - fetchAsOverview, - extractOrgFromHolder, + resolveAsnOrgName, } from '../common/ripestat.js'; -import { lookupAsOrgName } from '../common/as-org-db.js'; import logger from '../common/logger.js'; const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10); @@ -58,22 +56,13 @@ function summarizeOrigin(entry, minLen) { }; } -// Two-tier resolver: local CAIDA as2org first (µs), RIPEstat as-overview -// fallback. Best-effort — any failure yields null so the row drops to -// ASN-only display rather than blocking the whole batch. -async function resolveOrgName(asn) { - const local = lookupAsOrgName(asn); - if (local) return local; - try { - const res = await fetchAsOverview(asn); - if (!res.ok) return null; - const payload = await res.json(); - return extractOrgFromHolder(payload?.data?.holder); - } catch (error) { - logger.warn({ err: error, asn }, 'as-overview lookup failed'); - return null; - } -} +// Two-tier resolver lives in common/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) => + resolveAsnOrgName(asn, { + onError: (error) => logger.warn({ err: error, asn }, 'as-overview lookup failed'), + }); export default async (req, res) => { // Prefix presence + validity guaranteed by requireValidPrefix middleware. diff --git a/api/dns-leak-test.js b/api/dns-leak-test.js index 080346db5..35f6ee2fb 100644 --- a/api/dns-leak-test.js +++ b/api/dns-leak-test.js @@ -10,9 +10,9 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; +import { pickLang } from '../common/langs.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') { @@ -30,7 +30,7 @@ export async function getSessionResult(req, res) { return res.status(500).json({ error: 'API key is missing' }); } - const lang = SUPPORTED_LANGS.includes(req.query.lang) ? req.query.lang : 'zh-CN'; + const lang = pickLang(req.query.lang, 'zh-CN'); const url = new URL(`${apiEndpoint}/dnsleaktest/session/${token}`); url.searchParams.set('apikey', apiKey); diff --git a/api/dns-resolver.js b/api/dns-resolver.js index f787a1651..560f06b3f 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -19,11 +19,10 @@ const dnsServers = { 'Quad9': '9.9.9.9', 'ControlD': '76.76.2.0', 'AdGuard': '94.140.14.14', - 'Quad 101': '101.101.101.101', 'AliDNS': '223.5.5.5', 'DNSPod': '119.29.29.29', '114DNS': '114.114.114.114', - 'China Unicom': '123.123.123.123', + 'DNS4EU': '86.54.11.1', }; // DNS-over-HTTPS server list diff --git a/api/ip-sb.js b/api/ip-sb.js index b2d91ff95..2cfeb994f 100644 --- a/api/ip-sb.js +++ b/api/ip-sb.js @@ -1,21 +1,13 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +// /api/ipsb — geolocation source handler backed by api.ip.sb. +// Token-free upstream; normalizes the response into the canonical geo +// shape via the shared makeGeoHandler factory. -export default async (req, res) => { - // IP presence + validity guaranteed by requireValidIP middleware. - const ipAddress = req.query.ip; - - const url = `https://api.ip.sb/geoip/${ipAddress}`; +import { makeGeoHandler } from '../common/geo-handler.js'; - try { - const apiRes = await fetchUpstream(url); - 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 }); - } -}; +function buildUrl(req) { + const ipAddress = req.query.ip; + return `https://api.ip.sb/geoip/${ipAddress}`; +} function modifyJsonForIPSB(json) { return { @@ -30,4 +22,6 @@ function modifyJsonForIPSB(json) { asn: "AS" + json.asn, org: json.isp }; -} \ No newline at end of file +} + +export default makeGeoHandler({ name: 'ip-sb', buildUrl, normalize: modifyJsonForIPSB }); diff --git a/api/ip2location-io.js b/api/ip2location-io.js index 66113d35a..36ff5417f 100644 --- a/api/ip2location-io.js +++ b/api/ip2location-io.js @@ -1,23 +1,16 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +// /api/ip2location — geolocation source handler backed by api.ip2location.io. +// Picks a random API key and normalizes the response into the canonical +// geo shape via the shared makeGeoHandler factory. -export default async (req, res) => { - // IP presence + validity guaranteed by requireValidIP middleware. +import { makeGeoHandler } from '../common/geo-handler.js'; + +function buildUrl(req) { const ipAddress = req.query.ip; const keys = (process.env.IP2LOCATION_API_KEY).split(','); const key = keys[Math.floor(Math.random() * keys.length)]; - const url = `https://api.ip2location.io/?ip=${ipAddress}&key=${key}`; - - try { - const apiRes = await fetchUpstream(url); - 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 }); - } -}; + return `https://api.ip2location.io/?ip=${ipAddress}&key=${key}`; +} function modifyJsonForIPAPI(json) { let asn = json.asn || {}; @@ -36,3 +29,5 @@ function modifyJsonForIPAPI(json) { org: as || 'N/A', }; } + +export default makeGeoHandler({ name: 'ip2location-io', buildUrl, normalize: modifyJsonForIPAPI }); diff --git a/api/ipapi-com.js b/api/ipapi-com.js index 139f08c21..2760b2a69 100644 --- a/api/ipapi-com.js +++ b/api/ipapi-com.js @@ -1,23 +1,17 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +// /api/ipapi — geolocation source handler backed by ip-api.com. +// Forwards the optional ?lang and normalizes the response into the +// canonical geo shape via the shared makeGeoHandler factory. -export default async (req, res) => { - // IP presence + validity guaranteed by requireValidIP middleware. +import { makeGeoHandler } from '../common/geo-handler.js'; + +function buildUrl(req) { const ipAddress = req.query.ip; // Build request URL for ip-api.com const lang = req.query.lang || 'en'; const url = `http://ip-api.com/json/${ipAddress}?fields=66842623&lang=${lang}`; - - try { - const apiRes = await fetchUpstream(url); - 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 }); - } -}; + return { url, logContext: { lang } }; +} function modifyJsonForIPAPI(json) { const { query, country, countryCode, regionName, city, lat, lon, isp, as } = json; @@ -36,3 +30,5 @@ function modifyJsonForIPAPI(json) { org: isp }; } + +export default makeGeoHandler({ name: 'ipapi-com', buildUrl, normalize: modifyJsonForIPAPI }); diff --git a/api/ipapi-is.js b/api/ipapi-is.js index 534401842..f1c49b20d 100644 --- a/api/ipapi-is.js +++ b/api/ipapi-is.js @@ -1,23 +1,16 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +// /api/ipapiis — geolocation source handler backed by api.ipapi.is. +// Picks a random API key and normalizes the response into the canonical +// geo shape (plus isHosting / isProxy) via the shared makeGeoHandler factory. -export default async (req, res) => { - // IP presence + validity guaranteed by requireValidIP middleware. +import { makeGeoHandler } from '../common/geo-handler.js'; + +function buildUrl(req) { const ipAddress = req.query.ip; const keys = (process.env.IPAPIIS_API_KEY).split(','); const key = keys[Math.floor(Math.random() * keys.length)]; - const url = `https://api.ipapi.is?q=${ipAddress}&key=${key}`; - - try { - const apiRes = await fetchUpstream(url); - 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 }); - } -}; + return `https://api.ipapi.is?q=${ipAddress}&key=${key}`; +} function modifyJsonForIPAPI(json) { let asn = json.asn || {}; @@ -38,3 +31,5 @@ function modifyJsonForIPAPI(json) { isProxy: is_proxy || is_vpn || is_tor || false }; } + +export default makeGeoHandler({ name: 'ipapi-is', buildUrl, normalize: modifyJsonForIPAPI }); diff --git a/api/ipinfo-io.js b/api/ipinfo-io.js index 57802e049..ecccb2a9e 100644 --- a/api/ipinfo-io.js +++ b/api/ipinfo-io.js @@ -1,9 +1,11 @@ +// /api/ipinfo — geolocation source handler backed by ipinfo.io. +// 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 { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +import { makeGeoHandler } from '../common/geo-handler.js'; -export default async (req, res) => { - // IP presence + validity guaranteed by requireValidIP middleware. +function buildUrl(req) { const ipAddress = req.query.ip; // Build request URL for ipinfo.io @@ -12,17 +14,8 @@ export default async (req, res) => { const url_hasToken = `https://ipinfo.io/${ipAddress}?token=${token}`; const url_noToken = `https://ipinfo.io/${ipAddress}`; - const url = token ? url_hasToken : url_noToken; - - try { - const apiRes = await fetchUpstream(url); - 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 }); - } -}; + return token ? url_hasToken : url_noToken; +} function modifyJson(json) { const { ip, city, region, country, loc, org } = json; @@ -46,3 +39,5 @@ function modifyJson(json) { org: modifiedOrg }; } + +export default makeGeoHandler({ name: 'ipinfo-io', buildUrl, normalize: modifyJson }); diff --git a/api/maxmind.js b/api/maxmind.js index ed1951c9e..ed0f10585 100644 --- a/api/maxmind.js +++ b/api/maxmind.js @@ -1,13 +1,13 @@ import { lookupMaxMind } from '../common/maxmind-service.js'; import logger from '../common/logger.js'; +import { pickLang } from '../common/langs.js'; export default (req, res) => { // IP presence + validity guaranteed by requireValidIP middleware. const ip = req.query.ip; // Get request language - const supportedLanguages = ['zh-CN', 'en', 'fr', 'tr']; - const lang = supportedLanguages.includes(req.query.lang) ? req.query.lang : 'en'; + const lang = pickLang(req.query.lang, 'en'); try { res.json(lookupMaxMind(ip, lang)); diff --git a/common/geo-handler.js b/common/geo-handler.js new file mode 100644 index 000000000..7b07a2c9f --- /dev/null +++ b/common/geo-handler.js @@ -0,0 +1,40 @@ +// Factory for IP-geolocation source handlers. +// +// Every geo source (ipinfo.io, ip-api.com, ipapi.is, ip2location.io, ip.sb) +// shares an identical Express shell: read the (already-validated) ?ip, +// build a source-specific URL, fetch it through fetchUpstream, normalize the +// upstream JSON into the canonical response shape, and respond — with a +// uniform try/catch that logs the error and returns a 500. +// +// makeGeoHandler captures that shell. Each source supplies only: +// - name : short id used in the error log message +// - buildUrl : (req) => string URL OR { url, logContext } when the +// handler wants extra fields (e.g. lang) on the error log +// - normalize : (json) => canonical response object +// +// buildUrl runs before the try block so any env-key selection it performs +// keeps its current behavior (e.g. throwing on a missing key surfaces the +// same way the original inline handlers did). + +import { fetchUpstream } from './fetch-with-timeout.js'; +import logger from './logger.js'; + +export function makeGeoHandler({ name, buildUrl, normalize }) { + return async (req, res) => { + // IP presence + validity guaranteed by requireValidIP middleware. + const ipAddress = req.query.ip; + + const built = buildUrl(req); + const url = typeof built === 'string' ? built : built.url; + const logContext = typeof built === 'string' ? {} : (built.logContext || {}); + + try { + const apiRes = await fetchUpstream(url); + const json = await apiRes.json(); + res.json(normalize(json)); + } catch (e) { + logger.error({ err: e, ip: ipAddress, ...logContext }, `${name} handler failed`); + res.status(500).json({ error: e.message }); + } + }; +} diff --git a/common/langs.js b/common/langs.js new file mode 100644 index 000000000..9fabb2557 --- /dev/null +++ b/common/langs.js @@ -0,0 +1,9 @@ +// Shared language allow-list for handlers that accept a ?lang query param. +// Each consumer keeps its own default; pickLang validates against this list. + +export const SUPPORTED_LANGS = ['zh-CN', 'en', 'fr', 'tr']; + +// Return raw if it's a supported language, otherwise the given fallback. +export function pickLang(raw, fallback) { + return SUPPORTED_LANGS.includes(raw) ? raw : fallback; +} diff --git a/common/ripestat.js b/common/ripestat.js index b7a649688..0e4ca9939 100644 --- a/common/ripestat.js +++ b/common/ripestat.js @@ -2,6 +2,7 @@ // its endpoint's normal latency; callers can override per call. import { fetchUpstream } from './fetch-with-timeout.js'; +import { lookupAsOrgName } from './as-org-db.js'; const BASE_URL = 'https://stat.ripe.net/data'; const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip'; @@ -32,3 +33,26 @@ export function extractOrgFromHolder(holder) { const dash = holder.indexOf(' - '); return dash > 0 ? holder.slice(dash + 3).trim() : holder.trim(); } + +/** + * Two-tier AS org-name resolver: local CAIDA as2org first (µs), RIPEstat + * as-overview fallback when the snapshot doesn't have the ASN. Best-effort — + * any failure yields null so callers can fall back to ASN-only display. + * + * `onError(error, asn)` is an optional observability hook invoked on the + * fallback failure/catch path. asn-history passes a warn logger here; + * asn-connectivity omits it and stays silent — preserve that asymmetry. + */ +export async function resolveAsnOrgName(asn, { onError } = {}) { + const local = lookupAsOrgName(asn); + if (local) return local; + try { + const res = await fetchAsOverview(asn); + if (!res.ok) return null; + const payload = await res.json(); + return extractOrgFromHolder(payload?.data?.holder); + } catch (error) { + if (onError) onError(error, asn); + return null; + } +} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9c07026f6..451b8ea96 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -22,17 +22,17 @@ frontend/ ├── data/ ← static config │ (achievements / ip-databases / sections / │ default-preferences / changelog) -├── utils/ ← pure helpers +├── utils/ ← framework-agnostic pure helpers + IO modules │ (valid-ip / getips / transform-ip-data / -│ fetch-with-timeout / …) -├── composables/ ← reusable composition logic +│ fetch-with-timeout / analytics / scroll-to / …) +├── composables/ ← Vue-aware reactive / stateful logic (useXxx) │ ├── 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 │ ├── use-section-tracking.js │ ├── use-shortcuts.js +│ ├── use-speedtest-charts.js ← Chart.js config + reactive chart state for SpeedTest │ └── use-status-tone.js ← shared 4-tier business-state → visual-color mapping └── components/ ├── *.vue ← top-level sections (IpInfos / Connectivity / WebRTC / DnsLeaks @@ -55,11 +55,21 @@ frontend/ - **Path alias.** `@` → `frontend/` (defined in `jsconfig.json` and `vite.config.js`). Use `@/components/...`, `@/utils/...`, `@/composables/...`. - **Shared-with-backend helpers live under `common/`.** Import them with a relative path, e.g. `../../common/valid-ip.js`. `common/valid-ip.js` is re-exported from `frontend/utils/valid-ip.js` so most consumers can keep using `@/utils/valid-ip.js`; follow that pattern when adding more shared helpers (see `frontend/utils/fetch-with-timeout.js` for the second example). +### Where does a helper go? `lib/` vs `composables/` vs `utils/` + +Three sibling directories hold non-component code. The deciding question is **does it touch Vue's reactivity or lifecycle?** + +- **`composables/`** — Vue-aware reactive / stateful logic. It uses `ref` / `reactive` / `computed` / `watch` or a lifecycle hook (`onMounted` / `onUnmounted`), or it wires into a component's `setup()` (registers listeners, returns reactive state, must be called once at mount). Named `useXxx()`. Examples: `use-theme`, `use-info-mask`, `use-speedtest-charts`, `use-shortcuts`. A `setup()`-time factory with no internal `ref` (e.g. `use-maxmind`, `use-status-tone`) still belongs here when it's Vue-app glue, but a *pure* function that happens to live next to a composable should be exported from that same file, not promoted to its own composable. +- **`utils/`** — framework-agnostic pure helpers and IO modules. No `vue` import, no reactive state: pure transforms / validators / detection (`transform-ip-data`, `valid-ip`, `system-detect`, `timestamp-to-date`), network fetchers (`getips/`, `dnsleaks/`, `authenticated-fetch`), module-level services (`analytics`), and the thin re-export bridges to `common/` (`valid-ip`, `fetch-with-timeout`, `bgp-prefix`). **A file here must not carry a `use-` prefix** — that prefix is reserved for composables. +- **`lib/`** — the shadcn-vue support layer, *not* a general dumping ground. Today it holds only `cn()` (tailwind-merge + clsx), which every `ui/` primitive imports as `@/lib/utils`. Don't add business or app logic here; new shared helpers go to `utils/` or `composables/` per the rule above. + +Quick test before adding a file: needs Vue reactivity/lifecycle → `composables/` (`useXxx`); otherwise → `utils/` (no `use-` prefix). Leave `lib/` to shadcn. + ## shadcn-vue first Before hand-rolling a UI component (button, dialog, popover, list, etc.): -1. **Check `frontend/components/ui/` first** — 21 primitives are already copied in (see below). Missing variants are almost never a reason to bypass a primitive; `:class` overrides, `as-child`, and tw-merge cover nearly every state-color need. +1. **Check `frontend/components/ui/` first** — 20 primitives are already copied in (see below). Missing variants are almost never a reason to bypass a primitive; `:class` overrides, `as-child`, and tw-merge cover nearly every state-color need. 2. **If no local primitive fits, check https://www.shadcn-vue.com/docs/components** — shadcn-vue covers a lot more than what's in the repo. Copy one in if it's a good fit. 3. **Only fall back to hand-rolled Tailwind** when the shape or behavior genuinely doesn't exist in shadcn-vue. @@ -67,13 +77,12 @@ Before hand-rolling a UI component (button, dialog, popover, list, etc.): ### Primitives -Located at `frontend/components/ui/`. 23 primitives copied in: +Located at `frontend/components/ui/`. 21 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` · `table` (with `TableHeader` / `TableBody` / `TableRow` / `TableHead` / `TableCell`) · `tabs` · `textarea` · `toggle-group` · `tooltip` +`accordion` · `badge` · `button` · `card` · `collapsible` · `dialog` (with `DialogHeader`) · `drawer` (vaul-vue) · `dropdown-menu` · `input` · `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: +One is project-specific, not in stock shadcn-vue: -- **`ButtonGroup`** — visual container for stitching buttons together. - **`Spinner`** — lucide `Loader2` + `animate-spin` + `role="status"`. ### Design tokens @@ -135,8 +144,6 @@ Rule: any new module that surfaces a status reuses these tones. Do not hand-writ 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: ```vue @@ -176,4 +183,4 @@ The `input-group` primitive (stock shadcn-vue, with `InputGroupInput` / `InputGr - Composables and utils are the main target — covered by `tests/composable-*.test.js` and the various utility tests. - Vue rendering / user interactions / browser APIs: out of scope for the Node test runner. -- For visual changes (layout, styling, animations), self-testing is not possible — explicitly ask the user to verify via `npm run dev`. +- For visual changes (layout, styling, animations), self-testing is not possible — explicitly ask the user to verify via `pnpm dev`. diff --git a/frontend/App.vue b/frontend/App.vue index 3773154e1..5eef63560 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -15,9 +15,11 @@ - - + + + +