Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
08ea809
Improvements
jason5ng32 Jun 16, 2026
976e971
Feat(nav): surface Advanced Tools sub-tools in the navigation
jason5ng32 Jun 16, 2026
83752e2
Feat(github-stars): self-host the repo star count, drop shields.io
jason5ng32 Jun 16, 2026
42c8a87
Feat(preferences): per-module startup auto-run + connectivity refinem…
jason5ng32 Jun 16, 2026
827bd2c
Fix(info-mask): leave waiting / error placeholders unmasked
jason5ng32 Jun 16, 2026
a03a993
Style: trim historical comments, document the comment rule
jason5ng32 Jun 16, 2026
0830ec5
Improvements
jason5ng32 Jun 16, 2026
255034b
Improvements
jason5ng32 Jun 16, 2026
b6604af
Improvements
jason5ng32 Jun 16, 2026
93ce69e
Improvements
jason5ng32 Jun 16, 2026
c16c03b
Improvements
jason5ng32 Jun 17, 2026
51540ce
Improvements
jason5ng32 Jun 19, 2026
691924a
Improvements
jason5ng32 Jun 24, 2026
fe48218
Improvements
jason5ng32 Jun 24, 2026
d72cff6
Improvements
jason5ng32 Jun 28, 2026
7721124
Improvements
jason5ng32 Jun 28, 2026
eb00115
Improvements
jason5ng32 Jun 28, 2026
20d6d2e
Improvements
jason5ng32 Jun 28, 2026
acdab7c
Improvements
jason5ng32 Jun 30, 2026
53265e0
Improvements
jason5ng32 Jun 30, 2026
dc1ef05
Perf(cache): extend near-static API caches to 30 days
jason5ng32 Jun 30, 2026
5b8ccaa
Perf(map): quantize static-map coords to 1 decimal for cache sharing
jason5ng32 Jun 30, 2026
2752049
Perf(asn-history): push min_peers_seeing floor down to RIPEstat
jason5ng32 Jun 30, 2026
20c0e34
Improvements
jason5ng32 Jun 30, 2026
fb164bb
Merge pull request #348 from jason5ng32/dev
jason5ng32 Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Pull Request Template

> ⚠️ **Please open this PR against the `dev` branch, not `main`.** All contributions go through `dev`; `main` only receives release merges.

## Description
Please include a summary of the change and which issue is fixed.

Expand All @@ -10,6 +12,7 @@ Please include a summary of the change and which issue is fixed.
- [ ] Documentation update

