diff --git a/.env.example b/.env.example index c8bcf9d5f..d68b853a7 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ IP2LOCATION_API_KEY="" IPCHECKING_API_KEY="" MAC_LOOKUP_API_KEY="" IPCHECKING_API_ENDPOINT="" +RIPESTAT_SOURCE_APP="" # Security related SECURITY_BLACKLIST_LOG_FILE_PATH="" SECURITY_RATE_LIMIT="" diff --git a/.gitignore b/.gitignore index 1e0b7389d..fb6d570fb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ common/maxmind-db/*.next common/maxmind-db/*.mmdb .learnings/ docs/ + +# Private AI context (per-machine, not for the public repo) +local-context.md diff --git a/AGENTS.md b/AGENTS.md index 75d8ce02c..555796771 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se | 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 | +| PWA install | `manifest.webmanifest` only (installable but online-only — no service worker) | | Tests | Node built-in test runner (`node --test`) | | Runtime libs | chart.js · svgmap · @cloudflare/speedtest · maxmind · whoiser · thumbmarkjs · ua-parser-js · detect-gpu · circle-progress.vue · @vueuse/core (used by shadcn-vue primitives) | @@ -119,3 +119,7 @@ The backend enforces access control and timeouts through shared middleware rathe - **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. - **On every commit, scan AGENTS.md (root + relevant sub-file) for staleness** — if the change adds a convention, renames a shared module, flips a rule, or invalidates an example, update the doc in the same commit. AGENTS.md drifting from reality is the main failure mode of this kind of document. + +--- + +If [local-context.md](./local-context.md) exists in the workspace root, please Read it as well — it lists Knowledge Hub paths relevant to this project (machine-local only; not in git). diff --git a/README.md b/README.md index efdeb08d5..1033b7146 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Download `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` from your MaxMind account | `IPAPIIS_API_KEY` | No | `""` | API Key for IPAPI.is, used to obtain IP geolocation information through IPAPI.is | | `IP2LOCATION_API_KEY` | No | `""` | API Key for IP2Location.io, used to obtain IP geolocation information through IP2Location.io | | `CLOUDFLARE_API` | No | `""` | API Key for Cloudflare, used to obtain AS system information through Cloudflare | +| `RIPESTAT_SOURCE_APP` | No | `""` | Source app name for RIPE.net, used to obtain ASN history information through RIPE.net | | `MAC_LOOKUP_API_KEY` | No | `""` | API Key for MAC Lookup, used to obtain MAC address information | | `VITE_CURL_IPV4_DOMAIN` | No | `""` | Provides the IPv4 domain for the CURL API to users | | `VITE_CURL_IPV6_DOMAIN` | No | `""` | Provides the IPv6 domain for the CURL API to users | diff --git a/README_FR.md b/README_FR.md index 1b31bf70f..5a6d75b16 100644 --- a/README_FR.md +++ b/README_FR.md @@ -142,6 +142,7 @@ Téléchargez `GeoLite2-City.mmdb` et `GeoLite2-ASN.mmdb` depuis votre compte Ma | `IPAPIIS_API_KEY` | Non | `""` | Clé API pour IPAPI.is, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IPAPI.is | | `IP2LOCATION_API_KEY` | Non | `""` | Clé API pour IP2Location.io, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IP2Location.io | | `CLOUDFLARE_API` | Non | `""` | Clé API pour Cloudflare, utilisée pour obtenir des informations sur le système AS via Cloudflare | +| `RIPESTAT_SOURCE_APP` | Non | `""` | Nom de l'application source pour RIPE.net, utilisé pour obtenir des informations sur l'historique ASN via RIPE.net | | `MAC_LOOKUP_API_KEY` | Non | `""` | Clé API pour MAC Lookup, utilisée pour obtenir des informations sur l'adresse MAC via MAC Lookup | | `VITE_CURL_IPV4_DOMAIN` | Non | `""` | Fournit aux utilisateurs le domaine IPv4 pour l'API CURL | | `VITE_CURL_IPV6_DOMAIN` | Non | `""` | Fournit aux utilisateurs le domaine IPv6 pour l'API CURL | diff --git a/README_TR.md b/README_TR.md index 9406e8d29..dcdd4ad40 100644 --- a/README_TR.md +++ b/README_TR.md @@ -142,6 +142,7 @@ MaxMind hesabınızdan `GeoLite2-City.mmdb` ve `GeoLite2-ASN.mmdb` dosyalarını | `IPAPIIS_API_KEY` | Hayır | `""` | IPAPI.is API anahtarı, IP konum bilgisi almak için | | `IP2LOCATION_API_KEY` | Hayır | `""` | IP2Location.io API anahtarı, IP konum bilgisi almak için | | `CLOUDFLARE_API` | Hayır | `""` | Cloudflare API anahtarı, AS sistemi bilgisi almak için | +| `RIPESTAT_SOURCE_APP` | Hayır | `""` | RIPE.net kaynak uygulama adı, RIPE.net üzerinden ASN geçmiş bilgisi almak için | | `MAC_LOOKUP_API_KEY` | Hayır | `""` | MAC Lookup API anahtarı, MAC adresi bilgisi almak için | | `VITE_CURL_IPV4_DOMAIN` | Hayır | `""` | Kullanıcılara CURL API için IPv4 domain sağlar | | `VITE_CURL_IPV6_DOMAIN` | Hayır | `""` | Kullanıcılara CURL API için IPv6 domain sağlar | diff --git a/README_ZH.md b/README_ZH.md index e4d8f8e9b..63dddb62f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -142,6 +142,7 @@ MyIP 依赖 MaxMind 提供的免费 **GeoLite2** 数据库(City + ASN)来进 | `IPAPIIS_API_KEY` | 否 | `""` | IPAPI.is 的 API Key,用于通过 IPAPI.is 获取 IP 归属地信息 | | `IP2LOCATION_API_KEY` | 否 | `""` | IP2Location.io 的 API Key,用于通过 IP2Location.io 获取 IP 归属地信息 | | `CLOUDFLARE_API` | 否 | `""` | Cloudflare 的 API Key,用于通过 Cloudflare 获取 AS 系统的信息 | +| `RIPESTAT_SOURCE_APP` | 否 | `""` | RIPE.net 的源应用名称,用于通过 RIPE.net 获取 ASN 的历史信息 | | `MAC_LOOKUP_API_KEY` | 否 | `""` | MAC 查询的 API Key,用于通过 MAC Lookup 获取 MAC 地址的归属信息 | | `VITE_CURL_IPV4_DOMAIN` | 否 | `""` | 为用户提供 CURL API 的 IPv4 域名 | | `VITE_CURL_IPV6_DOMAIN` | 否 | `""` | 为用户提供 CURL API 的 IPv6 域名 | diff --git a/api/AGENTS.md b/api/AGENTS.md index 96b2ff1a6..aeda073f7 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -68,6 +68,7 @@ common/ - `requireReferer` is mounted globally on `/api/*` in `backend-server.js`. It rejects any request whose `Referer` header isn't on the `ALLOWED_DOMAINS` list (plus `localhost` always). **Handlers must not repeat the referer check.** - `requireValidIP()` is attached per-route to every handler that takes `?ip=`. It rejects missing or malformed IPs before the handler runs. **Handlers must not repeat the IP check** — inside the handler body, `req.query.ip` is already known to be a well-formed string. +- `requireValidPrefix()` is the same pattern for `?prefix=` (CIDR-shaped param). Used by `asn-history` so the frontend can quantize the user's IP to its BGP DFZ-floor (/24 v4 or /48 v6) before the request lands, maximizing CF edge cache reuse across every IP in the same prefix. - If you add a new handler that needs a different-shape param guard, add the guard to `common/guards.js` and attach it in `backend-server.js` rather than open-coding the check in the handler. ### Private-API header pass-through (intentional exception) @@ -84,6 +85,19 @@ This is deliberate — the upstream expects caller context (Accept-Language, aut Some handlers keep a `req.method !== 'GET'` (or `PUT`) branch even though Express routes already gate the method. These exist because dedicated smoke tests assert on that branch directly against the handler. If you add or copy a handler, leave the defensive gate in place if a test covers it. +## Edge caching + +Default: every `/api/*` response gets `Cache-Control: no-store` from the global middleware in `backend-server.js`. Routes that serve slowly-changing public data opt in to Cloudflare edge caching by attaching the `cacheable(maxAgeSeconds)` middleware (defined in `backend-server.js`): + +```js +app.get('/api/cfradar', cacheable(60 * 60), cfHander); +app.get('/api/asn-history', requireValidPrefix(), cacheable(24 * 60 * 60), asnHistoryHandler); +``` + +Write the TTL as a multiplied expression (`60 * 60` / `24 * 60 * 60` / `30 * 24 * 60 * 60`) rather than a raw second count — the intent is self-evident at a glance, and JS folds the constant at call time so there's no runtime cost. + +The middleware hooks `res.json` and only sets `Cache-Control: public, max-age=N` when the response status is < 400 — error responses keep `no-store` so CF never caches a 4xx/5xx page. Handlers themselves stay pure and don't touch `Cache-Control`. **Auth'd or per-user endpoints** (`ipchecking` / `invisibility` / `dns-leak-test/session` / `getuserinfo` / etc.) must not be wrapped with `cacheable` — their caching belongs at the upstream service that owns the auth context. + ## Testing - Smoke tests for every handler live in `tests/api-handlers.test.js`. They cover method gating, param presence / validity (beyond what middleware handles), and the "API key missing" early-return branches. diff --git a/api/asn-history.js b/api/asn-history.js new file mode 100644 index 000000000..ab1400f82 --- /dev/null +++ b/api/asn-history.js @@ -0,0 +1,124 @@ +// /api/asn-history — RIPEstat routing-history for a CIDR prefix (the +// frontend quantizes the user's IP to /24 v4 or /48 v6 first, so all IPs +// in the same prefix collapse to one CF edge cache entry). Org names per +// ASN come from RIPEstat as-overview, fetched in parallel, best-effort. + +import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; + +const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10); + +// BGP-meaningful prefix floor per family. Shorter prefixes are leaks / +// default routes that happen to cover the IP but don't attribute it. +const MIN_PREFIX = { v4: 8, v6: 19 }; + +// Below this peer count an announcement is route noise / brief misconfig. +const MIN_PEERS = 30; + +// RIPEstat polite-citizen marker; overridable per deployment. +const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip'; + +// Shorter timeout for the org enrichment calls — they're best-effort, we'd +// rather return ASN-only history than make the user wait the full upstream +// timeout for a slow secondary lookup. +const ORG_FETCH_TIMEOUT_MS = 8000; + +function summarizeOrigin(entry, minLen) { + const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= minLen); + if (acceptedPrefixes.length === 0) return null; + + const allTimes = []; + for (const p of acceptedPrefixes) { + for (const t of p.timelines || []) allTimes.push(t); + } + if (allTimes.length === 0) return null; + + let firstSeen = allTimes[0].starttime; + let lastSeen = allTimes[0].endtime; + let maxPeers = 0; + for (const t of allTimes) { + if (t.starttime < firstSeen) firstSeen = t.starttime; + if (t.endtime > lastSeen) lastSeen = t.endtime; + if ((t.full_peers_seeing || 0) > maxPeers) maxPeers = t.full_peers_seeing; + } + + if (maxPeers < MIN_PEERS) return null; + + return { + asn: String(entry.origin), + org: null, + firstSeen, + lastSeen, + peers: Math.round(maxPeers), + prefixes: acceptedPrefixes.map(p => p.prefix), + }; +} + +// Holder is typically " - , "; strip the leading handle. +function extractOrgFromHolder(holder) { + if (!holder || typeof holder !== 'string') return null; + const dashIdx = holder.indexOf(' - '); + return dashIdx > 0 ? holder.slice(dashIdx + 3).trim() : holder.trim(); +} + +// Best-effort. Any failure (timeout, non-2xx, parse error) yields null so the +// parent Promise.all never rejects and the row falls back to ASN-only display. +async function fetchAsOrgName(asn) { + try { + const url = `https://stat.ripe.net/data/as-overview/data.json` + + `?resource=AS${encodeURIComponent(asn)}&sourceapp=${SOURCE_APP}`; + const res = await fetchUpstream(url, { timeoutMs: ORG_FETCH_TIMEOUT_MS }); + 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; + } +} + +export default async (req, res) => { + // Prefix presence + validity guaranteed by requireValidPrefix middleware. + // Frontend quantizes the user's IP to /24 (v4) or /48 (v6) so every IP in + // the same prefix collapses to one cache entry at CF's edge. + const prefix = req.query.prefix; + const family = prefix.includes(':') ? 'v6' : 'v4'; + const minLen = MIN_PREFIX[family]; + + try { + const url = `https://stat.ripe.net/data/routing-history/data.json` + + `?resource=${encodeURIComponent(prefix)}&sourceapp=${SOURCE_APP}`; + const apiRes = await fetchUpstream(url); + if (!apiRes.ok) { + logger.warn({ prefix, status: apiRes.status }, 'RIPEstat routing-history non-2xx'); + return res.status(502).json({ error: 'Upstream error' }); + } + const payload = await apiRes.json(); + const origins = payload?.data?.by_origin || []; + + const history = origins + .map(entry => summarizeOrigin(entry, minLen)) + .filter(Boolean) + .sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || '')); + + // Org enrichment is strictly best-effort: anything that goes wrong here + // leaves rows with org=null, but the ASN-keyed timeline still ships. + try { + const uniqueAsns = [...new Set(history.map(row => row.asn))]; + const orgPairs = await Promise.all( + uniqueAsns.map(async asn => [asn, await fetchAsOrgName(asn)]) + ); + const orgByAsn = Object.fromEntries(orgPairs); + for (const row of history) { + row.org = orgByAsn[row.asn] || null; + } + } catch (error) { + logger.warn({ err: error, prefix }, 'as-overview batch failed; returning ASN-only history'); + } + + res.json({ prefix, history }); + } catch (error) { + logger.error({ err: error, prefix }, 'asn-history handler failed'); + res.status(500).json({ error: error.message }); + } +}; diff --git a/backend-server.js b/backend-server.js index 31ba7f760..cf330a84e 100644 --- a/backend-server.js +++ b/backend-server.js @@ -7,7 +7,7 @@ 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'; +import { requireReferer, requireValidIP, requireValidPrefix } from './common/guards.js'; // Backend APIs import mapHandler from './api/google-map.js'; @@ -21,6 +21,7 @@ import ipsbHandler from './api/ip-sb.js'; import maxmindHandler from './api/maxmind.js'; // Others import cfHander from './api/cf-radar.js'; +import asnHistoryHandler from './api/asn-history.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'; @@ -64,31 +65,29 @@ if (process.env.LOG_HTTP === 'true') { 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 + const cfIp = req.headers['cf-connecting-ip']; const forwardedIps = req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0] : null; const cfIpV6 = req.headers['cf-connecting-ipv6']; return cfIp || forwardedIps || cfIpV6 || req.ip; } -// Format timestamp for rate limit log using Shanghai time zone +// Shanghai TZ — fixed for log consistency across deployments regardless of host locale. function formatDate(timestamp) { return new Date(timestamp).toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }); } -// Write IP that triggered the limit to the log and count the number of times the same IP was limited +// Append-or-update one line in the rate-limit log, keeping the original +// timestamp on repeat offenders so we can see when an IP *first* showed up. function logLimitedIP(ip) { const logPath = path.join(__dirname, blackListIPLogFilePath); - // If logs directory does not exist, create it const logDir = path.dirname(logPath); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); 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') { logger.error({ err }, 'Error reading the log file'); @@ -108,7 +107,7 @@ function logLimitedIP(ip) { newCount = parseInt(count, 10) + 1; logExists = true; logger.warn({ ip, count: newCount }, 'Rate-limited IP hit again'); - return `${ip},${newCount},${timestamp}`; // Update count but keep the original timestamp + return `${ip},${newCount},${timestamp}`; } return line; }).join('\n'); @@ -132,9 +131,11 @@ const rateLimiter = rateLimit({ windowMs: 20 * 60 * 1000, max: rateLimitSet, message: 'Too Many Requests', - // Handle requests that exceed the rate limit threshold, and record the IP that triggered the limit as needed handler: (req, res, next) => { const ip = getClientIp(req); + // Log on the exact transition into rate-limited state — not every + // blocked request — to avoid log flooding when an abusive client + // keeps hammering after being limited. if (req.rateLimit.current === req.rateLimit.limit + 1 && blackListIPLogFilePath) { logLimitedIP(ip); } @@ -145,17 +146,14 @@ const rateLimiter = rateLimit({ const speedLimiter = slowDown({ windowMs: 60 * 60 * 1000, delayAfter: speedLimitSet, - // Increase response delay gradually based on the number of hits delayMs: (hits) => hits * 400, }) -// If rateLimitSet is 0, do not enable rate limiting if (rateLimitSet !== 0) { app.use('/api', rateLimiter); 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); logger.info(`🐢 Speed limiter enabled — slow down after ${speedLimitSet} requests`); @@ -163,30 +161,55 @@ if (speedLimitSet !== 0) { app.use(express.json()); +// Default every /api/* response to no-store. Routes that want edge caching +// declare it explicitly via the `cacheable(maxAge)` middleware below. +app.use('/api', (req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); +}); + +// Cache-Control middleware factory. Hooks res.json so the header is only +// attached on 2xx — CF must not cache 4xx/5xx error pages. Binary streams +// (res.send) bypass this and must set their own header if needed. +const cacheable = (maxAgeSeconds) => (req, res, next) => { + const originalJson = res.json.bind(res); + res.json = function (body) { + if (res.statusCode < 400) { + res.setHeader('Cache-Control', `public, max-age=${maxAgeSeconds}`); + } + return originalJson(body); + }; + next(); +}; + // Global referer gate for all /api/* routes. Handlers no longer repeat this // check individually — see common/guards.js. app.use('/api', requireReferer); -// APIs. Routes that validate an `?ip=` param attach requireValidIP() so the -// handler body no longer repeats the check. +const ONE_HOUR_CACHE = 60 * 60; +const ONE_DAY_CACHE = 24 * 60 * 60; +const THIRTY_DAYS_CACHE = 30 * 24 * 60 * 60; + +// Cacheable routes — TTLs picked against each upstream's natural refresh cadence. +app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipinfoHandler); +app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapicomHandler); +app.get('/api/ipsb', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipsbHandler); +app.get('/api/cfradar', cacheable(ONE_DAY_CACHE), cfHander); +app.get('/api/asn-history', requireValidPrefix(), cacheable(ONE_DAY_CACHE), asnHistoryHandler); +app.get('/api/whois', cacheable(ONE_HOUR_CACHE), getWhois); +app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapiisHandler); +app.get('/api/ip2location', requireValidIP(), cacheable(ONE_HOUR_CACHE), ip2locationHandler); +app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); +app.get('/api/maxmind', requireValidIP(), cacheable(ONE_DAY_CACHE), maxmindHandler); + +// Non-cacheable routes — auth-context, debug tools, or per-request lookups. app.get('/api/map', mapHandler); -app.get('/api/ipinfo', requireValidIP(), ipinfoHandler); -app.get('/api/ipapicom', requireValidIP(), ipapicomHandler); 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); app.get('/api/invisibility', invisibilitytestHandler); -app.get('/api/macchecker', macChecker); -app.get('/api/maxmind', requireValidIP(), maxmindHandler); app.get('/api/getuserinfo', getUserinfo); app.put('/api/updateuserachievement', updateUserAchievement); - -// Handle all configuration requests using query parameters app.get('/api/configs', validateConfigs); // Set static file server @@ -195,18 +218,9 @@ const __dirname = path.dirname(__filename); app.use(express.static(path.join(__dirname, './dist'))); -// 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. +// Bootstrap MaxMind before accepting traffic so we never serve mid-download. +// Each step is non-fatal: a failure here leaves the MaxMind API returning +// 503 until the watcher picks up valid databases later. async function bootBackend() { await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases }); @@ -218,7 +232,6 @@ async function bootBackend() { 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}`); }); } diff --git a/common/bgp-prefix.js b/common/bgp-prefix.js new file mode 100644 index 000000000..eff654758 --- /dev/null +++ b/common/bgp-prefix.js @@ -0,0 +1,75 @@ +// BGP-prefix helpers. Routing on the public internet happens at prefix +// granularity, not per-IP — the default-free zone won't accept anything +// smaller than /24 (IPv4) or /48 (IPv6). Quantizing a query IP down to +// that floor before hitting the upstream lets Cloudflare's edge dedupe +// asn-history responses across every IP in the same prefix. + +import { isValidIP } from './valid-ip.js'; + +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; + +// Expand an IPv6 address (with optional `::` compression) into 8 hextet +// strings. Returns null on any structural failure. +function expandIPv6(ip) { + const halves = ip.split('::'); + if (halves.length > 2) return null; + + const hasShorthand = halves.length === 2; + const left = halves[0] ? halves[0].split(':') : []; + const right = hasShorthand && halves[1] ? halves[1].split(':') : []; + const explicit = left.length + right.length; + + if (!hasShorthand && explicit !== 8) return null; + if (hasShorthand && explicit > 7) return null; // `::` must elide at least one group + + const gap = hasShorthand ? 8 - explicit : 0; + const hextets = [...left, ...Array(gap).fill('0'), ...right]; + + if (hextets.length !== 8) return null; + return hextets.every(h => HEX_GROUP.test(h)) ? hextets : null; +} + +/** + * Quantize a single IP to its BGP DFZ floor prefix. + * IPv4 → /24 (e.g. `8.8.8.8` → `8.8.8.0/24`) + * IPv6 → /48 (e.g. `2001:4860:4860::8888` → `2001:4860:4860::/48`) + * Returns null when input is not a recognizable IP. + */ +export function toBgpPrefix(ip) { + if (!isValidIP(ip)) return null; + + if (ip.includes('.') && !ip.includes(':')) { + const [a, b, c] = ip.split('.'); + return `${a}.${b}.${c}.0/24`; + } + + const hextets = expandIPv6(ip); + if (!hextets) return null; + // Canonicalize each kept hextet to lowercase without leading zeros so + // semantically identical inputs map to the same cache key. `0x0001` and + // `1` would otherwise collide as different URLs at the CF edge. + const first3 = hextets.slice(0, 3).map(h => parseInt(h, 16).toString(16)); + return `${first3[0]}:${first3[1]}:${first3[2]}::/48`; +} + +/** + * Accept any well-formed CIDR string (IP + length within family bounds). + * Intentionally not strict about the specific length — the frontend decides + * the quantization policy; the guard just rejects junk. + */ +export function isValidBgpPrefix(prefix) { + if (typeof prefix !== 'string') return false; + const slash = prefix.indexOf('/'); + if (slash <= 0 || slash === prefix.length - 1) return false; + + const address = prefix.slice(0, slash); + const lengthStr = prefix.slice(slash + 1); + if (!/^\d+$/.test(lengthStr)) return false; + + if (!isValidIP(address)) return false; + + const length = Number(lengthStr); + const isV6 = address.includes(':'); + const max = isV6 ? 128 : 32; + return length >= 0 && length <= max; +} diff --git a/common/guards.js b/common/guards.js index e72e62962..33d9d0935 100644 --- a/common/guards.js +++ b/common/guards.js @@ -5,6 +5,7 @@ import { refererCheck } from './referer-check.js'; import { isValidIP } from './valid-ip.js'; +import { isValidBgpPrefix } from './bgp-prefix.js'; // Reject requests without an allowed referer. The error message variant // preserves the existing user-facing wording. @@ -31,3 +32,17 @@ export const requireValidIP = (paramName = 'ip') => (req, res, next) => { } next(); }; + +// Reject requests without a valid CIDR prefix in the specified query param. +// Accepts any well-formed CIDR — the quantization policy (e.g. /24 for v4, +// /48 for v6) is the frontend's job, not the guard's. +export const requireValidPrefix = (paramName = 'prefix') => (req, res, next) => { + const prefix = req.query[paramName]; + if (!prefix) { + return res.status(400).json({ error: 'No prefix provided' }); + } + if (!isValidBgpPrefix(prefix)) { + return res.status(400).json({ error: 'Invalid prefix' }); + } + next(); +}; diff --git a/frontend-server.js b/frontend-server.js index 9535aca30..45f1c9fe5 100644 --- a/frontend-server.js +++ b/frontend-server.js @@ -21,8 +21,33 @@ frontendApp.use('/api', createProxyMiddleware({ changeOrigin: true })); -// Set static file directory -frontendApp.use(express.static(path.join(__dirname, './dist'))); +// Set static file directory. +// Cache-Control is set per-asset class so the static layer behaves well +// even when no CDN sits in front of it (CF in production sets its own +// Browser TTL on top of these, so the longer values are upper bounds): +// - dist/assets/** Vite-hashed JS/CSS/images — content-addressed → 1y immutable +// - dist/fonts/** non-hashed but essentially never change → 1y immutable +// - top-level images favicon / logos / achievements / … → 24h +// - index.html + manifest never cache — otherwise stale HTML keeps pointing +// at a hashed chunk that no longer exists post-deploy +// - everything else (robots.txt, …) → 1h +const distDir = path.join(__dirname, './dist'); + +function setStaticHeaders(res, filePath) { + const rel = path.relative(distDir, filePath).replaceAll(path.sep, '/'); + + if (rel.startsWith('assets/') || rel.startsWith('fonts/')) { + res.setHeader('Cache-Control', `public, max-age=${24 * 60 * 60 * 365}, immutable`); + } else if (/\.(png|jpg|jpeg|webp|svg|ico)$/i.test(rel)) { + res.setHeader('Cache-Control', `public, max-age=${24 * 60 * 60}`); + } else if (rel.endsWith('.html') || rel === 'manifest.webmanifest') { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + } else { + res.setHeader('Cache-Control', `public, max-age=${60 * 60}`); + } +} + +frontendApp.use(express.static(distDir, { setHeaders: setStaticHeaders })); // Start static file server frontendApp.listen(frontEndPort, () => { diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index fdf37ec16..6fd9e5cb1 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -15,7 +15,6 @@ frontend/ ├── main.js ← app bootstrap + global init ├── store.js ← Pinia main store ├── firebase-init.js ← Firebase Auth env-gated init -├── sw.js ← Serwist service worker ├── router/ ← Advanced Tools subroutes (open inside a Drawer) ├── locales/ ← i18n copy (en / zh / fr / tr) + security-checklist data ├── style/style.css ← Tailwind v4 entry + design tokens diff --git a/frontend/components/Additional.vue b/frontend/components/Additional.vue index 50b0c9770..3eaceb7c1 100644 --- a/frontend/components/Additional.vue +++ b/frontend/components/Additional.vue @@ -15,24 +15,21 @@

