Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
a062bbc
Feat(quota): monthly quotas for advanced features, benefits & usage d…
jason5ng32 Aug 13, 2026
fdd1281
Feat(ui): localize all user-visible dates via shared time helpers
jason5ng32 Aug 14, 2026
aab3cc5
Chore(changelog): stamp v7.3.0 release date, start v7.4.0 entry
jason5ng32 Aug 14, 2026
7d009fb
Chore(docs): document the dates & times canonical pattern
jason5ng32 Aug 14, 2026
34ac46e
Merge branch 'dev' into feat/quota
jason5ng32 Aug 15, 2026
aa4524d
Feat(quota): surface unique-IP metering for ipinfo in copy and store …
jason5ng32 Aug 15, 2026
cc8e9a0
Improvements
jason5ng32 Aug 15, 2026
26ab6ac
Improvements
jason5ng32 Aug 15, 2026
5f7dfe5
Improvements
jason5ng32 Aug 16, 2026
153f76a
Improvements
jason5ng32 Aug 16, 2026
3841af0
Improvements
jason5ng32 Aug 16, 2026
228d457
Improvements
jason5ng32 Aug 16, 2026
33637f5
Improvements
jason5ng32 Aug 16, 2026
7cd879f
Improvements
jason5ng32 Aug 17, 2026
552763b
Add Combobox
jason5ng32 Aug 18, 2026
42dfd92
Add OTP Component
jason5ng32 Aug 18, 2026
735555d
Sentry Improvement
jason5ng32 Aug 18, 2026
228755d
Adv Tools can be unstandalone
jason5ng32 Aug 18, 2026
0ff2615
Chore: Add vue-input-otp dependency
jason5ng32 Aug 19, 2026
9d65365
Feat(widgets): Add CountryPicker
jason5ng32 Aug 19, 2026
87d0f82
Feat(api): Add persona evaluate proxy
jason5ng32 Aug 19, 2026
7becf39
Feat(tools): Add Persona Check
jason5ng32 Aug 19, 2026
f6c8556
Docs(privacy): Disclose what Persona Check sends
jason5ng32 Aug 19, 2026
d377f19
Improvements
jason5ng32 Aug 19, 2026
918f667
Feat(report): Include Persona Check in shareable reports
jason5ng32 Aug 19, 2026
8d55118
Add shortcut to persona check
jason5ng32 Aug 19, 2026
337d03f
Improvements
jason5ng32 Aug 19, 2026
020053e
Improvements
jason5ng32 Aug 19, 2026
ac06336
Improvements
jason5ng32 Aug 19, 2026
26f744b
Improvements
jason5ng32 Aug 19, 2026
69e75a7
Improvements
jason5ng32 Aug 19, 2026
3bb49db
Improvements
jason5ng32 Aug 19, 2026
30a3250
Fix(api): Tolerate missing optional fields from ipinfo.io
jason5ng32 Aug 19, 2026
8c78e37
Fix(ui): Skip geo lookup when a source returns a non-public IP
jason5ng32 Aug 19, 2026
123f17f
Improvements
jason5ng32 Aug 19, 2026
f2460db
Improvements
jason5ng32 Aug 19, 2026
cd621c1
Try to fix scrolling bug
Jasonhiddle512 Aug 20, 2026
fd3403a
Fix the scrolling bug
Jasonhiddle512 Aug 20, 2026
93d867d
Improvements
jason5ng32 Aug 20, 2026
d6fb27d
Merge pull request #376 from Jasonhiddle512/dev
jason5ng32 Aug 20, 2026
f5d2601
Fix(ui): Suspend keyboard shortcuts while an overlay is open
jason5ng32 Aug 20, 2026
802507e
Improvements
jason5ng32 Aug 20, 2026
0cc7efc
Fix(ui): Show IP History addresses in the clear
jason5ng32 Aug 20, 2026
e548b6e
Fix(ui): Anchor ASN graph gesture zoom on the SVG rect
jason5ng32 Aug 20, 2026
ccac693
Fix(ui): Drop shortcut actions queued when an overlay opens
jason5ng32 Aug 20, 2026
89c4327
Improvements
jason5ng32 Aug 20, 2026
a703442
Improvements
jason5ng32 Aug 20, 2026
2eddb72
Improvements
jason5ng32 Aug 20, 2026
1776107
Improvements
jason5ng32 Aug 20, 2026
e757c61
Improvements
jason5ng32 Aug 20, 2026
e2c0d8d
Feat(connectivity): User-managed target set with removable presets, p…
jason5ng32 Aug 20, 2026
cc2442f
Fix(connectivity): Settle-time aggregate over surviving targets, fail…
jason5ng32 Aug 20, 2026
8d94aaa
Improvements
jason5ng32 Aug 20, 2026
95401e3
Fix(connectivity): Scope writes to the current run and verdicts to th…
jason5ng32 Aug 20, 2026
3f9feb1
Improvements
jason5ng32 Aug 20, 2026
0e8db72
Fix(ui): Move focus into the drawer on open
jason5ng32 Aug 20, 2026
c85e370
Feat(connectivity): Add the Brazil Essentials import list
jason5ng32 Aug 20, 2026
df37712
Fix(connectivity): Sanitize the stored target set on load
jason5ng32 Aug 20, 2026
d50befc
Feat(ui): Show a spinner while a lazy tool component loads
jason5ng32 Aug 20, 2026
0f0160d
Feat(ui): Skeleton for tool chunk loading, in-context spinners for to…
jason5ng32 Aug 20, 2026
b5b60a0
Refactor(ui): Move ToolLoadingSkeleton into components/ui
jason5ng32 Aug 20, 2026
80c0d88
Improvements
jason5ng32 Aug 21, 2026
d711b7b
Feat(pulse): Two-tier gating — outage feed no longer needs the pulse …
jason5ng32 Aug 21, 2026
e4ae72c
Fix(shortcuts): Register reactively instead of racing configs on a 2s…
jason5ng32 Aug 21, 2026
41b6354
Improvements
jason5ng32 Aug 21, 2026
6c70d47
Improvements
jason5ng32 Aug 21, 2026
40929b7
Improvements
jason5ng32 Aug 21, 2026
f98ee53
Update frontend/locales/fr.json
jason5ng32 Aug 21, 2026
4df8b78
Improvements
jason5ng32 Aug 22, 2026
46ce613
Improvements
jason5ng32 Aug 22, 2026
60cac96
Merge pull request #388 from jason5ng32/dev
jason5ng32 Aug 22, 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
18 changes: 18 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# EditorConfig — the file-level basics (charset, line endings, indent, final
# newline) that every editor and formatter should agree on, so a "format
# document" in one editor doesn't produce diff noise for the next person.
# https://editorconfig.org

