From e68f3f7bddc2a0078a9d569c35e7be939ed80d17 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 25 Apr 2026 18:24:11 +0800 Subject: [PATCH 01/18] Improvements --- frontend/components/Additional.vue | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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
From 9c37dbcd874abb1fbc1c8eb7da9137ab67495ec0 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Fri, 1 May 2026 18:21:52 +0800 Subject: [PATCH 02/18] Improvements --- .gitignore | 3 +++ AGENTS.md | 4 ++++ 2 files changed, 7 insertions(+) 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..e23cbf2d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). From 9c5b655f4d38ecf130fa04e404924a4108e838ca Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 7 May 2026 09:25:22 +0800 Subject: [PATCH 03/18] Refactor(cache): drop service worker, add explicit Cache-Control on static assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service-worker offline caching had no real value (every detection in this app needs the network) and conflicted with Cloudflare challenge pages — the SW would NetworkFirst-timeout and serve a stale index.html, then the hashed chunk requests came back as a wall of 403s from CF. Replace with conventional HTTP cache headers in frontend-server.js: - dist/assets/** + dist/fonts/** → 1y immutable (content-addressed) - top-level images → 24h - index.html + manifest.webmanifest → no-store (so deploys propagate cleanly to hashed chunks) - everything else → 1h Existing prod clients still have the old SW + ~5 MB precache installed, so unregister-service-worker.js does a one-shot cleanup on first load after this deploy: unregister all SW registrations, drop every cache. Safe to delete the file and its main.js call after a few release cycles. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- frontend-server.js | 29 ++++++++- frontend/AGENTS.md | 1 - frontend/main.js | 4 +- frontend/sw.js | 65 --------------------- frontend/utils/register-service-worker.js | 14 ----- frontend/utils/unregister-service-worker.js | 21 +++++++ package.json | 2 - vite.config.js | 10 ---- 9 files changed, 51 insertions(+), 97 deletions(-) delete mode 100644 frontend/sw.js delete mode 100644 frontend/utils/register-service-worker.js create mode 100644 frontend/utils/unregister-service-worker.js diff --git a/AGENTS.md b/AGENTS.md index e23cbf2d0..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) | 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/main.js b/frontend/main.js index e185015cc..b78318108 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -5,7 +5,7 @@ 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 { detectOS } from './utils/system-detect'; import './style/style.css' @@ -45,7 +45,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/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..62bf1babb 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,10 @@ "whoiser": "^1.18.0" }, "devDependencies": { - "@serwist/vite": "^9.5.7", "@tailwindcss/vite": "^4.2.2", "@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" } 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, From 49240ce545430d69fde87be434d0eda8ab7c1250 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 7 May 2026 09:25:30 +0800 Subject: [PATCH 04/18] Feat(api): default /api/* responses to Cache-Control: no-store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most /api endpoints are caller-specific (e.g. /api/ipinfo without ?ip reads the *caller's* IP, /api/getuserinfo returns the authenticated user's profile). Without explicit headers, a stray Cloudflare cache rule could serve user A's response to user B. Add a one-line middleware on /api/* that defaults every response to no-store, mounted before requireReferer so even rejected 403/404/429 responses carry the header. Pure-function handlers (whois / macchecker / cfradar / map / configs) can override later with public, max-age=… if edge caching is desired. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-server.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend-server.js b/backend-server.js index 31ba7f760..2691205c2 100644 --- a/backend-server.js +++ b/backend-server.js @@ -163,6 +163,15 @@ if (speedLimitSet !== 0) { app.use(express.json()); +// Default every /api/* response to no-store. +// Pure-function handlers (whois / macchecker / cfradar / map / configs) MAY +// override this with `res.setHeader('Cache-Control', 'public, max-age=...')` +// before sending the response. +app.use('/api', (req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); +}); + // Global referer gate for all /api/* routes. Handlers no longer repeat this // check individually — see common/guards.js. app.use('/api', requireReferer); From 4895edbc03a347bde577ced5f58d5479c59b2123 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 7 May 2026 09:27:45 +0800 Subject: [PATCH 05/18] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 62bf1babb..1f610cfa9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "6.1.0", + "version": "6.1.1", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"nodemon backend-server.js\"", From b6071699d2abf9971ba1f176d081d56aea36f7cb Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 12 May 2026 01:46:55 +0800 Subject: [PATCH 06/18] Feat(connectivity): redesign multi-test mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename pref connectivityAutoRefresh → connectivityMultipleTests across defaults, tests, locales (en/zh/fr/tr), Preferences, and ConnectivityTest. - Defer the Congrats/OhNo toast until every round finishes; aggregate the verdict via "ever-succeeded across all rounds" (mintime > 0) so a target reachable in any later round still counts as connected. Sticky alertToShow latch prevents interval ticks from suppressing it. - Preserve best-of-N face/text/ms — a later failed round no longer downgrades a card that already succeeded. - Add per-card progress dots: one slot per round, bootstrap-only writer so manual operations leave the historical snapshot alone. Head-of-queue pulses; pulse suppressed once the user takes over. - Trim verbose comments throughout the component. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/components/ConnectivityTest.vue | 239 ++++++++++---------- frontend/components/widgets/Preferences.vue | 16 +- frontend/data/default-preferences.js | 2 +- frontend/locales/en.json | 4 +- frontend/locales/fr.json | 4 +- frontend/locales/tr.json | 4 +- frontend/locales/zh.json | 4 +- tests/default-preferences.test.js | 2 +- 8 files changed, 135 insertions(+), 140 deletions(-) diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index d7efb6ed6..71efd4c53 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..86347bdbc 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -133,26 +133,41 @@ - -
+ +
{{ t('ipInfos.ASN') }} {{ data.asn }}
- - - +
+ + + + + + + + +
- + - + +
@@ -197,22 +212,24 @@ import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/use-analytics'; import { fetchWithTimeout } from '@/utils/fetch-with-timeout.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 +243,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 +256,9 @@ const props = defineProps({ enableMap: { type: Boolean, default: false }, }); -const isAsnOpen = ref(false); +// Single-select panel state for the ASN block: 'info' | 'history' | null. +// At most one panel open at a time — they're alternate views of the same ASN. +const activePanel = ref(null); const isMapDialogOpen = ref(false); // Advanced block only surfaces for the IPCheck.ing source (ipGeoSource === 0). @@ -295,13 +316,29 @@ const openMapDialog = () => { trackEvent('IPCheck', 'ViewOnMapClick', props.data.source || 'unknown'); }; -const toggleASNCollapse = async (asn) => { - isAsnOpen.value = !isAsnOpen.value; - if (isAsnOpen.value) { - await getASNInfo(asn); +// 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 (activePanel.value === name) { + activePanel.value = null; + return; + } + activePanel.value = name; + if (name === 'info') { + await getASNInfo(props.data.asn); + } else if (name === 'history') { + await getASNHistory(props.data.ip); } }; +// Collapsible's controlled mode also emits close on outside interactions like +// the Esc key; mirror that back into the activePanel state. +const onPanelOpenChange = (open) => { + if (!open) activePanel.value = null; +}; + const getASNInfo = async (asn) => { trackEvent('IPCheck', 'ASNInfoClick', 'Show ASN Info'); try { @@ -314,4 +351,24 @@ const getASNInfo = async (asn) => { console.error('Error fetching ASN info:', error); } }; + +const getASNHistory = async (ip) => { + trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History'); + try { + if (props.asnHistoryInfos[ip]) return; + const response = await fetchWithTimeout( + `/api/asn-history?ip=${encodeURIComponent(ip)}`, + { timeoutMs: 10000 } + ); + if (!response.ok) { + props.asnHistoryInfos[ip] = { error: true }; + return; + } + const data = await response.json(); + props.asnHistoryInfos[ip] = data; + } catch (error) { + console.error('Error fetching ASN history:', error); + props.asnHistoryInfos[ip] = { error: true }; + } +}; diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index 58ccd83fb..fa36a01fe 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; @@ -148,7 +150,7 @@ const fetchIPForModal = async (ip, sourceID = null) => { 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; } catch (error) { diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 0939954b5..223f047d0 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1092,9 +1092,18 @@ ] }, { - "version": "v6.1.2", + "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": { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b178c9471..5d9a1c784 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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,12 @@ "Human_Pct": "Human", "moreData": "More Data : " }, + "ASNHistory": { + "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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index d9a922822..0ad05011f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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,12 @@ "Human_Pct": "Humain : ", "moreData": "Plus de données : " }, + "ASNHistory": { + "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", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index 86161c529..979d43c4c 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -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,12 @@ "Human_Pct": "İnsan : ", "moreData": "Daha Fazla Veri : " }, + "ASNHistory": { + "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", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 3e77b32fb..d934ba340 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -503,6 +503,7 @@ "SpeedTestButton": "开始/暂停网速测试", "SourceSelect": "选择 IP 归属地数据来源", "ShowASNInfo": "显示 AS 详细信息", + "ShowASNHistory": "显示 ASN 历史", "CopyIP": "复制 IP 地址", "ViewOnMap": "在地图上查看位置", "InfoMask": "隐藏信息", @@ -552,6 +553,12 @@ "Human_Pct": "人类", "moreData": "更多数据:" }, + "ASNHistory": { + "note": "该 IP 的历史 ASN 公告:", + "empty": "未找到该 IP 的历史路由数据。", + "error": "加载 ASN 历史失败。", + "seenBy": "被 {peers} 个 BGP 全表节点观测到" + }, "advancedData": { "proxyYes": "是代理或 VPN", "proxyMaybe": "可能是代理或 VPN", diff --git a/package.json b/package.json index 01b190f95..72d531685 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "6.1.2", + "version": "6.2.0", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"nodemon backend-server.js\"", From dbdd71d13fbd2fa10680b704f27ab5215936c0a0 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 12 May 2026 21:40:52 +0800 Subject: [PATCH 11/18] Feat(api): edge-cacheable middleware for slow-changing routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a cacheable(maxAge) middleware factory in backend-server.js that hooks res.json to attach Cache-Control: public, max-age=N — but only on 2xx responses, so Cloudflare's edge never caches 4xx/5xx. Wire it on the routes whose upstreams refresh slowly: cfradar / asn-history / whois / maxmind / macchecker plus the third-party IP geolocation handlers (ipinfo / ipapicom / ipsb / ipapiis / ip2location). Auth-context handlers (ipchecking, invisibility, dns-leak-test/session, getuserinfo, updateuserachievement) remain on the global no-store default — their caching belongs at the upstream that owns the auth. Also trim stale "what" comments throughout backend-server.js and document the new pattern in api/AGENTS.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/AGENTS.md | 13 +++++++ backend-server.js | 86 ++++++++++++++++++++++++----------------------- 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index 96b2ff1a6..7b59a4f51 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -84,6 +84,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', requireValidIP(), 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/backend-server.js b/backend-server.js index 9c2ccc974..1d927330b 100644 --- a/backend-server.js +++ b/backend-server.js @@ -65,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'); @@ -109,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'); @@ -133,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); } @@ -146,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`); @@ -164,40 +161,55 @@ if (speedLimitSet !== 0) { app.use(express.json()); -// Default every /api/* response to no-store. -// Pure-function handlers (whois / macchecker / cfradar / map / configs) MAY -// override this with `res.setHeader('Cache-Control', 'public, max-age=...')` -// before sending the response. +// 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', requireValidIP(), 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/asn-history', requireValidIP(), asnHistoryHandler); 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 @@ -206,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 }); @@ -229,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}`); }); } From b08fb9189fa0968f99d22005666bb61fa88d804e Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 00:08:53 +0800 Subject: [PATCH 12/18] Feat(asn-history): query by BGP-floor prefix for cross-user cache reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/asn-history previously took ?ip=, producing a unique CF cache key per IP — wasteful since BGP routes are announced at /24 (v4) and /48 (v6) at minimum, so every IP in the same prefix has identical routing history. Frontend now quantizes the user's IP to its DFZ-floor prefix via toBgpPrefix() and hits /api/asn-history?prefix=...; CF can dedupe 256 v4 addresses (or a /48's worth of v6) onto one edge cache entry. Backend swaps requireValidIP() for a new requireValidPrefix() guard that validates any well-formed CIDR. RIPEstat returns identical results whether queried by IP or its covering prefix (verified empirically), so the upstream call is unchanged in shape. Shared via common/bgp-prefix.js with a frontend re-export at frontend/utils/bgp-prefix.js, matching the valid-ip.js pattern. 50 new tests cover v4/v6 quantization (including various IPv6 shorthand forms, mixed case, leading-zero normalization) and the prefix guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/AGENTS.md | 3 +- api/asn-history.js | 30 +++-- backend-server.js | 4 +- common/bgp-prefix.js | 75 ++++++++++++ common/guards.js | 15 +++ frontend/components/IpInfos.vue | 3 +- frontend/components/ip-infos/ASNHistory.vue | 9 +- .../components/ip-infos/IpDetailPanel.vue | 26 +++-- frontend/utils/bgp-prefix.js | 6 + tests/bgp-prefix.test.js | 109 ++++++++++++++++++ tests/guards.test.js | 42 ++++++- 11 files changed, 291 insertions(+), 31 deletions(-) create mode 100644 common/bgp-prefix.js create mode 100644 frontend/utils/bgp-prefix.js create mode 100644 tests/bgp-prefix.test.js diff --git a/api/AGENTS.md b/api/AGENTS.md index 7b59a4f51..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) @@ -90,7 +91,7 @@ Default: every `/api/*` response gets `Cache-Control: no-store` from the global ```js app.get('/api/cfradar', cacheable(60 * 60), cfHander); -app.get('/api/asn-history', requireValidIP(), cacheable(24 * 60 * 60), asnHistoryHandler); +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. diff --git a/api/asn-history.js b/api/asn-history.js index 04ce82cd9..ab1400f82 100644 --- a/api/asn-history.js +++ b/api/asn-history.js @@ -1,5 +1,7 @@ -// /api/asn-history — RIPEstat routing-history for an IP, enriched with -// per-ASN org names from RIPEstat as-overview (in parallel, best-effort). +// /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'; @@ -21,8 +23,8 @@ const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip'; // timeout for a slow secondary lookup. const ORG_FETCH_TIMEOUT_MS = 8000; -function summarizeOrigin(entry, family) { - const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= MIN_PREFIX[family]); +function summarizeOrigin(entry, minLen) { + const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= minLen); if (acceptedPrefixes.length === 0) return null; const allTimes = []; @@ -76,22 +78,26 @@ async function fetchAsOrgName(asn) { } export default async (req, res) => { - const ip = req.query.ip; + // 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(ip)}&sourceapp=${SOURCE_APP}`; + + `?resource=${encodeURIComponent(prefix)}&sourceapp=${SOURCE_APP}`; const apiRes = await fetchUpstream(url); if (!apiRes.ok) { - logger.warn({ ip, status: apiRes.status }, 'RIPEstat routing-history non-2xx'); + 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 family = ip.includes(':') ? 'v6' : 'v4'; const history = origins - .map(entry => summarizeOrigin(entry, family)) + .map(entry => summarizeOrigin(entry, minLen)) .filter(Boolean) .sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || '')); @@ -107,12 +113,12 @@ export default async (req, res) => { row.org = orgByAsn[row.asn] || null; } } catch (error) { - logger.warn({ err: error, ip }, 'as-overview batch failed; returning ASN-only history'); + logger.warn({ err: error, prefix }, 'as-overview batch failed; returning ASN-only history'); } - res.json({ ip, history }); + res.json({ prefix, history }); } catch (error) { - logger.error({ err: error, ip }, 'asn-history handler failed'); + 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 1d927330b..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'; @@ -195,7 +195,7 @@ app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipinfoHandle 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', requireValidIP(), cacheable(ONE_DAY_CACHE), asnHistoryHandler); +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); 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/components/IpInfos.vue b/frontend/components/IpInfos.vue index 4657eaf44..a14609fc5 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -110,7 +110,8 @@ const asnInfos = ref({ } }); -// ASN routing history (RIPEstat), keyed by IP. Session cache — wipes on reload. +// ASN routing history (RIPEstat), keyed by BGP-floor prefix (/24 v4, /48 v6). +// Session cache — wipes on reload. const asnHistoryInfos = ref({}); // Other data diff --git a/frontend/components/ip-infos/ASNHistory.vue b/frontend/components/ip-infos/ASNHistory.vue index b1176a037..1406282ae 100644 --- a/frontend/components/ip-infos/ASNHistory.vue +++ b/frontend/components/ip-infos/ASNHistory.vue @@ -67,13 +67,14 @@ const { t } = useI18n(); const placeholderSizes = [12, 8, 6, 8, 4]; const props = defineProps({ - // The IP we're displaying history for; used as cache key. - ip: { type: String, required: true }, - // Shared session cache, keyed by IP. Owned by IpInfos.vue (or local in QueryIP). + // BGP-floor prefix the parent quantized the IP to (/24 v4, /48 v6). + // Used both as cache key and as the URL param sent to the backend. + prefix: { type: String, required: true }, + // Shared session cache, keyed by prefix. Owned by IpInfos.vue (or local in QueryIP). asnHistoryInfos: { type: Object, required: true }, }); -const entry = computed(() => props.asnHistoryInfos[props.ip]); +const entry = computed(() => props.asnHistoryInfos[props.prefix]); const rows = computed(() => (entry.value && !entry.value.error) ? entry.value.history : null); // Compact YYYY-MM-DD — RIPEstat returns "2013-12-05T00:00:00", so slice is enough. diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 86347bdbc..266b95afd 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -152,7 +152,7 @@ - +