geo {{ t('curl.Note3') }}

-

- YOUR_API_KEY {{ t('curl.Note4') }} -

{{ t('curl.getIPv4') }}

-
curl {{ ipv4Domain }}/geo -H 'x-key: YOUR_API_KEY'
+
curl {{ ipv4Domain }}/geo

{{ t('curl.getIPv6') }}

-
curl {{ ipv6Domain }}/geo -H 'x-key: YOUR_API_KEY'
+
curl {{ ipv6Domain }}/geo

{{ t('curl.get6and4') }}

-
curl {{ ipv64Domain }}/geo -H 'x-key: YOUR_API_KEY'
+
curl {{ ipv64Domain }}/geo
diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index d7efb6ed6..cce78aa05 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -1,7 +1,5 @@ @@ -97,7 +97,8 @@ const props = defineProps({ isCardsCollapsed: { type: Boolean, required: true }, copiedStatus: { type: Object, required: true }, configs: { type: Object, required: true }, - asnInfos: { type: Object, required: true } + asnInfos: { type: Object, required: true }, + asnHistoryInfos: { type: Object, default: () => ({}) } }); defineEmits(['refresh-card']); diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index dc8d002b7..7cd32da3e 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -38,7 +38,7 @@ {{ data.city || '—' }} @@ -133,26 +133,43 @@ - -
+ +
{{ t('ipInfos.ASN') }} {{ data.asn }}
- - - +
+ + + + + + + + +
- - - + + +
+ + +
@@ -196,23 +213,26 @@ import { ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/use-analytics'; import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; +import { toBgpPrefix } from '@/utils/bgp-prefix.js'; import ASNInfo from './ASNInfo.vue'; +import ASNHistory from './ASNHistory.vue'; import { JnTooltip } from '@/components/ui/tooltip'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Progress } from '@/components/ui/progress'; import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; import { Icon } from '@iconify/vue'; import { Earth } from 'lucide-vue-next'; import { Building2, - ChevronDown, - ChevronUp, CircleCheck, CircleX, CornerUpRight, EthernetPort, Gauge, + History, House, + Info, Lock, Map, MapPin, @@ -226,6 +246,8 @@ const props = defineProps({ data: { type: Object, required: true }, ipGeoSource: { type: Number, required: true }, asnInfos: { type: Object, required: true }, + // Optional — keyed by IP. IpInfos owns the shared map; QueryIP falls back to its own. + asnHistoryInfos: { type: Object, default: () => ({}) }, configs: { type: Object, required: true }, isDarkMode: { type: Boolean, required: true }, // ASNInfo requires an index; homepage cards pass their grid index, QueryIP has nothing meaningful. @@ -237,7 +259,11 @@ const props = defineProps({ enableMap: { type: Boolean, default: false }, }); -const isAsnOpen = ref(false); +// Single-select panel content for the ASN block: 'info' | 'history' | null. +// Kept separate from the open state so close animations retain their content +// until Collapsible finishes measuring and animating the closing height. +const activePanel = ref(null); +const isPanelOpen = ref(false); const isMapDialogOpen = ref(false); // Advanced block only surfaces for the IPCheck.ing source (ipGeoSource === 0). @@ -295,13 +321,37 @@ const openMapDialog = () => { trackEvent('IPCheck', 'ViewOnMapClick', props.data.source || 'unknown'); }; -const toggleASNCollapse = async (asn) => { - isAsnOpen.value = !isAsnOpen.value; - if (isAsnOpen.value) { - await getASNInfo(asn); +// BGP DFZ-floor prefix for the IP — /24 v4, /48 v6. Used both as the query +// param sent to /api/asn-history (so CF dedupes across every IP in the same +// prefix) and as the local session-cache key. +const ipPrefix = computed(() => toBgpPrefix(props.data.ip)); + +// Toggle the panel for `name`. Clicking the already-active button collapses +// the panel entirely; clicking the inactive one switches view and lazily +// triggers its data fetch. Session caches (asnInfos / asnHistoryInfos) make +// the switch instant on the second visit. +const togglePanel = async (name) => { + if (isPanelOpen.value && activePanel.value === name) { + isPanelOpen.value = false; + return; + } + activePanel.value = name; + isPanelOpen.value = true; + if (name === 'info') { + await getASNInfo(props.data.asn); + } else if (name === 'history' && ipPrefix.value) { + await getASNHistory(ipPrefix.value); } }; +const isPanelActive = (name) => isPanelOpen.value && activePanel.value === name; + +// Collapsible's controlled mode can emit open-state updates from its internals; +// mirror those without clearing the selected content before the close animation. +const onPanelOpenChange = (open) => { + isPanelOpen.value = open; +}; + const getASNInfo = async (asn) => { trackEvent('IPCheck', 'ASNInfoClick', 'Show ASN Info'); try { @@ -314,4 +364,24 @@ const getASNInfo = async (asn) => { console.error('Error fetching ASN info:', error); } }; + +const getASNHistory = async (prefix) => { + trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History'); + try { + if (props.asnHistoryInfos[prefix]) return; + const response = await fetchWithTimeout( + `/api/asn-history?prefix=${encodeURIComponent(prefix)}`, + { timeoutMs: 10000 } + ); + if (!response.ok) { + props.asnHistoryInfos[prefix] = { error: true }; + return; + } + const data = await response.json(); + props.asnHistoryInfos[prefix] = data; + } catch (error) { + console.error('Error fetching ASN history:', error); + props.asnHistoryInfos[prefix] = { error: true }; + } +}; diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index 594eadeab..d75ac158c 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -100,11 +100,11 @@ :tip="t('nav.preferences.autoRunTips')" :model-value="userPreferences.autoStart" @update:model-value="prefAutoStart" /> - + { trackEvent('Nav', 'PrefereceClick', 'LanguageChange'); }; -const prefConnectivityRefresh = (value) => { - store.updatePreference('connectivityAutoRefresh', value); +const prefConnectivityMultipleTests = (value) => { + store.updatePreference('connectivityMultipleTests', value); if (isSignedIn.value && !store.userAchievements.ResourceHog.achieved) { store.setTriggerUpdateAchievements('ResourceHog'); } - trackEvent('Nav', 'PrefereceClick', 'ConnectivityRefresh'); + trackEvent('Nav', 'PrefereceClick', 'ConnectivityMultipleTests'); }; const prefSimpleMode = (value) => { diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index 58ccd83fb..1f5f1519e 100644 --- a/frontend/components/widgets/QueryIP.vue +++ b/frontend/components/widgets/QueryIP.vue @@ -46,7 +46,8 @@
+ :asn-history-infos="asnHistoryInfos" :configs="configs" :is-dark-mode="isDarkMode" + :enable-map="false" /> @@ -58,7 +59,7 @@ // Differences from IPCard: // - No Copy button (the IP was typed by the user — copying it is pointless). // - No Map button (Dialog-in-Dialog stacking is avoided; enableMap=false). -// - Own asnInfos cache (local to this component; not shared with IPCard). +// - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard). import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'; import { useMainStore } from '@/store'; import { isValidIP } from '@/utils/valid-ip.js'; @@ -90,6 +91,7 @@ const modalQueryError = ref(''); const isChecking = ref('idle'); const ipGeoSource = ref(userPreferences.value.ipGeoSource); const asnInfos = ref({}); +const asnHistoryInfos = ref({}); watch(() => userPreferences.value.ipGeoSource, (newVal) => { ipGeoSource.value = newVal; @@ -138,23 +140,38 @@ const openQueryIP = () => { const openModal = () => onOpenChange(true); -const fetchIPForModal = async (ip, sourceID = null) => { - let selectedLang = lang.value === 'zh' ? 'zh-CN' : lang.value; - sourceID = ipGeoSource.value; - const sources = store.ipDBs; - - for (const source of sources) { - if (sourceID && source.id !== sourceID) continue; +const fetchIPForModal = async (ip) => { + const selectedLang = lang.value === 'zh' ? 'zh-CN' : lang.value; + const sources = store.ipDBs.filter(s => s.enabled); + + // Cyclic walk from the user's preferred source + // Fallback fires only on real failures (network error, 4xx/5xx, + // transform throwing). A 200 response that contains "N/A" fields + // means the upstream is healthy but doesn't know the IP — display + // those as-is rather than walking to the next source. + const startIdx = Math.max(0, sources.findIndex(s => s.id === ipGeoSource.value)); + let currentIdx = startIdx; + let attempts = 0; + + while (attempts < sources.length) { + const source = sources[currentIdx]; try { const url = store.getDbUrl(source.id, ip, selectedLang); const response = await authenticatedFetch(url); - modalQueryResult.value = transformDataFromIPapi(response, source.id, t, lang.value); + modalQueryResult.value = { ...transformDataFromIPapi(response, source.id, t, lang.value), ip }; isChecking.value = 'idle'; - break; + return; } catch (error) { - console.error('Error fetching IP details:', error); + console.error(`Error fetching IP details from source ${source.id}:`, error); + currentIdx = (currentIdx + 1) % sources.length; + attempts++; } } + + // Every source exhausted with real failures — surface a user-facing + // error instead of leaving the spinner running forever. + isChecking.value = 'idle'; + modalQueryError.value = t('ipcheck.NoData'); }; // Floating button positioning (align to content area right on wide screen) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 56ad2770e..223f047d0 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1090,5 +1090,38 @@ } } ] + }, + { + "version": "v6.2.0", + "date": "May 12, 2026", + "content": [ + { + "type": "add", + "change": { + "en": "Now you can view the historical ASN records of an IP", + "zh": "可以查看一个 IP 过往的所有 ASN 记录", + "fr": "Vous pouvez maintenant voir l'historique des ASN d'une IP", + "tr": "Bir IP'in geçmiş ASN kayıtlarını görüntüleyebilirsiniz" + } + }, + { + "type": "improve", + "change": { + "en": "Optimized Connectivity Test to show multiple test progress", + "zh": "优化网络连通性测试,显示多次测试进度", + "fr": "Optimisation du test de connectivité pour afficher le progrès des tests multiples", + "tr": "Ağ bağlantı testi çoklu test ilerlemesini görüntüleme iyileştirildi" + } + }, + { + "type": "fix", + "change": { + "en": "Fixed a bug that caused the notification to fire too early when running multiple connectivity tests", + "zh": "修复运行多次网络连通性测试时,通知时机错误的问题", + "fr": "Correction d'un bug causant la notification à se déclencher trop tôt lors de l'exécution de plusieurs tests de connectivité", + "tr": "Birden fazla ağ bağlantı testi çalıştırılırken bildirimin erken tetiklenmesine neden olan bir hata düzeltildi" + } + } + ] } ] \ No newline at end of file diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index 9df57e0ba..ced79b608 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -5,7 +5,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({ theme: 'auto', // auto | light | dark - connectivityAutoRefresh: false, + connectivityMultipleTests: false, simpleMode: false, autoStart: true, hideUnavailableIPStack: false, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 06eb94dd7..870df0fcc 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -162,8 +162,8 @@ "autoRunTips": "If disabled, the app will only check the local IP and will not run tests automatically.", "simpleMode": "Simple Mode", "simpleModeTips": "Reduces the information displayed on IP cards. Effective on mobile devices only.", - "connectivityAutoRefresh": "Multiple Connectivity Refreshes", - "connectivityAutoRefreshTips": "When enabled, the app runs five checks at startup and displays the lowest latency.", + "connectivityMultipleTests": "Multiple Connectivity Refreshes", + "connectivityMultipleTestsTips": "When enabled, the app runs multiple checks at startup and displays the lowest latency.", "popupConnectivityNotifications": "Pop-up Connectivity Alerts", "popupConnectivityNotificationsTips": "When enabled, the initial check's results are shown as a pop-up alert.", "ipDB": "IP Geolocation Database", @@ -501,6 +501,7 @@ "SpeedTestButton": "Start/Pause Speed Test", "SourceSelect": "Select IP Geolocation Source", "ShowASNInfo": "Show AS Details", + "ShowASNHistory": "Show ASN History", "CopyIP": "Copy IP Address", "ViewOnMap": "View location on map", "InfoMask": "Hide IP Information", @@ -550,6 +551,13 @@ "Human_Pct": "Human", "moreData": "More Data : " }, + "ASNHistory": { + "announcedPrefixes": "Announced Prefixes: ", + "note": "Historical ASN announcements for this IP: ", + "empty": "No historical routing data found for this IP.", + "error": "Failed to load ASN history.", + "seenBy": "Seen by {peers} BGP full-feed peers" + }, "advancedData": { "proxyYes": "Must be a proxy or VPN", "proxyMaybe": "Possibly a proxy or VPN", @@ -579,6 +587,7 @@ "checking": "Checking...", "minTestTime": "Min Test Time: ", "RefreshThisTest": "Refresh This Test", + "RoundCount": "Round {n}: ", "addCustom": { "AddCard": "Add Test", "Title": "Add a custom test", @@ -680,7 +689,8 @@ "id": "ipcheck", "Title": "IP Check", "Placeholder": "e.g. 1.1.1.1", - "Error": "Please enter a valid IPv4 or IPv6 address." + "Error": "Please enter a valid IPv4 or IPv6 address.", + "NoData": "Unable to fetch information for this IP. It may be unroutable (e.g. 0.0.0.0, private address) or the upstream source has no data for it." }, "alert": { "id": "alert", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 4bae2e4fe..88d045c0b 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -162,8 +162,8 @@ "autoRunTips": "Si désactivé, l'appli vérifiera seulement l'IP local et n'exécutera pas de tests automatiquement.", "simpleMode": "Mode Simple", "simpleModeTips": "Réduit les informations affichées sur les cartes IP. Effectif uniquement sur les appareils mobiles.", - "connectivityAutoRefresh": "Rafraîchissements Multiples de Connectivité", - "connectivityAutoRefreshTips": "Activé, l'appli effectue cinq vérifications au démarrage et affiche le délai le plus bas.", + "connectivityMultipleTests": "Rafraîchissements Multiples de Connectivité", + "connectivityMultipleTestsTips": "Activé, l'appli effectue plusieurs vérifications au démarrage et affiche le délai le plus bas.", "popupConnectivityNotifications": "Alertes de Connectivité Pop-up", "popupConnectivityNotificationsTips": "Activé, les résultats de la première vérification sont affichés sous forme d'alerte pop-up.", "ipDB": "Base de données de géolocalisation IP", @@ -501,6 +501,7 @@ "SpeedTestButton": "Démarrer/Pause le test de vitesse", "SourceSelect": "Sélectionner la source de géolocalisation IP", "ShowASNInfo": "Afficher les détails AS", + "ShowASNHistory": "Afficher l'historique ASN", "CopyIP": "Copier l'adresse IP", "ViewOnMap": "Voir l'emplacement sur la carte", "InfoMask": "Masquer les informations IP", @@ -550,6 +551,13 @@ "Human_Pct": "Humain : ", "moreData": "Plus de données : " }, + "ASNHistory": { + "announcedPrefixes": "Préfixes annoncés : ", + "note": "Annonces ASN historiques pour cette IP : ", + "empty": "Aucune donnée de routage historique trouvée pour cette IP.", + "error": "Échec du chargement de l'historique ASN.", + "seenBy": "Vu par {peers} pairs BGP full-feed" + }, "advancedData": { "proxyYes": "Doit être un proxy ou un VPN", "proxyMaybe": "Possiblement un proxy ou un VPN", @@ -579,6 +587,7 @@ "checking": "Vérification en cours...", "minTestTime": "Minimal: ", "RefreshThisTest": "Rafraîchir ce test", + "RoundCount": "Tour {n} : ", "addCustom": { "AddCard": "Ajouter un test", "Title": "Ajouter un test personnalisé", @@ -680,7 +689,8 @@ "id": "ipcheck", "Title": "Vérification IP", "Placeholder": "ex. 1.1.1.1", - "Error": "Veuillez entrer une adresse IPv4 ou IPv6 valide." + "Error": "Veuillez entrer une adresse IPv4 ou IPv6 valide.", + "NoData": "Impossible de récupérer les informations pour cette IP. Elle peut être non routable (ex. 0.0.0.0, adresse privée) ou la source amont n'a pas de données pour celle-ci." }, "alert": { "id": "alert", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index f6d55dfaf..c3417c5ca 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -162,8 +162,8 @@ "autoRunTips": "Devre dışı bırakılırsa, uygulama yalnızca yerel IP'yi kontrol eder ve testleri otomatik olarak çalıştırmaz.", "simpleMode": "Basit Mod", "simpleModeTips": "IP kartlarında görüntülenen bilgiyi azaltır. Yalnızca mobil cihazlarda etkilidir.", - "connectivityAutoRefresh": "Çoklu Bağlantı Yenilemeleri", - "connectivityAutoRefreshTips": "Etkinleştirildiğinde, uygulama başlangıçta beş kontrol çalıştırır ve en düşük gecikmeyi görüntüler.", + "connectivityMultipleTests": "Çoklu Bağlantı Yenilemeleri", + "connectivityMultipleTestsTips": "Etkinleştirildiğinde, uygulama başlangıçta birden fazla kontrol çalıştırır ve en düşük gecikmeyi görüntüler.", "popupConnectivityNotifications": "Açılır Bağlantı Uyarıları", "popupConnectivityNotificationsTips": "Etkinleştirildiğinde, ilk kontrolün sonuçları açılır bir uyarı olarak gösterilir.", "ipDB": "IP Coğrafi Konum Veritabanı", @@ -500,6 +500,7 @@ "SpeedTestButton": "Hız Testini Başlat/Duraklat", "SourceSelect": "IP Coğrafi Konum Kaynağını Seç", "ShowASNInfo": "AS Detaylarını Göster", + "ShowASNHistory": "ASN Geçmişini Göster", "CopyIP": "IP Adresini Kopyala", "ViewOnMap": "Konumu haritada görüntüle", "InfoMask": "IP Bilgilerini Gizle", @@ -549,6 +550,13 @@ "Human_Pct": "İnsan : ", "moreData": "Daha Fazla Veri : " }, + "ASNHistory": { + "announcedPrefixes": "Duyurulan Önekler : ", + "note": "Bu IP için geçmiş ASN duyuruları : ", + "empty": "Bu IP için geçmiş yönlendirme verisi bulunamadı.", + "error": "ASN geçmişi yüklenemedi.", + "seenBy": "{peers} BGP full-feed eşi tarafından görüldü" + }, "advancedData": { "proxyYes": "Kesinlikle bir proxy veya VPN", "proxyMaybe": "Muhtemelen bir proxy veya VPN", @@ -578,6 +586,7 @@ "checking": "Kontrol ediliyor...", "minTestTime": "Min Test Süresi: ", "RefreshThisTest": "Bu Testi Yenile", + "RoundCount": "Tur {n}: ", "addCustom": { "AddCard": "Test Ekle", "Title": "Özel test ekle", @@ -679,7 +688,8 @@ "id": "ipcheck", "Title": "IP Kontrolü", "Placeholder": "örn. 1.1.1.1", - "Error": "Lütfen geçerli bir IPv4 veya IPv6 adresi girin." + "Error": "Lütfen geçerli bir IPv4 veya IPv6 adresi girin.", + "NoData": "Bu IP için bilgi alınamadı. Yönlendirilemez bir adres olabilir (örn. 0.0.0.0, özel adres) veya kaynak bu IP için veri bulamadı." }, "alert": { "id": "alert", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 987a88d61..eb500e84c 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -162,8 +162,8 @@ "autoRunTips": "关闭后,打开应用将只会进行本机 IP 检测,不会自动运行其它的测试。", "simpleMode": "简洁模式", "simpleModeTips": "减少 IP 卡片显示的内容。仅在手机上生效。", - "connectivityAutoRefresh": "多次刷新可用性检测", - "connectivityAutoRefreshTips": "开启多次刷新后,程序在启动时将运行 5 次检测,并显示最小延迟值。", + "connectivityMultipleTests": "多次刷新可用性检测", + "connectivityMultipleTestsTips": "开启多次刷新后,程序在启动时将运行多次检测,并显示最小延迟值。", "popupConnectivityNotifications": "显示可用性检测气泡", "popupConnectivityNotificationsTips": "开启后,将会在首次检测时以气泡形式提示可用性结果。", "ipDB": "IP 解析数据源", @@ -503,6 +503,7 @@ "SpeedTestButton": "开始/暂停网速测试", "SourceSelect": "选择 IP 归属地数据来源", "ShowASNInfo": "显示 AS 详细信息", + "ShowASNHistory": "显示 ASN 历史", "CopyIP": "复制 IP 地址", "ViewOnMap": "在地图上查看位置", "InfoMask": "隐藏信息", @@ -552,6 +553,13 @@ "Human_Pct": "人类", "moreData": "更多数据:" }, + "ASNHistory": { + "announcedPrefixes": "公告前缀:", + "note": "该 IP 的历史 ASN 公告:", + "empty": "未找到该 IP 的历史路由数据。", + "error": "加载 ASN 历史失败。", + "seenBy": "被 {peers} 个 BGP 全表节点观测到" + }, "advancedData": { "proxyYes": "是代理或 VPN", "proxyMaybe": "可能是代理或 VPN", @@ -581,6 +589,7 @@ "checking": "检查中...", "minTestTime": "最小测试时间:", "RefreshThisTest": "刷新此测试", + "RoundCount": "第 {n} 次:", "addCustom": { "AddCard": "添加测试", "Title": "添加自定义测试", @@ -682,7 +691,8 @@ "id": "ipcheck", "Title": "IP 查询", "Placeholder": "例如 1.1.1.1", - "Error": "请输入有效的 IPv4 或 IPv6 地址。" + "Error": "请输入有效的 IPv4 或 IPv6 地址。", + "NoData": "无法查询到该 IP 的信息。可能是不可路由地址(如 0.0.0.0、私有地址)或数据源没有该 IP 的记录。" }, "alert": { "id": "alert", diff --git a/frontend/main.js b/frontend/main.js index e185015cc..b62a04191 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -5,14 +5,18 @@ import App from './App.vue' import i18n from './locales/i18n'; import router from './router'; import { analytics } from './utils/use-analytics'; -import { registerServiceWorker } from './utils/register-service-worker'; +import { unregisterLegacyServiceWorker } from './utils/unregister-service-worker'; +import { addCollection } from '@iconify/vue'; import { detectOS } from './utils/system-detect'; import './style/style.css' -// Dynamic import of circle-flags collection +// The flag icon JSON is hundreds of KB — keep it dynamic so it doesn't bloat +// the first-paint bundle. `@iconify/vue` itself is statically imported (above) +// because a dozen components already pull it in synchronously, so wrapping +// addCollection in another dynamic() would just be a no-op Promise tick. import('@iconify-json/circle-flags/icons.json').then(({ default: flags }) => { - import('@iconify/vue').then(({ addCollection }) => addCollection(flags)); + addCollection(flags); }); const app = createApp(App); @@ -45,7 +49,7 @@ window.addEventListener('resize', handleResize); // Start Google Analytics analytics.page(); -registerServiceWorker(); +unregisterLegacyServiceWorker(); // Check Firebase environment store.checkFirebaseEnv(); diff --git a/frontend/sw.js b/frontend/sw.js deleted file mode 100644 index 87e3a8207..000000000 --- a/frontend/sw.js +++ /dev/null @@ -1,65 +0,0 @@ -// Service Worker configuration - -import { CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist'; - -const serwist = new Serwist({ - precacheEntries: self.__SW_MANIFEST, - precacheOptions: { - cleanupOutdatedCaches: true, - }, - skipWaiting: true, - clientsClaim: true, - runtimeCaching: [ - { - matcher: ({ request, url }) => request.mode === 'navigate' || url.pathname.endsWith('.html'), - handler: new NetworkFirst({ - cacheName: 'html-cache', - networkTimeoutSeconds: 3, - plugins: [ - new ExpirationPlugin({ - maxEntries: 5, - maxAgeSeconds: 60 * 60, - }), - ], - }), - }, - { - matcher: /\/(sw\.js|manifest\.webmanifest)$/, - handler: new NetworkFirst({ - cacheName: 'critical-assets', - plugins: [ - new ExpirationPlugin({ - maxEntries: 3, - maxAgeSeconds: 4 * 60 * 60, - }), - ], - }), - }, - { - matcher: /\.(?:png|jpg|jpeg|svg|webp|woff|woff2)$/, - handler: new CacheFirst({ - cacheName: 'images', - plugins: [ - new ExpirationPlugin({ - maxEntries: 60, - maxAgeSeconds: 7 * 24 * 60 * 60, - }), - ], - }), - }, - { - matcher: /\.(?:js|css)$/, - handler: new StaleWhileRevalidate({ - cacheName: 'assets', - plugins: [ - new ExpirationPlugin({ - maxEntries: 30, - maxAgeSeconds: 3 * 24 * 60 * 60, - }), - ], - }), - }, - ], -}); - -serwist.addEventListeners(); diff --git a/frontend/utils/bgp-prefix.js b/frontend/utils/bgp-prefix.js new file mode 100644 index 000000000..ece232a6b --- /dev/null +++ b/frontend/utils/bgp-prefix.js @@ -0,0 +1,6 @@ +// Thin re-export — implementation lives in common/bgp-prefix.js so the +// front-end and back-end share one source of truth (same pattern as +// valid-ip.js and fetch-with-timeout.js). +// +// import { toBgpPrefix } from '@/utils/bgp-prefix.js'; +export { toBgpPrefix, isValidBgpPrefix } from '../../common/bgp-prefix.js'; diff --git a/frontend/utils/register-service-worker.js b/frontend/utils/register-service-worker.js deleted file mode 100644 index 99c460955..000000000 --- a/frontend/utils/register-service-worker.js +++ /dev/null @@ -1,14 +0,0 @@ -export function registerServiceWorker() { - if (!import.meta.env.PROD || !('serviceWorker' in navigator)) { - return; - } - - window.addEventListener('load', async () => { - try { - const registration = await navigator.serviceWorker.register('/sw.js'); - await registration.update(); - } catch (error) { - console.warn('Service worker registration failed:', error); - } - }); -} diff --git a/frontend/utils/unregister-service-worker.js b/frontend/utils/unregister-service-worker.js new file mode 100644 index 000000000..a50d820d5 --- /dev/null +++ b/frontend/utils/unregister-service-worker.js @@ -0,0 +1,21 @@ +// One-shot cleanup for the legacy Serwist service worker. +// Existing prod clients still have it registered and a ~5 MB precache cached, +// so first visit after this deploy unregisters the worker and drops every +// cache it ever owned. Safe to delete this file (and its main.js call) after +// a few release cycles, once stale clients have rotated. + +export function unregisterLegacyServiceWorker() { + if (!import.meta.env.PROD || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return; + } + + navigator.serviceWorker.getRegistrations() + .then((regs) => Promise.all(regs.map((r) => r.unregister()))) + .catch(() => {}); + + if (typeof caches !== 'undefined') { + caches.keys() + .then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) + .catch(() => {}); + } +} diff --git a/package.json b/package.json index d0fe89d61..86b7bc1cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "6.1.0", + "version": "6.2.0", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"nodemon backend-server.js\"", @@ -14,13 +14,13 @@ "start": "concurrently \"npm run start-frontend\" \"npm run start-backend\"" }, "dependencies": { - "@cloudflare/speedtest": "^1.8.1", + "@cloudflare/speedtest": "^1.8.5", "@iconify-json/circle-flags": "^1.2.10", - "@iconify/vue": "^5.0.0", + "@iconify/vue": "^5.0.1", "@khmyznikov/pwa-install": "^0.6.3", "@tanstack/vue-table": "^8.21.3", - "@thumbmarkjs/thumbmarkjs": "^1.7.7", - "@vueuse/core": "^14.2.1", + "@thumbmarkjs/thumbmarkjs": "^1.9.0", + "@vueuse/core": "^14.3.0", "chart.js": "^4.5.1", "circle-progress.vue": "^3.4.1", "class-variance-authority": "^0.7.1", @@ -30,38 +30,36 @@ "detect-gpu": "^5.0.70", "dotenv": "^17.4.2", "express": "^5.2.1", - "express-rate-limit": "^8.3.2", + "express-rate-limit": "^8.5.1", "express-slow-down": "^3.1.0", - "firebase": "^12.12.0", - "http-proxy-middleware": "^3.0.5", + "firebase": "^12.13.0", + "http-proxy-middleware": "^4.0.0", "lucide-vue-next": "^1.0.0", "maxmind": "^5.0.6", "pinia": "^3.0.4", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", - "reka-ui": "^2.9.6", - "svgmap": "2.19.3", - "tailwind-merge": "^3.5.0", - "tar": "^7.5.13", + "reka-ui": "^2.9.7", + "svgmap": "2.20.0", + "tailwind-merge": "^3.6.0", + "tar": "^7.5.15", "tw-animate-css": "^1.4.0", "ua-parser-js": "^2.0.9", "vaul-vue": "^0.4.1", - "vue": "^3.5.32", - "vue-i18n": "^11.3.2", + "vue": "^3.5.34", + "vue-i18n": "^11.4.2", "vue-markdown-render": "^2.3.0", - "vue-router": "^5.0.4", + "vue-router": "^5.0.6", "vue-sonner": "^2.0.9", "whoiser": "^1.18.0" }, "devDependencies": { - "@serwist/vite": "^9.5.7", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.3.0", "@vitejs/plugin-vue": "^6.0.6", "code-inspector-plugin": "^1.5.1", "nodemon": "^3.1.14", - "serwist": "^9.5.7", - "tailwindcss": "^4.2.2", - "vite": "^8.0.8" + "tailwindcss": "^4.3.0", + "vite": "^8.0.12" } } diff --git a/tests/bgp-prefix.test.js b/tests/bgp-prefix.test.js new file mode 100644 index 000000000..677de76e8 --- /dev/null +++ b/tests/bgp-prefix.test.js @@ -0,0 +1,109 @@ +// Coverage for common/bgp-prefix.js — both the IP→prefix quantizer used by +// the frontend before hitting /api/asn-history and the prefix validator used +// by the backend guard. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { toBgpPrefix, isValidBgpPrefix } from '../common/bgp-prefix.js'; + +describe('toBgpPrefix — IPv4 → /24', () => { + const cases = [ + ['8.8.8.8', '8.8.8.0/24'], + ['1.1.1.1', '1.1.1.0/24'], + ['18.141.59.1', '18.141.59.0/24'], + ['255.255.255.255', '255.255.255.0/24'], + ['0.0.0.0', '0.0.0.0/24'], + ['10.20.30.40', '10.20.30.0/24'], + ]; + for (const [ip, expected] of cases) { + it(`${ip} → ${expected}`, () => { + assert.equal(toBgpPrefix(ip), expected); + }); + } +}); + +describe('toBgpPrefix — IPv6 → /48', () => { + const cases = [ + // Standard, no shorthand. + ['2001:4860:4860:0:0:0:0:8888', '2001:4860:4860::/48'], + // Shorthand at end. + ['2001:4860:4860::8888', '2001:4860:4860::/48'], + // Shorthand inside, after the 3rd hextet. + ['2001:db8:1234::1', '2001:db8:1234::/48'], + // Shorthand splitting before the 3rd hextet — gap fills with zero. + ['2001:db8::1', '2001:db8:0::/48'], + // Address starting with `::`. + ['::1', '0:0:0::/48'], + // All zeros. + ['::', '0:0:0::/48'], + // Mixed case + leading zeros — normalized to lowercase, no leading zeros. + ['2001:0DB8:0001::1', '2001:db8:1::/48'], + // Ending with `::`. + ['fe80::', 'fe80:0:0::/48'], + ]; + for (const [ip, expected] of cases) { + it(`${ip} → ${expected}`, () => { + assert.equal(toBgpPrefix(ip), expected); + }); + } +}); + +describe('toBgpPrefix — rejects garbage', () => { + const cases = [ + '', + 'nope', + '8.8.8', // missing octet + '8.8.8.8.8', // too many octets + '256.1.1.1', // octet out of range + '2001:::8888', // multiple `::` + '2001:db8:1234:5:6:7:8:9:10', // too many hextets + 'gggg::1', // non-hex + null, + undefined, + 42, + ]; + for (const input of cases) { + it(`rejects ${JSON.stringify(input)}`, () => { + assert.equal(toBgpPrefix(input), null); + }); + } +}); + +describe('isValidBgpPrefix', () => { + const valid = [ + '8.8.8.0/24', + '0.0.0.0/0', + '255.255.255.255/32', + '2001:db8::/48', + '::/0', + '2001:4860:4860::/48', + 'fe80::/64', + ]; + for (const p of valid) { + it(`accepts ${p}`, () => { + assert.equal(isValidBgpPrefix(p), true); + }); + } + + const invalid = [ + '', + 'nope', + '8.8.8.0', // no slash + '8.8.8.0/', // empty length + '/24', // no address + '8.8.8.0/33', // v4 length out of range + '2001:db8::/129', // v6 length out of range + '8.8.8.0/-1', // negative + '8.8.8.0/abc', // non-numeric length + '8.8.8.300/24', // address invalid + null, + undefined, + 42, + ]; + for (const p of invalid) { + it(`rejects ${JSON.stringify(p)}`, () => { + assert.equal(isValidBgpPrefix(p), false); + }); + } +}); diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index c30badf25..f40f5d3c2 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -14,7 +14,7 @@ describe('DEFAULT_PREFERENCES', () => { it('contains the full preference shape with expected defaults', () => { assert.deepEqual(DEFAULT_PREFERENCES, { theme: 'auto', - connectivityAutoRefresh: false, + connectivityMultipleTests: false, simpleMode: false, autoStart: true, hideUnavailableIPStack: false, diff --git a/tests/guards.test.js b/tests/guards.test.js index 063a9af6f..a07461b28 100644 --- a/tests/guards.test.js +++ b/tests/guards.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { requireReferer, requireValidIP } from '../common/guards.js'; +import { requireReferer, requireValidIP, requireValidPrefix } from '../common/guards.js'; // Minimal (req, res, next) stubs — just enough to observe what the // middleware does. @@ -89,3 +89,43 @@ describe('requireValidIP', () => { assert.equal(nextCalled, true); }); }); + +describe('requireValidPrefix', () => { + const guard = requireValidPrefix(); + + it('calls next() when prefix is a valid IPv4 CIDR', () => { + let nextCalled = false; + guard(makeReq({ query: { prefix: '8.8.8.0/24' } }), makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + }); + + it('calls next() when prefix is a valid IPv6 CIDR', () => { + let nextCalled = false; + guard(makeReq({ query: { prefix: '2001:4860:4860::/48' } }), makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + }); + + it('returns 400 "No prefix provided" when prefix is missing', () => { + const res = makeRes(); + let nextCalled = false; + guard(makeReq({ query: {} }), res, () => { nextCalled = true; }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'No prefix provided'); + assert.equal(nextCalled, false); + }); + + it('returns 400 "Invalid prefix" when prefix is malformed', () => { + const res = makeRes(); + let nextCalled = false; + guard(makeReq({ query: { prefix: '8.8.8.0' } }), res, () => { nextCalled = true; }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'Invalid prefix'); + assert.equal(nextCalled, false); + }); + + it('returns 400 when prefix length is out of v4 range', () => { + const res = makeRes(); + guard(makeReq({ query: { prefix: '8.8.8.0/33' } }), res, () => {}); + assert.equal(res.statusCode, 400); + }); +}); diff --git a/vite.config.js b/vite.config.js index a87c91a8d..902cd7a65 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,7 +2,6 @@ import dotenv, { parse } from 'dotenv'; import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' -import { serwist } from '@serwist/vite' import { CodeInspectorPlugin } from 'code-inspector-plugin'; dotenv.config(); @@ -76,15 +75,6 @@ export default defineConfig({ } }), tailwindcss(), - serwist({ - swSrc: 'frontend/sw.js', - swDest: 'sw.js', - globDirectory: 'dist', - globPatterns: [ - '**/*.{js,css,woff,woff2}', - '*.{js,css,png,svg,jpg,webp}', - ], - }), CodeInspectorPlugin({ bundler: 'vite', hideDomPathAttr: true,