root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
# Two trailing spaces are a hard line break in Markdown — don't strip them.
trim_trailing_whitespace = false
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ VITE_SITE_URL="https://ipcheck.ing"
# assistant and the Help Center link. Empty disables both.
VITE_DOCS_URL=""
# EARTH ONLINE — full URL of the pulse backend (trailing slashes stripped).
# Empty disables the feature entirely (build-time, like Sentry).
VITE_PULSE_URL=""
# Empty hides the social parts (status feed / visitor map / visit beacon) at
# build time, like Sentry. The outage feed is independent — it only needs
# CLOUDFLARE_API_KEY above.
VITE_PULSE_BEACON_URL=""
# CURL API
VITE_CURL_IPV4_DOMAIN=""
VITE_CURL_IPV6_DOMAIN=""
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dist-ssr
*.sln
*.sw?
.env
.env.forker
.vscode/extensions.json

package-lock.json
Expand All @@ -51,6 +52,7 @@ common/as-org-db/*.next
common/as-rel-db/*.next
.learnings/
docs/
.plan/

# Local Scripts
scripts/
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Area-specific details: @frontend/AGENTS.md (Vue SPA) · @api/AGENTS.md (Express

**MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity
tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security
checklist, browser fingerprint, anonymity checks, and more. Single repo, two
checklist, browser fingerprint, anonymity checks, persona check, and
more. Single repo, two
halves: a Vue 3 SPA front-end and an Express 5 back-end API.

## Stack
Expand Down Expand Up @@ -77,7 +78,9 @@ use npm / yarn — they'd produce a competing lockfile.

### Comments

- **Every new file opens with a header comment** stating its purpose.
- **Every new file opens with a header comment** stating its purpose —
except `frontend/components/ui/`, which holds shadcn-vue CLI output kept
verbatim so it can be re-synced (see frontend/AGENTS.md).
- **Large templates / functions carry block comments** per meaningful region.
- **Comments describe the code as it is now** — no changelog narration
(`previously…`, `…fixes that`); git history covers the past. A comment
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Jason Ng
Copyright (c) 2026 Jason Ng

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,6 @@ As a open source project, I'm very grateful to the following sponsors for their

<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>

<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=en"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="v.ps" title="v.ps" width="240px" /></a>

<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
2 changes: 2 additions & 0 deletions README_FR.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,6 @@ En tant que projet open source, je suis très reconnaissant aux sponsors suivant

<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>

<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=fr"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="v.ps" title="v.ps" width="240px" /></a>

<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
2 changes: 2 additions & 0 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,6 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8

<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>

<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=ru"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="v.ps" title="v.ps" width="240px" /></a>

<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
2 changes: 2 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,6 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8

<a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" title="GitBook" width="240px" /></a>

<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=zh"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="v.ps" title="v.ps" width="240px" /></a>

<a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" title="Cloudflare Project Alexandria" width="240px" /></a>
8 changes: 5 additions & 3 deletions api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` /
`maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` /
`cf-radar` / `net-outages` / `asn-history` / `asn-connectivity` /
`ooni-blocking` / `globalping-probes` / `service-status` / `google-map` /
`github-stars` / `invisibility-test` / `dns-leak-test`), user
`github-stars` / `invisibility-test` / `dns-leak-test` / `persona`), user
proxies (`get-user-info` / `update-user-achievement`), platform
(`configs` / `sentry-tunnel` / `share-report`). Each file's header comment
states its route and purpose — read those for specifics.
Expand Down Expand Up @@ -101,9 +101,11 @@ A new geo source inherits the field by adding the middleware to its route.

Handlers proxying our private IPCheck.ing API (`ipcheck-ing`,
`invisibility-test`, `update-user-achievement`, `get-user-info`,
`dns-leak-test`) forward the caller's headers upstream
`dns-leak-test`, `persona`) forward the caller's headers upstream
(`headers: { ...req.headers }`) — the upstream needs caller context
(Accept-Language, auth tokens). Do **not** replicate for third-party
(Accept-Language, auth tokens). `persona` is the one that strips the framing
headers first (`host` / `content-length` / …): it re-serializes the body, so
the caller's length no longer describes what goes out. Do **not** replicate for third-party
upstreams; those get only what's explicitly needed.

### Defensive method gates
Expand Down
10 changes: 10 additions & 0 deletions api/invisibility-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export default async (req, res) => {
return res.json({ status: 'pending' });
}

// Upstream 429 = monthly quota exhausted. Pass status + code through
// so the frontend can point at the sponsor path; not an error for us.
if (apiResponse.status === 429) {
const errorData = await apiResponse.json().catch(() => ({}));
return res.status(429).json({
error: errorData.error || 'Monthly quota exceeded',
code: 'quota_exceeded'
});
}

// Upstream 401/403. Pass the status through so the frontend prompts
// sign-in instead of retrying; keep it off the error logger.
if (apiResponse.status === 401 || apiResponse.status === 403) {
Expand Down
46 changes: 31 additions & 15 deletions api/ipinfo-io.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,43 @@ function buildUrl(req) {
return token ? url_hasToken : url_noToken;
}

function modifyJson(json) {
const { ip, city, region, country, loc, org } = json;

const countryName = countryLookup.byIso(country).country || 'Unknown Country';

const [latitude, longitude] = loc.split(',').map(Number);
const [asn, ...orgName] = org.split(' ');
const modifiedOrg = orgName.join(' ');
// Parse one half of `loc`. Blank and non-numeric halves become null rather
// than the 0 Number() would hand back — 0 is a real coordinate.
const toCoordinate = (raw) => {
const value = Number(raw);
return raw?.trim() && Number.isFinite(value) ? value : null;
};

// Every field is optional upstream: anycast ranges, bogons ({"bogon":true})
// and degraded answers all come back 200 with `loc`, `org` or `country`
// missing, so nothing here may be split or dereferenced unguarded. Absent
// data degrades to null / empty while the canonical shape stays complete.
export const modifyJson = (json) => {
const { ip, city, region, country, loc, org } = json || {};

// byIso returns null for an unknown or absent code.
const countryName = countryLookup.byIso(country)?.country || 'Unknown Country';

// "37.4056,-122.0775" — a partial or non-numeric pair degrades to null.
const [rawLat, rawLon] = typeof loc === 'string' ? loc.split(',') : [];
const latitude = toCoordinate(rawLat);
const longitude = toCoordinate(rawLon);

// "AS15169 Google LLC" — leading token is the ASN, the rest the org name.
const [asn = '', ...orgName] = typeof org === 'string' ? org.split(' ') : [];

return {
ip,
city,
region,
country,
ip: ip ?? null,
city: city ?? null,
region: region ?? null,
country: country ?? null,
country_name: countryName,
country_code: country,
country_code: country ?? null,
latitude,
longitude,
asn,
org: modifiedOrg
org: orgName.join(' ')
};
}
};

export default makeGeoHandler({ name: 'ipinfo-io', buildUrl, normalize: modifyJson });
57 changes: 57 additions & 0 deletions api/persona.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// POST /api/persona/evaluate — Persona Check.
//
// Thin proxy to the main IPCheck.ing API, which owns the evaluation this
// deployment deliberately does not carry. One observation in, one graded
// report out; per-visitor, never cached. The caller's headers travel
// upstream (the evaluator needs the request context); results come back as
// ids and enums, and the front end renders its own four-language copy.

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

// Headers that describe *this* hop rather than the caller, dropped before the
// request is rebuilt.
const HOP_HEADERS = ['host', 'content-length', 'content-type', 'connection', 'transfer-encoding'];

const callerHeaders = (req) => {
const headers = { ...req.headers };
for (const name of HOP_HEADERS) delete headers[name];
return headers;
};

export default async (req, res) => {
// Defensive method gate (the route is POST-only) — covered by tests.
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}

const apiKey = process.env.IPCHECKING_API_KEY;
const apiEndpoint = process.env.IPCHECKING_API_ENDPOINT;
if (!apiKey || !apiEndpoint) {
return res.status(500).json({ error: 'API key is missing' });
}

if (!req.body?.persona?.country) {
return res.status(400).json({ error: 'No persona provided' });
}

const url = new URL(`${apiEndpoint}/persona/evaluate`);
url.searchParams.set('key', apiKey);

try {
const apiResponse = await fetchUpstream(url, {
method: 'POST',
headers: { ...callerHeaders(req), 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});

// Status and payload pass through verbatim so the frontend can tell a
// rejected request from an unreachable evaluator and degrade to its
// error line rather than a blank report.
const data = await apiResponse.json().catch(() => ({}));
return res.status(apiResponse.status).json(data);
} catch (error) {
logger.error({ err: error }, 'persona evaluate upstream fetch failed');
return res.status(502).json({ error: 'Upstream fetch failed' });
}
};
14 changes: 13 additions & 1 deletion api/sentry-tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,19 @@ export default async (req, res) => {
headers: { 'Content-Type': 'application/x-sentry-envelope' },
body,
});
res.status(apiRes.status).send(await apiRes.text());
const relayed = await apiRes.text();
if (!apiRes.ok) {
// Ingest refuses an envelope for reasons the browser can do
// nothing about — most often size, since replay recordings travel
// uncompressed here. Without this line the rejection is visible
// only in the visitor's devtools.
logger.warn({
statusCode: apiRes.status,
envelopeBytes: body.length,
bodyPreview: relayed.slice(0, 200),
}, 'sentry tunnel upstream rejected the envelope');
}
res.status(apiRes.status).send(relayed);
} catch (error) {
// warn, not error: a transient relay failure is infra noise, not an
// application defect worth a grouped Issue of its own.
Expand Down
10 changes: 7 additions & 3 deletions backend-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import createReportHandler, { getReport as getReportHandler } from './api/share-
import invisibilitytestHandler from './api/invisibility-test.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
import personaEvaluateHandler from './api/persona.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
Expand Down Expand Up @@ -182,13 +183,14 @@ const rateLimiter = rateLimit({
const speedLimiter = slowDown({
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
skip: (req) => req.path === '/monitoring',
delayMs: (used, req) => (used - req.slowDown.limit) * 400,
maxDelayMs: 5000,
skip: (req) => req.path === '/monitoring' || req.path === '/maxmind',
})

if (rateLimitSet !== 0) {
app.use('/api', rateLimiter);
logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 60 minutes`);
logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 20 minutes`);
}

if (speedLimitSet !== 0) {
Expand Down Expand Up @@ -282,6 +284,8 @@ app.put('/api/updateuserachievement', updateUserAchievement);
// KV expiry, and private diagnostic data doesn't belong in a public cache.
app.get('/api/report/:id', requireValidReportId(), getReportHandler);
app.post('/api/report', createReportHandler);
// One observation in, one graded report out — per-visitor by definition.
app.post('/api/persona/evaluate', personaEvaluateHandler);

// Sentry tunnel — first-party relay for the frontend SDK's envelopes
// Mounted only when this deployment actually built the frontend with a DSN.
Expand Down
32 changes: 32 additions & 0 deletions common/report-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,38 @@ const SECTION_SPECS = {
cd: opt(bool()),
})),
}),
// Persona Check. Deliberately verdict-only: each check contributes its id,
// its axis and its conclusion, and nothing else. The live report's `detail`
// objects carry what the visitor themselves supplied or what their machine
// exposed — issuing bank, the country their position resolved to, their
// zone and language list — none of which belongs behind a link anyone can
// open. Same doctrine as the invisibility section, which stores its signals
// as key + flag rather than the evidence behind them.
persona: obj({
testedAt: isoDate(),
country: countryCode(),
grade: oneOf('A', 'B', 'C', 'D', 'unknown'),
// Null whenever there was too little signal to grade at all.
score: nullable(int(0, 100)),
counts: obj({
total: int(0, 64),
scored: int(0, 64),
match: int(0, 64),
mismatch: int(0, 64),
unnatural: int(0, 64),
leak: int(0, 64),
unknown: int(0, 64),
notApplicable: int(0, 64),
}),
// `id` stays a capped string rather than an enum: the check registry
// lives in the evaluating API, and duplicating its ids here would be a
// second list to keep in step for no validation gain.
results: arr(32, obj({
id: str(48),
axis: oneOf('match', 'coherence', 'leak'),
verdict: oneOf('match', 'mismatch', 'leak', 'unnatural', 'unknown', 'not-applicable'),
})),
}),
};

// Section ids in homepage order — the report page renders in this order.
Expand Down
Loading
Loading