## Checklist:
- [ ] This PR targets the `dev` branch, not `main`.
- [ ] I have followed the contribution guidelines.
- [ ] My code follows the style guidelines of this project.
- [ ] I have performed a self-review of my own code.
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,15 @@ This project uses **pnpm** as its package manager (pinned via the `packageManage
- **JavaScript only.** New files are `.js` / `.vue`; `<script setup>` has no `lang="ts"`. Do not rename `jsconfig.json` to `tsconfig.json` or otherwise introduce TypeScript.
- **English by default** for source-code comments , commit messages and AGENTS.md files. Planning docs can be in other languages. Locale packs are obviously the exception — they contain user-facing copy in their respective language.

### Functions

- **New functions use `const` arrow syntax** — `const fn = (...) => {}` / `const fn = async (...) => {}`, not `function fn()` / `async function fn()` declarations. Object methods (store actions, the `analytics` API, etc.) keep their shorthand. Mind that arrow consts aren't hoisted: declare one before the code that uses it. This applies to **new / rewritten** code — don't mass-convert existing `function` declarations just to match; leave surrounding style intact when making an unrelated edit.

### Comments

- **Every new file opens with a header comment** stating its purpose. One or two lines is usually enough; enough that a reader opening the file cold understands what it is.
- **Large templates or functions carry block comments** on each meaningful section — enough for a maintainer six months later to orient quickly. Not every line, but every region / branch / step.
- **Comments describe the code as it is now, not how it got here.** Explain the current "why" — don't narrate past states or read like a changelog (`previously…`, `replaced X in v7`, `this used to…`, `…fixes that`). Git history covers the past. A comment should stay shorter than the code it explains; if it's growing into a story, cut it.

### i18n coverage

Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ We love to hear your ideas! Open an issue with the tag `feature request` and pro
Before making any changes, please:

1. Open a new issue discussing your proposed change.
2. Fork the repository and make your changes in a new branch.
2. Fork the repository and create your branch from **`dev`** — all contributions are based on `dev`, not `main`.

### Setting Up Your Environment

Expand All @@ -64,9 +64,10 @@ Ensure that all tests pass and, if applicable, add new tests for your changes. R

When you're ready to submit your changes:

1. Rebase your branch to the latest `dev` branch.
2. Ensure your changes adhere to the coding standards and guidelines.
3. Submit a pull request with a clear description of your changes.
1. **Open your pull request against the `dev` branch — not `main`.** `main` only receives release merges from `dev`, so any PR targeting `main` will be asked to retarget.
2. Rebase your branch onto the latest `dev` before submitting.
3. Keep one concern per PR — don't bundle unrelated changes (e.g. a feature plus a dev-environment tweak) into the same pull request.
4. Ensure your changes adhere to the coding standards and guidelines, with a clear description of what you changed.

### Code Review Process

Expand Down
2 changes: 2 additions & 0 deletions api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ api/
│ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source)
├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint
├── mac-checker.js ← /api/macchecker — MAC vendor lookup
├── github-stars.js ← /api/github-stars — repo star count via no-token
│ GitHub fetch (edge-cached; replaces shields.io)
├── get-whois.js ← /api/whois — whoiser primary + RDAP fallback for new gTLDs
├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar
├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query
Expand Down
4 changes: 3 additions & 1 deletion api/asn-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export default async (req, res) => {
const minLen = MIN_PREFIX[family];

try {
const apiRes = await fetchRoutingHistory(prefix);
// Push our peer floor down to RIPEstat so it drops sub-threshold rows
// during the scan — same MIN_PEERS we filter on below, single source.
const apiRes = await fetchRoutingHistory(prefix, { minPeersSeeing: MIN_PEERS });
if (!apiRes.ok) {
logger.warn({ prefix, status: apiRes.status }, 'RIPEstat routing-history non-2xx');
return res.status(502).json({ error: 'Upstream error' });
Expand Down
35 changes: 35 additions & 0 deletions api/github-stars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// /api/github-stars — stargazer count for this repo, fetched from GitHub's
// public REST API without a token (`stargazers_count` is available
// unauthenticated). Edge-cached for a day (see backend-server.js), so behind
// Cloudflare the origin hits GitHub at most once per cache window — well under
// the 60 req/hour unauthenticated limit.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

// The project's own repository. GitHub requires a User-Agent on every request.
const REPO = 'jason5ng32/MyIP';

export default async (req, res) => {
// Defensive; app.get() in backend-server.js already gates method, but a
// dedicated smoke test asserts this branch directly against the handler.
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' });
}

try {
const apiRes = await fetchUpstream(`https://api.github.com/repos/${REPO}`, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'MyIP-IPCheck.ing',
},
});
if (!apiRes.ok) {
throw new Error(`GitHub API responded ${apiRes.status}`);
}
const data = await apiRes.json();
res.json({ stars: data.stargazers_count ?? 0 });
} catch (error) {
logger.error({ err: error }, 'github-stars handler failed');
res.status(500).json({ error: 'Failed to fetch GitHub stars' });
}
};
5 changes: 5 additions & 0 deletions api/google-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export default async (req, res) => {
return res.status(apiRes.status).json({ error: `Upstream map API returned ${apiRes.status}` });
}
res.setHeader('Content-Type', apiRes.headers.get('content-type') || 'image/jpeg');
// Binary streams bypass the res.json hook in the cacheable() middleware,
// so apply the cache value it stashed on res.locals here on the 2xx path.
if (res.locals.cacheControl) {
res.setHeader('Cache-Control', res.locals.cacheControl);
}
Readable.fromWeb(apiRes.body).pipe(res);
} catch (e) {
logger.error({ err: e, latitude, longitude, language, CanvasMode }, 'google-map handler failed');
Expand Down
50 changes: 29 additions & 21 deletions backend-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js';
import getWhois from './api/get-whois.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
Expand Down Expand Up @@ -150,9 +151,9 @@ const rateLimiter = rateLimit({
});

const speedLimiter = slowDown({
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
})

if (rateLimitSet !== 0) {
Expand All @@ -175,13 +176,15 @@ app.use('/api', (req, res, 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.
// attached on 2xx — CF must not cache 4xx/5xx error pages. The intended cache
// value is also stashed on res.locals.cacheControl so binary-stream handlers
// (which bypass res.json) can apply it themselves on their own 2xx path.
const cacheable = (maxAgeSeconds) => (req, res, next) => {
res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`;
const originalJson = res.json.bind(res);
res.json = function (body) {
if (res.statusCode < 400) {
res.setHeader('Cache-Control', `public, max-age=${maxAgeSeconds}`);
res.setHeader('Cache-Control', res.locals.cacheControl);
}
return originalJson(body);
};
Expand All @@ -193,28 +196,33 @@ const cacheable = (maxAgeSeconds) => (req, res, next) => {
app.use('/api', requireReferer);

const FIVE_MIN_CACHE = 5 * 60;
const ONE_HOUR_CACHE = 60 * 60;
const ONE_DAY_CACHE = 24 * 60 * 60;
const ONE_WEEK_CACHE = 7 * 24 * 60 * 60;
const THIRTY_DAYS_CACHE = 30 * 24 * 60 * 60;
const ONE_YEAR_CACHE = 365 * 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_WEEK_CACHE), cfHander);
app.get('/api/asn-history', requireValidPrefix(), cacheable(ONE_WEEK_CACHE), asnHistoryHandler);
app.get('/api/asn-connectivity', requireValidASN(), cacheable(ONE_WEEK_CACHE), asnConnectivityHandler);
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);
// Short Cache
app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);

// Cache for 1 day
app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_DAY_CACHE), ipinfoHandler);
app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_DAY_CACHE), ipapicomHandler);
app.get('/api/ipsb', requireValidIP(), cacheable(ONE_DAY_CACHE), ipsbHandler);
app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_DAY_CACHE), ipapiisHandler);
app.get('/api/ip2location', requireValidIP(), cacheable(ONE_DAY_CACHE), ip2locationHandler);
app.get('/api/maxmind', requireValidIP(), cacheable(ONE_DAY_CACHE), maxmindHandler);
app.get('/api/whois', cacheable(ONE_DAY_CACHE), getWhois);
app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler);
// Cache for 30 days — registry / historical data that changes on a monthly
// (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection,
// and append-only BGP routing history.
app.get('/api/cfradar', cacheable(THIRTY_DAYS_CACHE), cfHander);
app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler);
app.get('/api/asn-connectivity', requireValidASN(), cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler);
app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker);
// Long Cache
app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler);
// Non-cacheable routes — auth-context, debug tools, or per-request lookups.
app.get('/api/map', mapHandler);
app.get('/api/ipchecking', requireValidIP(), ipCheckingHandler);
app.get('/api/dnsresolver', dnsResolver);
app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult);
Expand Down
17 changes: 12 additions & 5 deletions common/ripestat.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { lookupAsOrgName } from './as-org-db.js';
const BASE_URL = 'https://stat.ripe.net/data';
const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip';

function fetchRipestat(endpoint, resource, { timeoutMs = 8000 } = {}) {
const search = new URLSearchParams({ resource, sourceapp: SOURCE_APP });
function fetchRipestat(endpoint, resource, { timeoutMs = 8000, params = {} } = {}) {
const search = new URLSearchParams({ resource, sourceapp: SOURCE_APP, ...params });
return fetchUpstream(`${BASE_URL}/${endpoint}/data.json?${search}`, { timeoutMs });
}

Expand All @@ -19,9 +19,16 @@ export function fetchAsOverview(asn, { timeoutMs = 3000 } = {}) {

/** routing-history: historical AS announcements for a prefix or IP.
* Slow analytical endpoint — scans years of BGP data, regularly takes
* 10–20s for prefixes with long history. */
export function fetchRoutingHistory(resource, { timeoutMs = 25000 } = {}) {
return fetchRipestat('routing-history', resource, { timeoutMs });
* 10–20s for prefixes with long history.
*
* Pass `minPeersSeeing` to push the caller's peer floor down to RIPEstat:
* rows seen by fewer RIS peers are route noise the caller would discard
* anyway, so dropping them during the scan trims the response without
* changing the result. The threshold is owned by the caller (asn-history's
* MIN_PEERS) — omit it and RIPEstat keeps its own default. */
export function fetchRoutingHistory(resource, { timeoutMs = 25000, minPeersSeeing } = {}) {
const params = minPeersSeeing != null ? { min_peers_seeing: minPeersSeeing } : {};
return fetchRipestat('routing-history', resource, { timeoutMs, params });
}

/**
Expand Down
8 changes: 7 additions & 1 deletion frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ frontend/
│ display mode, which is utils/pwa.js's
│ isRunningAsPwa()). Advanced tools also open
│ in-page via the `?tool=<slug>` query on `/`.
├── locales/ ← i18n copy (en / zh / fr / tr) + security-checklist data
│ `/privacy` → PrivacyPolicy.vue (standalone page).
├── locales/ ← i18n copy (en / zh / fr / tr) + on-demand
│ sub-packs (security-checklist/ · privacy/)
│ loaded by their own page, not the main bundle
├── style/style.css ← Tailwind v4 entry + design tokens
├── lib/ ← cn() helper (tailwind-merge + clsx)
├── data/ ← static config
Expand All @@ -47,6 +50,9 @@ frontend/
└── components/
├── Home.vue ← the homepage (`/` route): all top-level sections + the drawer
├── StandaloneTool.vue ← `/tools/:slug` page: one tool in a minimal full-page layout
├── PrivacyPolicy.vue ← `/privacy` page (standalone layout)
├── StandalonePageHeader.vue ← slim header shared by StandaloneTool + PrivacyPolicy
│ (brand → home + breadcrumb + back link; NOT Nav.vue)
├── *.vue ← top-level sections (IpInfos / Connectivity / WebRTC / DnsLeaks
│ / SpeedTest / Advanced / Footer / Nav / Achievements / User /
│ Additional)
Expand Down
16 changes: 9 additions & 7 deletions frontend/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ import Alert from './components/widgets/Toast.vue';
import PWA from './components/widgets/PWA.vue';
import { useTheme } from '@/composables/use-theme.js';

// The standalone tool pages (/tools/:slug) drop the homepage's fixed-Nav body
// padding (see the `body.jn-standalone-page` rule in index.html). Toggle the
// marker class as the route changes. NB: "tool page" here is unrelated to PWA
// display mode — that's `isRunningAsPwa()` in utils/pwa.js.
// The standalone pages (/tools/:slug, /privacy) carry their own header, so they
// drop the homepage's fixed-Nav body padding (see the `body.jn-standalone-page`
// rule in index.html) — otherwise a blank strip shows above their header. Toggle
// the marker class as the route changes. NB: "standalone" here is unrelated to
// PWA display mode — that's `isRunningAsPwa()` in utils/pwa.js.
const STANDALONE_ROUTES = new Set(['tool', 'privacy']);
const route = useRoute();
watch(
() => route.name === 'tool',
(isToolPage) => {
document.body.classList.toggle('jn-standalone-page', isToolPage);
() => STANDALONE_ROUTES.has(route.name),
(isStandalone) => {
document.body.classList.toggle('jn-standalone-page', isStandalone);
},
{ immediate: true },
);
Expand Down
18 changes: 13 additions & 5 deletions frontend/components/ConnectivityTest.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h2>
<JnTooltip :text="t('Tooltips.RefreshConnectivityTests')" side="left">
<Button size="icon" variant="outline" class="shrink-0 cursor-pointer"
@click="checkAllConnectivity(false, true, true)" aria-label="Refresh Connectivity Test">
@click="handelCheckStart('manual')" aria-label="Refresh Connectivity Test">
<component :is="isStarted ? RotateCw : Play" />
</Button>
</JnTooltip>
Expand Down Expand Up @@ -510,12 +510,20 @@ const finalizeMultiTestAlert = () => {
};

// ── Main control ───────────────────────────────────────────────────────────
const handelCheckStart = async (fromApp = false) => {
// `trigger` selects three behaviors (arming the toast = setting alertToShow):
// 'boot' — startup auto-run: arm toast, no card reset, auto pass (records rounds)
// 'manual' — section refresh button: arm toast, reset cards, single pass
// 'refresh' — global "refresh everything": suppress toast (global alert covers it), reset cards
const handelCheckStart = async (trigger = 'boot') => {
const multi = multipleTests.value;
if (fromApp) await checkAllConnectivity(false, true, true);
else await checkAllConnectivity(true, false, false);
const isAuto = trigger === 'boot';
const showToast = trigger !== 'refresh';
const resetCards = trigger !== 'boot';
await checkAllConnectivity(showToast, resetCards, !isAuto);
store.setLoadingStatus('Connectivity', true);
if (multi) {
// Multi-round follow-ups are a startup-only feature; manual/global refreshes
// are a single pass and flag completion immediately so the toast can fire.
if (multi && isAuto) {
intervalId.value = setInterval(async () => {
if (counter.value < maxCounts.value && !manualRun.value) {
await checkAllConnectivity(false, false, false);
Expand Down
5 changes: 4 additions & 1 deletion frontend/components/DnsLeaksTest.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<span class="relative inline-flex size-2 rounded-full" :class="dotClass(toneOf(leak))"></span>
</span>
<FitText :text="leak.ip" :tiers="INLINE_TIERS" :title="leak.ip" class="font-mono min-w-0"
:class="textClass(toneOf(leak))" data-mask="ip" />
:class="textClass(toneOf(leak))" :data-mask="maskAttr(leak.ip)" />
</div>

<!-- ISP + Country sub-block -->
Expand Down Expand Up @@ -122,6 +122,7 @@ import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useStatusTone, ipFieldTone, isFieldPending as isFieldPendingShared } from '@/composables/use-status-tone.js';
import { createMaskGate } from '@/composables/use-info-mask.js';
import { useMaxmind } from '@/composables/use-maxmind.js';
import { EthernetPort, Play, MapPin, RotateCw, Sparkles, ArrowRight, DoorOpen } from '@lucide/vue';
import { Icon } from '@iconify/vue';
Expand All @@ -140,6 +141,8 @@ const { t } = useI18n();
const store = useMainStore();
const router = useRouter();
const { lookupMaxmind } = useMaxmind();
// Skip the info-mask blur on waiting/error placeholders (not a real IP).
const maskAttr = createMaskGate(t);
const isStarted = ref(false);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
Expand Down
Loading
Loading