diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7472e45cd --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example index 13c9c427c..2e2efed02 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/.gitignore b/.gitignore index a98fcdd8a..a3b6e7f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist-ssr *.sln *.sw? .env +.env.forker .vscode/extensions.json package-lock.json @@ -51,6 +52,7 @@ common/as-org-db/*.next common/as-rel-db/*.next .learnings/ docs/ +.plan/ # Local Scripts scripts/ diff --git a/AGENTS.md b/AGENTS.md index 1ce871c63..972977c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/LICENSE b/LICENSE index c41881bdd..3029ed567 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 8e113501f..d4786513f 100644 --- a/README.md +++ b/README.md @@ -154,4 +154,6 @@ As a open source project, I'm very grateful to the following sponsors for their GitBook +v.ps + Cloudflare Project Alexandria diff --git a/README_FR.md b/README_FR.md index e97cb8d4e..925cd9de3 100644 --- a/README_FR.md +++ b/README_FR.md @@ -154,4 +154,6 @@ En tant que projet open source, je suis très reconnaissant aux sponsors suivant GitBook +v.ps + Cloudflare Project Alexandria diff --git a/README_RU.md b/README_RU.md index e5a7c0e3e..996baa305 100644 --- a/README_RU.md +++ b/README_RU.md @@ -154,4 +154,6 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 GitBook +v.ps + Cloudflare Project Alexandria diff --git a/README_ZH.md b/README_ZH.md index 32dcbfe58..f347793be 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -154,4 +154,6 @@ DOMAIN,ptest-8.ipcheck.ing,Proxy8 GitBook +v.ps + Cloudflare Project Alexandria diff --git a/api/AGENTS.md b/api/AGENTS.md index 74e4eb221..056b44391 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -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. @@ -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 diff --git a/api/invisibility-test.js b/api/invisibility-test.js index 3d251b424..08b74026b 100644 --- a/api/invisibility-test.js +++ b/api/invisibility-test.js @@ -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) { diff --git a/api/ipinfo-io.js b/api/ipinfo-io.js index 8be0aef5f..c4f9e91bf 100644 --- a/api/ipinfo-io.js +++ b/api/ipinfo-io.js @@ -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 }); diff --git a/api/persona.js b/api/persona.js new file mode 100644 index 000000000..69e82f714 --- /dev/null +++ b/api/persona.js @@ -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' }); + } +}; diff --git a/api/sentry-tunnel.js b/api/sentry-tunnel.js index c84826de5..70133a2c8 100644 --- a/api/sentry-tunnel.js +++ b/api/sentry-tunnel.js @@ -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. diff --git a/backend-server.js b/backend-server.js index 2c3a4b7a7..dba20bc01 100644 --- a/backend-server.js +++ b/backend-server.js @@ -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'; @@ -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) { @@ -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. diff --git a/common/report-schema.js b/common/report-schema.js index 2c87a0285..c0a45ae84 100644 --- a/common/report-schema.js +++ b/common/report-schema.js @@ -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. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 133eac2f2..dfdb21e77 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -32,9 +32,10 @@ frontend/ │ pulse-statuses (Earth Online vocabulary: presets + │ date-windowed festival statuses + their celebration │ effect mapping; recipes in utils/pulse-celebration.js)/ -│ connectivity-import-lists (curated target sets; icons -│ are committed 64px PNGs under public/favicons/ — one -│ per member, enforced by its data test) +│ connectivity-import-lists (default target set + +│ curated import sets + the shared cap; icons are +│ committed 64px PNGs under public/favicons/ — one per +│ member, enforced by its data test) ├── utils/ ← framework-agnostic helpers + IO │ (app-events bus / getips/ / valid-ip / analytics / …) ├── composables/ ← Vue-aware `useXxx` logic @@ -84,6 +85,26 @@ semantics or an upstream field means updating that test's builder whitelist field) and test fixtures are frozen, so drift shows up as quietly missing report fields, not as errors. +A report link is readable by anyone who has it, so the builder — not the +renderer — is where anything the visitor supplied gets dropped. Persona +Check is the sharpest case: its live results carry a `detail` per check +(issuing bank, the country a shared position resolved to), and only the +id / axis / verdict triple reaches the section. Invisibility follows the +same rule with key + flag. Keep new sections on that side of the line. + +### Overlays take no keyboard shortcuts + +`utils/shortcut.js` runs one document-level keydown dispatcher over the map +`composables/use-shortcuts.js` registers — every entry there is a home-page +action, and the whole map is suspended while any overlay is open. The rule keys +off the component's form, not its purpose: the `ui/` roots (`Dialog` / `Sheet` / +`Drawer`) call `composables/use-overlay-shortcuts.js`, so anything built on them +inherits it, and overlays nest. Esc and the native scrolling keys still work — +reka-ui / vaul and the browser own those. + +`registerShortcuts()` replaces the map rather than appending, and Home clears it +on unmount — shortcuts belong to the home route alone. + ### Error monitoring (Sentry) is env-gated and invisible to app code `sentry-init.js` loads via a build-time-gated dynamic import: no @@ -118,12 +139,14 @@ upload at build, gated on `SENTRY_AUTH_TOKEN`. ## UI system -**shadcn-vue first.** Check `components/ui/` (21 copied-in primitives), then +**shadcn-vue first.** Check `components/ui/` (copied-in primitives), then https://www.shadcn-vue.com/docs/components for something to copy in; -hand-rolled Tailwind only when neither fits. Two local notes: `Spinner` is -project-specific (lucide `Loader2` + `role="status"`); `toggle` / -`toggle-group` deliberately use the `primary` pair for the pressed state — -don't revert that when syncing upstream. +hand-rolled Tailwind only when neither fits. Four local notes: `Spinner` +(lucide `Loader2` + `role="status"`) and `ToolLoadingSkeleton` (chunk-loading +placeholder for lazy tools) are project-specific; `toggle` / `toggle-group` +deliberately use the `primary` pair for the pressed state; and the `Dialog` / +`Sheet` / `Drawer` roots suspend the keyboard shortcuts while open — keep all +of these when syncing upstream. ### Design tokens @@ -166,14 +189,25 @@ Copy from the named exemplar instead of re-inventing: transition (Connectivity / WebRTC / IPCard). `jn-card` = shadow / border / keyboard outline; `keyboard-shortcut-card` = J/K navigation target. - **Flag** — always ``. +- **Dates & times** — every user-visible stamp renders through + `utils/time-utils.js` with the vue-i18n locale: `formatIsoDate` (date-only + ISO — changelog, OONI windows, IP-history day headers), `isoToDateTime` + (ISO instants — report generated / expiry / per-section stamps), + `unixToDateTime` (epoch ms — account & achievement dates), + `relativeTimeFromMinutes` / `relativeTimeSince` / `formatDuration` (Pulse). + No hand-rolled `toLocaleDateString` / `Intl.DateTimeFormat` in components; + a deliberate exception carries a comment saying why (ASNHistory's + fixed-width ISO columns, report-export's AI-facing ISO intro, + ServiceStatus's seconds-bearing refresh clock). - **Fit-to-width tokens** — IP / MAC strings render inside `` (`HERO_TIERS` hero rows, `INLINE_TIERS` compact rows; `:max-lines="2"` on heroes). Never per-component length-threshold helpers (IPCard, QueryIP). - **Tables vs lists** — real per-column header semantics → ``; otherwise a bordered `