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 @@
+ {{ leak.providerName }} +