Merge pull request #512 from jason5ng32/dev

Sync with Dev
This commit is contained in:
Jason Ng authored and GitHub committed 2026-10-22 11:24:25 +08:00
commit b7cf99fc36
349 files changed
+8326 -2583

No files matched your search

+1 -1
View File
@@ -9,5 +9,5 @@ Summary of the change, and the issue it fixes if any (e.g. `Closes #123`).
- [ ] Targets `dev`; one concern per PR
- [ ] `pnpm check` is green locally (tests + build)
- [ ] Logic changes ship with a spec in `tests/`
- [ ] User-visible copy lands in all five `full` locales (`en` / `zh` / `zh-TW` / `fr` / `ru`)
- [ ] User-visible copy lands in every `full` locale (`en` / `zh` / `zh-TW` / `fr` / `ru` / `pt-BR`)
- [ ] I've read [CONTRIBUTING.md](../CONTRIBUTING.md) and the relevant `AGENTS.md`
+1 -1
View File
@@ -33,7 +33,7 @@ localtest
.VSCodeCounter
# Offline datasets (common/datasets.js): every common/<name>-db/ directory
# Offline datasets (server/datasets/datasets.js): every common/<name>-db/ directory
# holds only downloaded data, its state and lock — created at runtime, never
# committed (CAIDA's data is under a non-commercial license).
common/*-db/
+7 -4
View File
@@ -33,7 +33,7 @@ Express 5 back-end API.
| Build | Vite + `@vitejs/plugin-vue`; Tailwind CSS v4 + `tw-animate-css` |
| UI | shadcn-vue copy-in primitives (reka-ui) · lucide icons · circle-flags via `@iconify/vue` · vaul-vue drawer · vue-sonner toast |
| Backend | Express 5 |
| Logger | `pino` singleton at `common/logger.js` (+ `pino-http`, opt-in) |
| Logger | `pino` singleton at `server/logger.js` (+ `pino-http`, opt-in) |
| Auth | Firebase Auth (optional, env-gated) |
| Error monitoring | Sentry — optional & env-gated on both halves: `@sentry/vue` (no `VITE_SENTRY_DSN_FRONTEND`, no Sentry in the build — see frontend/AGENTS.md) + `@sentry/node` (no `SENTRY_DSN_BACKEND`, never loaded — see api/AGENTS.md) |
| PWA | `manifest.webmanifest` only — installable but online-only, no service worker |
@@ -62,8 +62,11 @@ use npm / yarn — they'd produce a competing lockfile.
├── AGENTS.md / CLAUDE.md ← this file + Claude pointer to it
├── frontend/ ← Vue 3 SPA (see frontend/AGENTS.md)
├── api/ ← Express handlers (see api/AGENTS.md)
├── common/ ← code shared by both halves (valid-ip /
│ fetch-with-timeout / guards / logger / …)
├── server/ ← backend-only code (logger / guards / …;
│ datasets/ = the offline dataset engine)
├── common/ ← code the frontend or build imports too
│ (valid-ip / fetch-with-timeout / …) + the
│ dataset dirs (`*-db/`, a self-host mount point)
├── tests/ ← Node test runner specs
├── backend-server.js ← Express app (default port 11966)
├── sentry-instrument.js ← backend Sentry bootstrap via `node --import`;
@@ -112,7 +115,7 @@ use npm / yarn — they'd produce a competing lockfile.
### Logging (backend)
- **Always the shared logger** (`common/logger.js`) in backend files; bare
- **Always the shared logger** (`server/logger.js`) in backend files; bare
`console.*` is banned there (frontend keeps using `console.*`).
- Pino first-arg-is-context: `logger.error({ err, ip }, 'short message')`.
- Env knobs: `LOG_LEVEL` (default info), `LOG_FORMAT=json` for shippers,
+16 -12
View File
@@ -36,7 +36,8 @@ from building something that can't be merged.
```
frontend/ Vue 3 SPA (Pinia, vue-router, vue-i18n, Tailwind v4 + shadcn-vue)
api/ Express 5 handlers, one file per route (wired in backend-server.js)
common/ Code shared by both halves (validators, fetch helper, logger, …)
server/ Backend-only code (logger, guards, upstream clients, dataset engine, …)
common/ Code both halves import (validators, fetch helper, report schema, …)
tests/ Node test runner specs (node --test)
```
@@ -58,10 +59,10 @@ Details and rationale live in the AGENTS.md files; the headlines:
- **New functions use `const` arrow syntax** (`const fn = async () => {}`).
- **Every new file opens with a header comment** stating its purpose.
- **The `full` locales land together** — user-visible copy ships in `en` / `zh` / `zh-TW` /
`fr` / `ru` in the same PR (`frontend/locales/`; tests enforce it). `beta` locales are
exempt — they fall back to English.
- **Backend logging goes through the shared pino logger** (`common/logger.js`) —
no `console.*` in `api/` or `common/`.
`fr` / `ru` / `pt-BR` in the same PR (`frontend/locales/`; tests enforce it). `beta`
locales are exempt — they fall back to English.
- **Backend logging goes through the shared pino logger** (`server/logger.js`) —
no `console.*` in `api/`, `server/` or `common/`.
## Good places to start
@@ -80,19 +81,22 @@ runnable directly as `pnpm fetch-favicons`). Only if auto-fetch can't find a usa
PNG do you hand-source one (on macOS, `sips` handles ICO→PNG). Remember to commit
the PNGs with your change — CI stays offline and only checks they exist.
**README translations** — beyond the five READMEs we maintain (`en` / `zh` / `zh-TW` / `fr` /
`ru`), community-maintained translations in any language are welcome. Create
**README translations** — beyond the READMEs we maintain (`en` / `zh` / `zh-TW` / `fr` /
`ru`) and the community-maintained `pt-BR` one, translations in any language are welcome. Create
`README_<LANG>.md` from the English [README.md](README.md) (the canonical source),
open it with a one-line "community-maintained translation — English is canonical"
note in your language, and add your language to the switcher row near the top of the
existing READMEs. Keep code blocks, URLs, and badges unchanged. Check open issues
for requested languages before starting.
**UI translations** — improvements to the existing `en` / `zh` / `zh-TW` / `fr` / `ru` packs
are welcome, and so is a whole new language. Adding one is two files: a locale pack under
`frontend/locales/` and a line in `common/locale-registry.js`. **A partial pack is a
welcome first PR** — anything you don't translate falls back to English, and the language
ships as `beta` until it's complete. Full walkthrough: **[TRANSLATING.md](TRANSLATING.md)**.
**UI translations** — improvements to the existing `en` / `zh` / `zh-TW` / `fr` / `ru` /
`pt-BR` packs are always welcome. New languages are added by visitor demand, not on request:
the open **Translations wanted** issue lists the ones currently up for claiming, and a pack
for any other language won't be merged. For a listed language, adding it is two files: a
locale pack under `frontend/locales/` and a line in `common/locale-registry.js`. **A partial
pack is a welcome first PR** — anything you don't translate falls back to English, and the
language ships as `beta` until it's complete. Full walkthrough:
**[TRANSLATING.md](TRANSLATING.md)**.
## Bugs & feature requests
+1
View File
@@ -29,6 +29,7 @@ COPY --from=build-stage /app/frontend-server.js ./
# npm start boots the backend with `--import ./sentry-instrument.js`
COPY --from=build-stage /app/sentry-instrument.js ./
COPY --from=build-stage /app/api ./api
COPY --from=build-stage /app/server ./server
COPY --from=build-stage /app/common ./common
EXPOSE 18966
+6 -1
View File
@@ -19,7 +19,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
An open-source, all-in-one IP toolbox: IP lookup from multiple sources, connectivity tests, WebRTC & DNS-leak detection, speed test, MTR, censorship checks, Whois, and more — self-hostable with one Docker command.
@@ -44,6 +44,7 @@ Feel free to bookmark the demo or deploy your own.
* 🚥 **WebRTC Detection**: Reveals the IP address exposed during WebRTC connections — including whether your browser's privacy hardening is on.
* 🛑 **DNS Leak Test**: Shows which DNS endpoints resolve your queries, to evaluate the risk of DNS leaks when using VPNs or proxies.
* 🛡️ **Ad & Tracker Blocking Test**: Checks what your ad blocker, browser, DNS filter, VPN or router actually blocks — ad networks, analytics, social trackers, error monitoring, device-maker telemetry, tracking scripts and element hiding.
* 📋 **Security Checklist**: A 258-item personal cybersecurity checklist across 12 areas, with progress saved in your browser.
### 📡 Network Tests
@@ -167,6 +168,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+6 -1
View File
@@ -22,7 +22,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
Une boîte à outils IP tout-en-un et open source : recherche d'IP depuis plusieurs sources, tests de connectivité, détection WebRTC et de fuites DNS, test de vitesse, MTR, vérification de la censure, Whois et plus encore — auto-hébergeable en une seule commande Docker.
@@ -47,6 +47,7 @@ N'hésitez pas à mettre la démo en favori ou à déployer votre propre instanc
* 🚥 **Détection WebRTC** : Révèle l'adresse IP exposée lors des connexions WebRTC — y compris si les protections de confidentialité de votre navigateur sont actives.
* 🛑 **Test de fuite DNS** : Montre quels points de terminaison DNS résolvent vos requêtes, afin d'évaluer le risque de fuite DNS lors de l'utilisation d'un VPN ou d'un proxy.
* 🛡️ **Test de blocage des pubs et traceurs** : Vérifie ce que votre bloqueur de pubs, navigateur, filtre DNS, VPN ou routeur bloque vraiment — régies publicitaires, statistiques, traceurs sociaux, suivi des erreurs, télémétrie des fabricants d'appareils, scripts de pistage et masquage d'éléments.
* 📋 **Liste de contrôle de cybersécurité** : Une liste personnelle de 258 points de cybersécurité répartis en 12 domaines, avec progression sauvegardée dans votre navigateur.
### 📡 Tests réseau
@@ -170,6 +171,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+6 -1
View File
@@ -22,7 +22,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
Uma caixa de ferramentas de IP completa e de código aberto: consulta de IP a partir de múltiplas fontes, testes de conectividade, detecção de WebRTC e de vazamento de DNS, teste de velocidade, MTR, verificação de censura, Whois e muito mais — auto-hospedável com um único comando Docker.
@@ -47,6 +47,7 @@ Adicione a demonstração aos favoritos ou faça sua própria implantação.
* 🚥 **Detecção de WebRTC**: Revela o endereço IP exposto durante conexões WebRTC — inclusive se as proteções de privacidade do seu navegador estão ativas.
* 🛑 **Teste de vazamento de DNS**: Mostra quais endpoints DNS resolvem suas consultas, para avaliar o risco de vazamentos de DNS ao usar VPNs ou proxies.
* 🛡️ **Teste de bloqueio de anúncios e rastreadores**: Verifica o que seu bloqueador de anúncios, navegador, filtro de DNS, VPN ou roteador realmente bloqueia — redes de anúncios, análise, rastreadores sociais, monitoramento de erros, telemetria de fabricantes de dispositivos, scripts de rastreamento e ocultação de elementos.
* 📋 **Checklist de segurança**: Um checklist pessoal de cibersegurança com 258 itens em 12 áreas, com progresso salvo no seu navegador.
### 📡 Testes de rede
@@ -170,6 +171,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+6 -1
View File
@@ -22,7 +22,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
Универсальный набор IP-инструментов с открытым исходным кодом: определение IP по нескольким источникам, проверка доступности сайтов, обнаружение утечек WebRTC и DNS, тест скорости, MTR, проверка цензуры, Whois и многое другое — разворачивается на своём сервере одной командой Docker.
@@ -47,6 +47,7 @@
* 🚥 **Обнаружение WebRTC**: показывает IP-адрес, раскрываемый при подключениях WebRTC, — в том числе включена ли в вашем браузере усиленная защита приватности.
* 🛑 **Тест утечки DNS**: показывает, какие конечные точки DNS обрабатывают ваши запросы, чтобы оценить риск утечки DNS при использовании VPN или прокси.
* 🛡️ **Проверка блокировки рекламы и трекеров**: показывает, что на самом деле блокирует ваш блокировщик рекламы, браузер, DNS-фильтр, VPN или роутер, — рекламные сети, аналитику, трекеры соцсетей, мониторинг ошибок, телеметрию производителей устройств, скрипты отслеживания и скрытие элементов.
* 📋 **Контрольный список безопасности**: 258 рекомендаций по личной кибербезопасности в 12 разделах; прогресс сохраняется в браузере.
### 📡 Сетевые тесты
@@ -170,6 +171,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+216
View File
@@ -0,0 +1,216 @@
# 🧰 MyIP - Një kuti më e mirë mjetesh IP
> [!NOTE]
> Ky është një përkthim i mirëmbajtur nga komuniteti; README-ja në anglisht është burimi zyrtar dhe ky version mund të mbetet prapa.
<div align="center">
![IPCheck.ing Banner](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/gh_banner.png)
<a href="https://trendshift.io/repositories/5332" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5332" alt="jason5ng32%2FMyIP | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![Mentioned in Awesome Self Hosted](https://awesome.re/mentioned-badge.svg)](https://github.com/awesome-selfhosted/awesome-selfhosted)
![GitHub Repo stars](https://img.shields.io/github/stars/jason5ng32/MyIP)
![GitHub forks](https://img.shields.io/github/forks/jason5ng32/myip)
![Docker Pulls](https://img.shields.io/docker/pulls/jason5ng32/myip)
[![Website](https://img.shields.io/website?url=https%3A%2F%2Fipcheck.ing&up_message=online&label=IPCheck.ing 'IPCheck.ing')](https://ipcheck.ing)
![PWA](https://img.shields.io/badge/PWA-Supported-blue)
![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
Një kuti mjetesh IP gjithëpërfshirëse dhe me burim të hapur: kërkim IP nga shumë burime, teste lidhshmërie, zbulim i rrjedhjeve WebRTC dhe DNS, test shpejtësie, MTR, kontrolle censure, Whois e shumë të tjera — e vetë-strehueshme me një komandë të vetme Docker.
👉 Demo: [https://ipcheck.ing](https://ipcheck.ing)
Mund ta ruani demon te faqeshënuesit ose të vendosni instancën tuaj.
[![Deploy with Docker](https://raw.githubusercontent.com/jason5ng32/MyIP/main/public/github/Docker.svg)](https://hub.docker.com/r/jason5ng32/myip)
</div>
## 👀 Veçoritë
### 🪪 IP-ja dhe identiteti juaj
* 🛜 **Kartat e IP-së**: Zbulojnë IPv4 dhe IPv6 tuaj nga disa burime të pavarura krah për krah — shtetin, rajonin, qytetin, ASN-në, organizatën dhe zonën kohore lokale të IP-së.
* 🔍 **Kërkimi i IP-së**: Kërkon të njëjtin informacion të detajuar për çdo adresë IP për të cilën jeni kureshtarë.
* 🧾 **Historiku i IP-ve**: Mban një regjistër lokal të IP-ve me të cilat jeni parë, të filtrueshëm sipas llojit dhe shtetit — i ruajtur vetëm në shfletuesin tuaj.
* 🖥️ **Gjurma e shfletuesit**: Llogarit gjurmën e shfletuesit tuaj në disa mënyra dhe tregon çfarë ju bën të identifikueshëm.
### 🕵️ Rrjedhjet dhe privatësia
* 🚥 **Zbulimi WebRTC**: Zbulon adresën IP që ekspozohet gjatë lidhjeve WebRTC — përfshirë nëse mbrojtja e privatësisë së shfletuesit tuaj është aktive.
* 🛑 **Testi i rrjedhjes DNS**: Tregon cilat pika fundore DNS i zgjidhin kërkesat tuaja, për të vlerësuar rrezikun e rrjedhjeve DNS kur përdorni VPN ose proxy.
* 🛡️ **Testi i bllokimit të reklamave dhe gjurmuesve**: Kontrollon çfarë bllokojnë vërtet bllokuesi i reklamave, shfletuesi, filtri DNS, VPN-ja ose router-i juaj — rrjetet e reklamave, analitikën, gjurmuesit socialë, monitorimin e gabimeve, telemetrinë e prodhuesve të pajisjeve, skriptet e gjurmimit dhe fshehjen e elementeve.
* 📋 **Lista e sigurisë**: Një listë personale kontrolli për sigurinë kibernetike me 258 pika në 12 fusha, me progresin të ruajtur në shfletuesin tuaj.
### 📡 Testet e rrjetit
* 🚦 **Kontrolli i lidhshmërisë**: Teston arritshmërinë e deri në 60 faqeve sipas zgjedhjes suaj, me rezultate të vonesës minimale në disa raunde — plus lista të kuruara për import, nga paketat për shtete te AI, rrjetet sociale, transmetimi, lojërat, zhvilluesit e të tjera. Bazuar në rezultatet, tregon nëse aksesi në internetin global është aktualisht i mundur për ju.
* 🚀 **Testi i shpejtësisë**: Mat shkarkimin, ngarkimin dhe vonesën tuaj kundrejt rrjeteve edge.
* ⏱️ **Testi global i vonesës**: Bën ping objektivit tuaj nga sonda në mbarë botën — zgjidhni shtetet nga të gjitha sondat e disponueshme të Globalping, të grupuara sipas kontinentit.
* 🚉 **Testi MTR**: Ekzekuton MTR nga sonda të shpërndara globalisht për të parë rrugën që ndjekin realisht paketat.
* 🚧 **Kontrolli i censurës**: Tregon ku është bllokuar një faqe interneti në mbarë botën — dhe me çfarë mjetesh.
* 🚏 **Testi i rregullave të proxy-t**: Verifikon që konfigurimi i rregullave të softuerit tuaj proxy funksionon ashtu siç e keni menduar.
### 🔦 Kërkimi dhe infrastruktura
* 📟 **Zgjidhësi DNS**: Zgjidh një domen përmes disa zgjidhësve njëkohësisht, të grupuar sipas shtetit — një mënyrë e thjeshtë për të vënë re rrëmbimin ose ndotjen.
* 📓 **Kërkimi Whois**: Kryen kërkime Whois për emra domenesh, adresa IP dhe numra AS.
* 🗄️ **Kërkimi MAC**: Identifikon prodhuesin dhe detajet pas një adrese fizike.
* 🧮 **Kalkulatori IP**: Llogaritje subnetesh, konvertime shënimesh dhe detaje të ndërfaqes IPv6 për çdo IP, prefiks, varg ose listë, të llogaritura lokalisht.
* 🛰️ **Informacioni ASN dhe topologjia drejt furnizuesve**: Direkt në çdo kartë IP — detajet e AS-së, shpalljet historike për prefiksin e IP-së dhe shtigjet nga ASN-ja e saj drejt shtyllës kurrizore Tier 1.
* 🛂 **Profili ASN**: Tabloja e plotë e çdo AS — regjistrimi me të dhënat e papërpunuara Whois, renditja dhe madhësia globale, trafiku dhe cilësia e lidhjes, ku përdoret hapësira e saj IPv4, çdo prefiks i shpallur me gjendjen RPKI, furnizuesit, peer-at dhe klientët e saj, si dhe pikat e shkëmbimit të internetit dhe qendrat e të dhënave ku është e pranishme.
* 📶 **Gjendja e shërbimeve**: Disponueshmëria live e shërbimeve të njohura — Claude, OpenAI, GitHub, Cloudflare e të tjera — nga faqet e tyre zyrtare të gjendjes, me incidentet e fundit.
### ✨ Platforma
* 📤 **Raporte për t'u ndarë**: Ktheni rezultatet e testeve tuaja në një raport diagnostikues — një lidhje vetëm për lexim me skadim automatik, Markdown gati për AI ose JSON.
* ⌨️ **API Curl**: Merrni IP-në tuaj nga terminali me një komandë të vetme `curl`.
* 🌍 **Toka Online**: Një panel që transmeton ngjarjet globale të ndërprerjeve të internetit në kohën kur ndodhin.
* 🌗 **Modaliteti i errët**: Ndjek automatikisht sistemin tuaj, me mundësi ndërrimi manual.
* 📲 **PWA**: Mund të instalohet si aplikacion në telefon dhe si aplikacion Chrome në desktop.
* ⚡ **Shkurtoret e tastierës**: Çdo funksion ka një të tillë — shtypni `?` për të parë listën.
* 🔤 **Shumë gjuhë**: Ndërfaqja vjen në 6 gjuhë, dhe shtimi i gjuhës suaj kërkon vetëm një paketë lokalizimi.
## 📕 Si ta përdorni
### Me Docker
Një komandë dhe jeni gati:
```bash
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
```
Ose klikoni butonin "Deploy with Docker" në krye të kësaj faqeje.
### Vendosja në një mjedis Node
Sigurohuni që keni të instaluar Node.js, pastaj klononi kodin:
```bash
git clone https://github.com/jason5ng32/MyIP.git
```
Instaloni dhe ndërtoni. Ky projekt përdor pnpm — nëse nuk e keni, instalojeni së pari (npm vjen me Node, ndaj kjo komandë funksionon gjithmonë):
```bash
npm install -g pnpm
pnpm install && pnpm run build
```
Ekzekutoni:
```bash
pnpm start
```
Programi do të ekzekutohet në portën 18966.
## ⚙️ Konfigurimi
> [!IMPORTANT]
> **Kredencialet e MaxMind GeoLite2 janë të detyrueshme.** Ato mundësojnë gjeolokacionin e IP-ve dhe kërkimet ASN — pa to, burimi MaxMind kthen 503. Janë falas: → [MaxMind Setup](https://docs.ipcheck.ing/developer/getting-started/maxmind-setup)
> [!WARNING]
> **`ALLOWED_DOMAINS` është i detyrueshëm në një domen real.** Është lista e lejuar e emrave të hosteve për API-në e backend-it — pa të, çdo kërkesë nga një domen që nuk është localhost merr 403. → [Reverse Proxy & Domains](https://docs.ipcheck.ing/developer/getting-started/reverse-proxy-and-domains)
```bash
docker run -d -p 18966:18966 \
-e MAXMIND_ACCOUNT_ID="YOUR_ACCOUNT_ID" \
-e MAXMIND_LICENSE_KEY="YOUR_LICENSE_KEY" \
-e DATASET_AUTO_UPDATE="true" \
-e ALLOWED_DOMAINS="your-domain.com" \
--name myip --restart always \
jason5ng32/myip:latest
```
Gjithçka tjetër — çelësat opsionalë API, siguria dhe kufizimi i shpejtësisë, regjistrimi, Sentry, domenet e API-së curl — është dokumentuar te [Environment Variables reference](https://docs.ipcheck.ing/developer/reference/environment-variables).
## 📖 Dokumentacioni
Udhëzuesit e plotë ndodhen në Qendrën e Dokumentacionit të MyIP: **[docs.ipcheck.ing](https://docs.ipcheck.ing)**
* [Developer Guide](https://docs.ipcheck.ing/developer) — vendosja, konfigurimi, arkitektura dhe kontributi
* [Knowledge Base](https://docs.ipcheck.ing/knowledge-base) — si të përdorni çdo mjet, diagnostikimi i rrjetit hap pas hapi dhe konceptet e rrjetit
## 🤝 Kontributi
Kontributet janë të mirëpritura! Mbajmë një grup të kuruar detyrash të përshtatshme për fillestarët — secila me shtigje të sakta skedarësh, kritere pranimi dhe teste që ju udhëheqin drejt një ndërtimi të suksesshëm:
* 🏷️ [Good first issues](https://github.com/jason5ng32/MyIP/labels/good%20first%20issue) — shtoni një zgjidhës DNS nga shteti juaj, shtoni lista të kuruara faqesh, përktheni README-në në gjuhën tuaj, përmirësoni përkthimet e të tjera
* 🌐 [TRANSLATING.md](TRANSLATING.md) — sillni ndërfaqen në gjuhën tuaj: një paketë lokalizimi plus një rresht në regjistër, dhe **një përkthim i pjesshëm është një PR i parë i mirëpritur**
* 📄 [CONTRIBUTING.md](CONTRIBUTING.md) — konfigurimi, konventat dhe si rrjedhin PR-të (synoni degën `dev`)
## 👩🏻‍💻 Përdorimi i avancuar
<details>
<summary>Rregulla proxy për të kontrolluar njëkohësisht IP-në tuaj të vërtetë dhe IP-në e proxy-t</summary>
Nëse përdorni një proxy për t'u lidhur me internetin, mendoni të shtoni këtë rregull në konfigurimin e proxy-t tuaj (përshtateni sipas klientit tuaj). Ky konfigurim ju lejon të kontrolloni si IP-në tuaj të vërtetë, ashtu edhe IP-në kur përdorni proxy-n:
```ini
# IP Testing
IP-CIDR,1.0.0.2/32,Proxy,no-resolve
IP-CIDR6,2606:4700:4700::1111/128,Proxy,no-resolve
DOMAIN,4.ipcheck.ing,DIRECT
DOMAIN,6.ipcheck.ing,DIRECT
# Rule Testing
DOMAIN,ptest-1.ipcheck.ing,Proxy1
DOMAIN,ptest-2.ipcheck.ing,Proxy2
DOMAIN,ptest-3.ipcheck.ing,Proxy3
DOMAIN,ptest-4.ipcheck.ing,Proxy4
DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
## 💖 Sponsorët dhe mbështetësit
Si projekt me burim të hapur, jam shumë mirënjohës për mbështetjen e sponsorëve të mëposhtëm:
**Sponsorët**
<a href="https://www.rapidproxy.io/?ref=myip"><img src="https://res.ipcheck.ing/img/rapid_proxy_logo.png" alt="RapidProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.rapidproxy.io/?ref=myip">RapidProxy</a>** — Residential proxies with 90M+ IPs, smart rotation and precise geo-targeting. From $0.55/GB, 10% off with code `RAPID10`</sub>
<a href="https://www.swiftproxy.net/?ref=myip"><img src="https://res.ipcheck.ing/img/swiftproxy_logo.png" alt="SwiftProxy" height="48" /></a><br />
<sub>↳ **<a href="https://www.swiftproxy.net/?ref=myip">SwiftProxy</a>** — High-quality rotating and static residential proxies, with 90M+ residential IPs across 220+ locations worldwide. It supports HTTP(S), SOCKS5, rotating and sticky sessions, with residential proxy plans starting at just $0.7/GB. Use code `PROXY90` for an additional 10% off.
</sub>
<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4"><img src="https://res.ipcheck.ing/img/proxyseller_logo.png" alt="Proxy-Seller" height="48" /></a><br />
<sub>↳ **<a href="https://proxy-seller.com/?partner=4FEC1644D12240F4">Proxy-Seller</a>** — offers private residential, mobile (4G/5G/LTE), ISP, and datacenter IPv4/IPv6 proxies with HTTP(S) and SOCKS5 support, 47M+ residential IPs in 220+ locations, precise country/city/ISP targeting, 99.7% uptime, and 24/7 support. MyIP users can use promo code `CHECKINGIP15` to get 15% off.
</sub>
<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=sq"><img src="https://res.ipcheck.ing/img/vps_logo.png" alt="V.PS" height="48" /></a><br />
<sub>↳ **<a href="https://v.ps/?utm_source=ipcheck.ing&utm_medium=referral&utm_campaign=github_readme&utm_content=sq">V.PS</a>** — High-performance KVM VPS with global locations, fast connectivity, and IPv4 & IPv6 included.</sub>
----
**Mbështetësit**
<p><a href="https://www.cloudflare.com/lp/project-alexandria/"><img src="https://res.ipcheck.ing/img/cloudflare_logo.png" alt="Cloudflare Project Alexandria" height="32" /></a></p>
<p><a href="https://www.1password.com"><img src="https://res.ipcheck.ing/img/1password_logo.png" alt="1Password" height="32" /></a></p>
<p><a href="https://www.greptile.com/"><img src="https://res.ipcheck.ing/img/greptile_logo.png" alt="Greptile" height="32" /></a></p>
<p><a href="https://www.sentry.io"><img src="https://res.ipcheck.ing/img/sentry_logo.png" alt="Sentry" height="32" /></a></p>
<p><a href="https://www.gitbook.com"><img src="https://res.ipcheck.ing/img/gitbook_logo.png" alt="GitBook" height="32" /></a></p>
Jeni të interesuar të sponsorizoni MyIP? [Bëhu sponsor](https://github.com/sponsors/jason5ng32)
## 📄 Licenca
[MIT](LICENSE) © Jason Ng
+6 -1
View File
@@ -22,7 +22,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
一個開源的多合一 IP 工具箱:多來源 IP 查詢、連通性測試、WebRTC 與 DNS 洩漏偵測、網速測試、MTR、審查封鎖檢查、Whois 等——一條 Docker 指令即可自行架設。
@@ -47,6 +47,7 @@
* 🚥 **WebRTC 洩漏測試**:揭示 WebRTC 連線過程中暴露的 IP 位址——包括你的瀏覽器隱私強化設定是否已開啟。
* 🛑 **DNS 洩漏測試**:顯示是哪些 DNS 端點在解析你的查詢,以評估使用 VPN 或代理時 DNS 洩漏的風險。
* 🛡️ **廣告與追蹤封鎖檢測**:檢查你的廣告阻擋器、瀏覽器、DNS 過濾、VPN 或路由器實際擋下了什麼——涵蓋廣告網路、統計分析、社群平台追蹤、錯誤監控、裝置廠商遙測、追蹤腳本與元素隱藏。
* 📋 **安全檢查清單**:一份涵蓋 12 個領域、共 258 個項目的個人網路安全檢查清單,進度儲存在你的瀏覽器裡。
### 📡 網路測試
@@ -170,6 +171,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+6 -1
View File
@@ -22,7 +22,7 @@
![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main)
[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md)
[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) | [Shqip](README_SQ.md)
一个开源的一站式 IP 工具箱:多来源 IP 查询、连通性测试、WebRTC 与 DNS 泄露检测、网速测试、MTR、封锁检测、Whois 等等 —— 一条 Docker 命令即可自行部署。
@@ -47,6 +47,7 @@
* 🚥 **WebRTC 检测**:揭示 WebRTC 连接过程中暴露的 IP 地址 —— 包括你的浏览器是否开启了隐私强化。
* 🛑 **DNS 泄露测试**:展示是哪些 DNS 出口在解析你的请求,以便评估使用 VPN 或代理时 DNS 泄露的风险。
* 🛡️ **广告与跟踪屏蔽检测**:检查你的广告拦截器、浏览器、DNS 过滤、VPN 或路由器实际拦住了什么——涵盖广告网络、统计分析、社交平台跟踪、错误监控、设备厂商遥测、跟踪脚本与元素隐藏。
* 📋 **安全检查清单**:一份覆盖 12 个领域、共 258 项的个人网络安全检查清单,进度保存在你的浏览器里。
### 📡 网络测试
@@ -170,6 +171,10 @@ DOMAIN,ptest-5.ipcheck.ing,Proxy5
DOMAIN,ptest-6.ipcheck.ing,Proxy6
DOMAIN,ptest-7.ipcheck.ing,Proxy7
DOMAIN,ptest-8.ipcheck.ing,Proxy8
DOMAIN,ptest-9.ipcheck.ing,Proxy9
DOMAIN,ptest-10.ipcheck.ing,Proxy10
DOMAIN,ptest-11.ipcheck.ing,Proxy11
DOMAIN,ptest-12.ipcheck.ing,Proxy12
```
</details>
+2 -2
View File
@@ -18,8 +18,8 @@ Helpful things to include:
- What an attacker can do with it, and what they'd need to start (a session? just a URL?).
- Steps to reproduce — a request, a payload, or a short script.
- Where the problem lives: front-end (`frontend/`), API handler (`api/`), or shared
code (`common/`).
- Where the problem lives: front-end (`frontend/`), API handler (`api/`), backend
code (`server/`), or shared code (`common/`).
- Whether you hit it on [ipcheck.ing](https://ipcheck.ing) or your own deployment, and
the version if self-hosted.
+19 -6
View File
@@ -1,9 +1,9 @@
# Translating MyIP
MyIP (demo website: [IPCheck.ing](https://ipcheck.ing)) ships its UI in English, 简体中文, 繁體中文,
Français and Русский. Adding a sixth — or a seventh — is a two-file change, and **a partial
translation is a welcome first PR**. You do not need to translate everything, and you do not
need to know Vue.
Français, Русский and Português (Brasil). Adding a language is a two-file change, and **a
partial translation is a welcome first PR**. You do not need to translate everything, and you
do not need to know Vue.
Anything you leave untranslated falls back to English, so a pack with a hundred strings in
it is genuinely useful on day one and can grow over several PRs. Untranslated strings stay
@@ -13,6 +13,19 @@ in your pack as `""`, which is what keeps a translation PR readable: every diff
General contribution rules (fork, branch from `dev`, `pnpm check` before you open the PR)
live in [CONTRIBUTING.md](CONTRIBUTING.md).
## Which languages we take
**New languages are added by visitor demand, not on request.** Every pack has to follow
every UI change — the English pack grew by roughly 45% over two months in 2026, and each
shipped language followed in the same commits — and each language is one more variant of
the docs site to look after. So a language opens only once it has a meaningful share of
IPCheck.ing's visitors. The open **Translations wanted** issue lists the languages
currently up for claiming; a pack for a language that isn't on it won't be merged, however
good it is. Right-to-left languages also need layout work the app doesn't have yet.
If your language isn't on the list, a README translation is welcome in any language — that
is a single file and costs nothing to keep (see CONTRIBUTING.md).
---
## The smallest useful PR
@@ -41,7 +54,7 @@ can see at a glance which strings you actually wrote.
| File | What it is | Required? |
|---|---|---|
| `frontend/locales/<code>.json` | The main pack — every string in the app UI (~1,150 keys) | **Yes** — all keys, values may be `""` |
| `frontend/locales/<code>.json` | The main pack — every string in the app UI (~1,650 keys) | **Yes** — all keys, values may be `""` |
| `frontend/locales/privacy/<code>.json` | Privacy policy copy (~50 keys) | No — but whole or not at all |
| `frontend/locales/security-checklist/<code>.json` | The Cybersecurity Checklist dataset (~1,080 keys, 258 items) | No — but whole or not at all |
| `frontend/data/changelog.json` | Release history, one string per language per entry | No — beta languages are exempt |
@@ -184,8 +197,8 @@ them would fail the gate on your behalf. Translate the new keys, or drop the fil
## Improving an existing language
Fixes and better phrasings for `en` / `zh` / `zh-TW` / `fr` / `ru` are just as welcome as new
languages — edit the JSON and open the PR. Two things to keep in mind:
Fixes and better phrasings for `en` / `zh` / `zh-TW` / `fr` / `ru` / `pt-BR` are just as
welcome as new languages — edit the JSON and open the PR. Two things to keep in mind:
- Prefer natural phrasing over literal translation; this is a networking tool, and the
jargon most users know is often the English term.
+45 -25
View File
@@ -1,29 +1,36 @@
# api/AGENTS.md
Conventions for Express 5 handlers under `api/` and shared back-end code
under `common/`. Universal rules live in ../AGENTS.md.
Conventions for Express 5 handlers under `api/` and the back-end code under
`server/` and `common/`. Universal rules live in ../AGENTS.md.
## Overview
The Express app lives in `backend-server.js` at the repo root — every route
is wired there and delegated to one handler module under `api/`. `common/`
holds shared back-end code (guards, logger, fetch helper, MaxMind / CAIDA
services, service-status poller), parts of which the frontend also imports
(`valid-ip.js`, `fetch-with-timeout.js`).
is wired there and delegated to one handler module under `api/`. Code the
handlers share is split by who imports it:
- **`server/`** — backend-only (guards, logger, upstream clients, service-status
poller, …), flat; `server/datasets/` is the offline dataset engine and its
readers (see "Offline datasets").
- **`common/`** — only what the frontend or the build imports too
(`valid-ip.js`, `fetch-with-timeout.js`, `report-schema.js`, …); it never
imports from `server/`. It also holds the dataset directories (`*-db/`),
which self-hosting docs and Docker volumes mount — they stay put.
Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` /
`ipapi-com` / `ipapi-is` / `ip2location-io` / `ip-sb` / `ipcheck-ing` /
`maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` /
`cf-radar` / `asn-history` / `asn-connectivity` / `asn-profile` /
`ooni-blocking` / `globalping-probes` / `service-status` / `google-map` /
`github-stars` / `invisibility-test` / `dns-leak-test` / `persona`), user
`github-stars` / `invisibility-test` / `dns-leak-test` / `persona` /
`ip-blocklist`), 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.
The exception to one-file-per-route is Cloudflare Radar: all Radar data rides
`/api/cfradar`, dispatched by `?view=` over `RADAR_VIEWS` in
`common/cf-radar.js`, where each view declares its guards, edge-cache TTL and
`server/cf-radar.js`, where each view declares its guards, edge-cache TTL and
fetch function — new Radar data is a view function plus a registry row, never
a new route. `api/data/` holds contributor-editable static config consumed by
handlers — currently `dns-resolvers.js`, the country-annotated resolver list
@@ -31,20 +38,20 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
### ASN data
- **`/api/asn-profile`** (`common/asn-profile.js`) runs each ASN source's core
- **`/api/asn-profile`** (`server/asn-profile.js`) runs each ASN source's core
under a deadline above the upstream timeouts it wraps (`INNER_TIMEOUTS`); a
failed or late one is just absent. Always 200 `{ asn, status, incomplete,
…sections }` (`ok` / `empty` / `error` / `disabled`; an upstream fault is
`error`, never `empty`; `incomplete` = `ok` but partial); 502 if all fail.
- **Relationship counts follow the graph:** `asn-connectivity` neighbour
lists come from local CAIDA (`common/as-relationships.js`), dropping the
lists come from local CAIDA (`server/as-relationships.js`), dropping the
providers the graph walk distrusts. The Radar `asn` view (ASN Info) carries
no prefix or relationship counts — the ASN Profile has both.
- **`/api/whois` `?q=`:** IPs (RDAP, whoiser fallback), domains (whoiser, RDAP
fallback), `AS<n>` (RDAP autnum); all carry a `__raw` block.
- **Offline datasets** (MaxMind, CAIDA, PeeringDB, IEEE MAC registries) are rows in
`common/datasets.js`, run by one engine,
`common/dataset-updater.js` (lock, atomic state, publish, boot download,
`server/datasets/datasets.js`, run by one engine,
`server/datasets/dataset-updater.js` (lock, atomic state, publish, boot download,
daily schedule). A row holds only its own logic — find the newest remote
version, fetch / distill it, validate, reload; a new dataset is a new row,
never a new updater. Fetched at boot when its reader has nothing usable
@@ -56,9 +63,9 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together);
PeeringDB only with a Cloudflare key; its 116 MB dump is
stream-distilled (never parsed whole) to a ~6 MB per-ASN index without
contact data (`common/peeringdb-distill.js`). The IEEE row (MAC Lookup)
contact data (`server/datasets/peeringdb-distill.js`). The IEEE row (MAC Lookup)
needs no key: five registry CSVs published as downloaded, answered by the
longest covering assignment (`common/oui-db.js`).
longest covering assignment (`server/datasets/oui-db.js`).
## Conventions
@@ -67,7 +74,7 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
- **Every upstream call uses `fetchUpstream`** (`common/fetch-with-timeout.js`,
8s timeout), never a bare `fetch()` / `https.get()` — a hanging provider must
time out. It injects a default `User-Agent` `MyIP/v<version>/<VITE_SITE_URL>`
(`common/upstream-ua.js`; some WAFs block undici's `node` UA); caller-supplied
(`server/upstream-ua.js`; some WAFs block undici's `node` UA); caller-supplied
ones, incl. the private-API `{ ...req.headers }` pass-through, win.
- **Error shape.** `res.status(500).json({ error: error.message })` on upstream
failure, `400` on bad input. Terse — the frontend doesn't show them verbatim.
@@ -76,8 +83,10 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
sources match it. `timezone` is the exception (see "Response enrichment").
- **`?lang` is never validated in a handler.** Pass the raw tag through:
`lookupMaxMind` normalizes it onto the languages its data carries
(`SUPPORTED_LANGS` in `common/maxmind-service.js`); the private-API proxies
forward it for the upstream's own resolution. No allow-lists.
(`SUPPORTED_LANGS` in `server/datasets/maxmind-service.js`); the private-API proxies
forward it for the upstream's own resolution. No allow-lists; `/api/map`'s
`language` only gets a tag-shape check, as each value is a year-cached
Static Maps call.
- **Logging.** Shared logger only, `logger.error({ err, ...ctx }, 'msg')`; no
`console.*`, no "received request" lines (`pino-http` covers those).
- **Error monitoring (Sentry) is env-gated and invisible to handlers.**
@@ -87,14 +96,14 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
loaded; `SENTRY_ENVIRONMENT=development` skips the init. Handlers never import
Sentry: throws and 5xx traces are automatic; caught failures stay on the
logger (its hook mirrors warn+ to Sentry Logs, error+ to Issues). Periodic
jobs wrap their tick in `common/sentry-cron.js`; `key` / `token` query params
are redacted (`common/sentry-scrub.js`).
jobs wrap their tick in `server/sentry-cron.js`; `key` / `token` query params
are redacted (`server/sentry-scrub.js`).
## Security & Boundaries
### Guards live in middleware, not handlers
`common/guards.js`, attached in `backend-server.js`; handlers never repeat them:
`server/guards.js`, attached in `backend-server.js`; handlers never repeat them:
- `requireReferer` — global on `/api/*` (ALLOWED_DOMAINS + localhost).
- `requirePublicIP()` — per-route `?ip=`: well-formed and publicly routable;
@@ -120,7 +129,7 @@ New param shape → new guard there, attached in `backend-server.js`.
### Response enrichment lives in middleware too
`withTimeZone()` (`common/ip-timezone.js`), on all seven geo routes, derives
`withTimeZone()` (`server/ip-timezone.js`), on all seven geo routes, derives
`timezone` (IANA name) from the `latitude` / `longitude` the handler returned
(2xx only, the `cacheable` hook); no handler computes or forwards one. Own
coordinates keep the zone consistent with the city beside it; only the name
@@ -132,7 +141,7 @@ DST switch. A new geo source adds the middleware.
The backend listens before any missing offline dataset or the service-status
snapshot is fetched; snapshots already on disk load first, so only a first
boot waits on downloads. During that window `requireOfflineData([...probes])`
(`common/offline-data.js`) answers 503 on a route whose data is still missing
(`server/offline-data.js`) answers 503 on a route whose data is still missing
— never edge-cached — and is a no-op after boot, when a missing dataset means
a failed download served degraded. A route reading local data declares its
probes (`isMaxMindReady`, `isAsRelLoaded`, …) in `backend-server.js`. Answers
@@ -143,17 +152,28 @@ loading (`isStillLoading`) answers `error`, leaving the others to answer.
Handlers proxying our private IPCheck.ing API (`ipcheck-ing`,
`invisibility-test`, `update-user-achievement`, `get-user-info`,
`dns-leak-test`, `persona`, and `asn-profile`'s reputation via
`common/asn-reputation.js`) forward the caller's headers upstream (`headers: {
`dns-leak-test`, `persona`, `ip-blocklist`, and `asn-profile`'s reputation via
`server/asn-reputation.js`) forward the caller's headers upstream (`headers: {
...req.headers }`) — the upstream needs caller context (Accept-Language, auth
tokens). `persona` strips the framing headers (`host` / `content-length` / …)
first, as it re-serializes the body. Do **not** replicate for third-party
upstreams; those get only what's explicitly needed.
### Firebase Auth handler proxy
`frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy
`/__/auth/*` to `<projectId>.firebaseapp.com` (`common/firebase-auth-proxy.js`)
when `VITE_FIREBASE_PROJECT_ID` is set, so `VITE_FIREBASE_AUTH_DOMAIN` can be
the deployment's own host and the sign-in handler runs first-party — inside
the installed PWA's webview and Safari's storage partition, where
`getRedirectResult` finds the result. Every OAuth provider must accept
`https://<host>/__/auth/handler` per environment. Not an `/api` route: no
guards, caching or per-request logs.
## Edge caching
Every `/api/*` response defaults to `Cache-Control: no-store`; slowly-changing
public routes opt in via `cacheable(maxAge)` (`common/cache-control.js`) in
public routes opt in via `cacheable(maxAge)` (`server/cache-control.js`) in
`backend-server.js`, e.g. `cacheable(24 * 60 * 60)` (TTLs written that way, not
raw seconds). `maxAge` may be a `(req) => seconds` resolver (cfradar: its view's
TTL; falsy = no-store). `{ cacheIf }` vetoes the full TTL for a 2xx body,
+9 -9
View File
@@ -8,21 +8,21 @@
// backbone connectivity is settlement-free peering with the Tier 1 clique,
// invisible to a p2c-only walk.
//
// Data is fully local (common/as-rel-db.js + common/as-org-db.js), so the
// Data is fully local (server/datasets/as-rel-db.js + server/datasets/as-org-db.js), so the
// whole BFS is synchronous. We only hit RIPEstat for as-overview as a
// rare fallback when as2org doesn't have an ASN's org name.
//
// Alongside the graph, `neighbours` lists the origin's direct providers,
// peers and customers from the same snapshot, counted by the walk's own
// misinference rule (common/as-relationships.js) so the counts never show an
// misinference rule (server/as-relationships.js) so the counts never show an
// upstream the graph drops. List names come from as2org, gaps filled from the
// graph's resolved names.
import { resolveAsnOrgName } from '../common/ripestat.js';
import { providersOf, peersOf, customersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js';
import { lookupAsOrgName } from '../common/as-org-db.js';
import { TIER1_PEERING_TRUSTED, TIER1_ADJACENCY_TRUSTED, countedRelationships } from '../common/as-relationships.js';
import logger from '../common/logger.js';
import { resolveAsnOrgName } from '../server/ripestat.js';
import { providersOf, peersOf, customersOf, customerCountOf, isTier1 } from '../server/datasets/as-rel-db.js';
import { lookupAsOrgName } from '../server/datasets/as-org-db.js';
import { TIER1_PEERING_TRUSTED, TIER1_ADJACENCY_TRUSTED, countedRelationships } from '../server/as-relationships.js';
import logger from '../server/logger.js';
// How deep to recurse from the origin. 3 covers regional networks reaching
// Tier 1s through 1-2 intermediates; deeper just adds noise at the periphery.
@@ -33,10 +33,10 @@ const MAX_DEPTH = 3;
const MAX_INTERMEDIATE_BRANCH = 3;
// Past either bar (TIER1_PEERING_TRUSTED / TIER1_ADJACENCY_TRUSTED, defined
// in common/as-relationships.js so the neighbour counts use the same ones), a
// in server/as-relationships.js so the neighbour counts use the same ones), a
// node's non-Tier-1 "providers" are treated as CAIDA misinference and dropped.
// Two-tier org name resolver lives in common/ripestat.js. No onError hook
// Two-tier org name resolver lives in server/ripestat.js. No onError hook
// here — connectivity stays silent on as-overview fallback failures (a node
// just keeps name=null); asn-history is the one that warns.
const resolveOrgName = (asn) => resolveAsnOrgName(asn);
+3 -3
View File
@@ -13,8 +13,8 @@
import {
fetchRoutingHistory,
resolveAsnOrgName,
} from '../common/ripestat.js';
import logger from '../common/logger.js';
} from '../server/ripestat.js';
import logger from '../server/logger.js';
const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10);
@@ -56,7 +56,7 @@ function summarizeOrigin(entry, minLen) {
};
}
// Two-tier resolver lives in common/ripestat.js. Here we pass a warn hook so
// Two-tier resolver lives in server/ripestat.js. Here we pass a warn hook so
// a failed as-overview fallback stays observable (asn-connectivity omits it
// and stays silent — keep that difference).
const resolveOrgName = (asn) =>
+14 -14
View File
@@ -1,23 +1,23 @@
// /api/asn-profile?asn=<n> — everything the ASN Profile page shows, in one
// answer: Radar ASN summary + prefix list (common/cf-radar.js), CAIDA
// answer: Radar ASN summary + prefix list (server/cf-radar.js), CAIDA
// topology and neighbours (./asn-connectivity.js), RDAP autnum
// (common/rdap.js), ASRank (common/asrank.js), the local PeeringDB index
// (common/peeringdb-db.js) and, when the private API is configured,
// reputation (common/asn-reputation.js). Composition, deadlines and statuses
// live in common/asn-profile.js. `asn` arrives as a canonical number string
// (server/rdap.js), ASRank (server/asrank.js), the local PeeringDB index
// (server/datasets/peeringdb-db.js) and, when the private API is configured,
// reputation (server/asn-reputation.js). Composition, deadlines and statuses
// live in server/asn-profile.js. `asn` arrives as a canonical number string
// (requireValidASN). 200 unless every configured source failed (502).
import { RADAR_VIEWS, hasRadarApiKey, loadAsnSummary, isCompleteRadarAnswer } from '../common/cf-radar.js';
import { rdapAutnum, isAutnumMissing } from '../common/rdap.js';
import { queryAsRank } from '../common/asrank.js';
import { requestAsnReputation } from '../common/asn-reputation.js';
import { isPeeringdbLoaded, lookupPeeringdb } from '../common/peeringdb-db.js';
import { isAsRelLoaded } from '../common/as-rel-db.js';
import { isAsOrgLoaded } from '../common/as-org-db.js';
import { isStillLoading } from '../common/offline-data.js';
import { RADAR_VIEWS, hasRadarApiKey, loadAsnSummary, isCompleteRadarAnswer } from '../server/cf-radar.js';
import { rdapAutnum, isAutnumMissing } from '../server/rdap.js';
import { queryAsRank } from '../server/asrank.js';
import { requestAsnReputation } from '../server/asn-reputation.js';
import { isPeeringdbLoaded, lookupPeeringdb } from '../server/datasets/peeringdb-db.js';
import { isAsRelLoaded } from '../server/datasets/as-rel-db.js';
import { isAsOrgLoaded } from '../server/datasets/as-org-db.js';
import { isStillLoading } from '../server/offline-data.js';
import {
SOURCE_TIMEOUTS, buildSectionLoaders, composeAsnProfile, allSourcesFailed,
} from '../common/asn-profile.js';
} from '../server/asn-profile.js';
import { getAsnConnectivity } from './asn-connectivity.js';
// A section whose local data isn't there fails (status 'error'): left out
+3 -3
View File
@@ -1,12 +1,12 @@
// /api/cfradar — single dispatch route for all Cloudflare Radar data.
// `?view=` selects an entry in RADAR_VIEWS (common/cf-radar.js): the view's
// `?view=` selects an entry in RADAR_VIEWS (server/cf-radar.js): the view's
// guards validate and normalize its params, its fetch builds the payload,
// and the route's cache middleware in backend-server.js reads the view's
// per-view TTL. Guards run before the API-key check so param errors stay
// 400s on keyless deployments (and tests never reach an upstream call).
import { RADAR_VIEWS, hasRadarApiKey } from '../common/cf-radar.js';
import logger from '../common/logger.js';
import { RADAR_VIEWS, hasRadarApiKey } from '../server/cf-radar.js';
import logger from '../server/logger.js';
// Run a view's guard middlewares outside an Express chain. Each guard either
// calls next() or writes its own 4xx response — so "didn't pass" means the
+1 -1
View File
@@ -9,7 +9,7 @@
// GET /api/dnsleaktest/session/:token
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
const TOKEN_RE = /^[0-9a-f]{32}$/;
+1 -1
View File
@@ -3,7 +3,7 @@
// one flat, country-annotated result list the frontend groups by country.
import { Resolver } from 'node:dns/promises';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
import { DNS_RESOLVERS } from './data/dns-resolvers.js';
import { NAME_VALUED_TYPES } from '../common/dns-record-types.js';
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
const key = process.env.IPCHECKING_API_KEY;
+2 -2
View File
@@ -4,8 +4,8 @@
import whoiser from 'whoiser';
import { isValidIP, isUsablePublicIP } from '../common/valid-ip.js';
import { rdapDomain, rdapIp, rdapAutnum, isAutnumMissing } from '../common/rdap.js';
import logger from '../common/logger.js';
import { rdapDomain, rdapIp, rdapAutnum, isAutnumMissing } from '../server/rdap.js';
import logger from '../server/logger.js';
function isValidDomain(domain) {
const domainPattern = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
+1 -1
View File
@@ -4,7 +4,7 @@
// 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';
import logger from '../server/logger.js';
// The project's own repository. GitHub requires a User-Agent on every
// request; fetchUpstream's default project UA satisfies that.
+2 -2
View File
@@ -5,8 +5,8 @@
// bytes, probe-country coverage changes slowly.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { buildProbeInventory } from '../common/globalping-inventory.js';
import logger from '../common/logger.js';
import { buildProbeInventory } from '../server/globalping-inventory.js';
import logger from '../server/logger.js';
const GLOBALPING_PROBES_URL = 'https://api.globalping.io/v1/probes';
+9 -4
View File
@@ -1,13 +1,18 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// Validate request legitimacy. `language` is the UI locale code, region
// subtag included (`pt-BR`, `zh-TW`); like every `?lang` it is not
// allow-listed, but it must be tag-shaped: every distinct value is an
// uncached Static Maps call whose answer is edge-cached for a year.
const LANGUAGE_TAG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,4}){0,2}$/;
// Validate request legitimacy
function isValidRequest(req) {
const isLatitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.latitude);
const isLongitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.longitude);
const isLanguageValid = /^[a-z]{2}$/.test(req.query.language);
const isLanguageValid = typeof req.query.language === 'string' && LANGUAGE_TAG.test(req.query.language);
if (!isLatitudeValid || !isLongitudeValid || !isLanguageValid) {
return false;
@@ -69,7 +74,7 @@ export default async (req, res) => {
styleParam = styles.Dark.join('&style=');
}
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&markers=color:blue%7C${latitude},${longitude}&scale=${scale}&zoom=${zoom}&maptype=roadmap&language=${language}&format=${fmt}&size=${mapSize}&style=${styleParam}&key=${apiKey}`;
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&markers=color:blue%7C${latitude},${longitude}&scale=${scale}&zoom=${zoom}&maptype=roadmap&language=${encodeURIComponent(language)}&format=${fmt}&size=${mapSize}&style=${styleParam}&key=${apiKey}`;
// Unlike the JSON handlers, the Static Maps response is a binary JPEG
// and must be passed through to the client. pipeline() propagates errors
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// If length is not 28 and is not a combination of letters and numbers, return false
function isValidUserID(userID) {
+53
View File
@@ -0,0 +1,53 @@
// GET /api/ipblocklist?ip= — which blocklists an IP is on, from the private
// IPCheck.ing API (`/iphitlist`: live DNSBL lookups plus static threat lists).
// `?ip` is validated by requirePublicIP(). Sign-in is enforced upstream, so
// the caller's headers (auth token) are forwarded as-is.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../server/logger.js';
// A cold DNSBL pass queries dozens of zones under per-zone deadlines; well
// past the default 8s, and the frontend waits a little longer than this.
export const IP_BLOCKLIST_TIMEOUT_MS = 30000;
export default async (req, res) => {
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' });
}
const url = new URL(`${apiEndpoint}/iphitlist`);
url.searchParams.set('ip', req.query.ip);
url.searchParams.set('key', apiKey);
try {
const apiResponse = await fetchUpstream(url, {
headers: { ...req.headers },
timeoutMs: IP_BLOCKLIST_TIMEOUT_MS,
});
// Monthly quota exhausted: not a failure — pass status + code through
// so the frontend shows the quota hint.
if (apiResponse.status === 429) {
const errorData = await apiResponse.json().catch(() => ({}));
return res.status(429).json({ error: errorData.error || 'Monthly quota exceeded', code: 'quota_exceeded' });
}
// Sign-in problems are the visitor's state, not a failure: pass the
// status through so the frontend prompts sign-in.
if (apiResponse.status === 401 || apiResponse.status === 403) {
const errorData = await apiResponse.json().catch(() => ({}));
return res.status(apiResponse.status).json({ error: errorData.message || 'Sign in required' });
}
if (!apiResponse.ok) {
throw new Error(`API responded with status: ${apiResponse.status}`);
}
res.json(await apiResponse.json());
} catch (error) {
logger.error({ err: error, ip: req.query.ip }, 'ip-blocklist upstream request failed');
res.status(500).json({ error: error.message });
}
};
+1 -1
View File
@@ -2,7 +2,7 @@
// Token-free upstream; normalizes the response into the canonical geo
// shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Picks a random API key and normalizes the response into the canonical
// geo shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Forwards the optional ?lang and normalizes the response into the
// canonical geo shape via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -2,7 +2,7 @@
// Picks a random API key and normalizes the response into the canonical
// geo shape (plus isHosting / isProxy) via the shared makeGeoHandler factory.
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
// Presence, validity and public routability guaranteed by the
+1 -1
View File
@@ -3,7 +3,7 @@
// into the canonical geo shape via the shared makeGeoHandler factory.
import getCountryName from '../common/country-name.js';
import { makeGeoHandler } from '../common/geo-handler.js';
import { makeGeoHandler } from '../server/geo-handler.js';
function buildUrl(req) {
const ipAddress = req.query.ip;
+2 -2
View File
@@ -1,11 +1,11 @@
// /api/macchecker?mac= — MAC Lookup against the local IEEE registries
// (common/oui-db.js). `mac` is a full address or a vendor prefix of 6–12
// (server/datasets/oui-db.js). `mac` is a full address or a vendor prefix of 6–12
// hex digits (common/mac-input.js). The answer names the block that covers
// it, its range and the first-octet flags; an address no block covers is
// still a 200 with `found: false`.
import { normalizeMacQuery } from '../common/mac-input.js';
import { lookupMac } from '../common/oui-db.js';
import { lookupMac } from '../server/datasets/oui-db.js';
// `lookup` is injectable so tests can run the handler over fixture registries.
export const createMacChecker = (lookup) => async (req, res) => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { lookupMaxMind } from '../common/maxmind-service.js';
import logger from '../common/logger.js';
import { lookupMaxMind } from '../server/datasets/maxmind-service.js';
import logger from '../server/logger.js';
export default (req, res) => {
// Presence, validity and public routability guaranteed by the
+3 -3
View File
@@ -1,7 +1,7 @@
// /api/ooni-blocking — per-country blocking view for a domain over the last
// 30 days, built from OONI's open aggregation API (web_connectivity test,
// probe_cc × blocking_type axes). Powers the default view of the frontend
// CensorshipCheck tool; classification lives in common/ooni-blocking.js.
// CensorshipCheck tool; classification lives in server/ooni-blocking.js.
//
// OONI matches `domain` against the exact tested hostname, so a single query
// misses most of the dataset (e.g. `bbc.com` has ~6 rows while `www.bbc.com`
@@ -13,8 +13,8 @@
// `cacheable()` and CF absorbs repeat lookups of popular domains.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { classifyOoniCountries, OONI_WINDOW_DAYS } from '../common/ooni-blocking.js';
import logger from '../common/logger.js';
import { classifyOoniCountries, OONI_WINDOW_DAYS } from '../server/ooni-blocking.js';
import logger from '../server/logger.js';
const OONI_AGGREGATION_URL = 'https://api.ooni.io/api/v1/aggregation';
+1 -1
View File
@@ -7,7 +7,7 @@
// 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';
import logger from '../server/logger.js';
// Headers that describe *this* hop rather than the caller, dropped before the
// request is rebuilt.
+1 -1
View File
@@ -7,7 +7,7 @@
// only when VITE_SENTRY_DSN_FRONTEND is set.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { isValidIP } from '../common/valid-ip.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// The envelope header is the first newline-delimited JSON line and carries
// the DSN the SDK was configured with. Returns a URL or null. Exported for
+2 -2
View File
@@ -1,7 +1,7 @@
// /api/service-status — the "Service Status" section's read endpoints.
//
// Both handlers serve slices of the in-memory snapshot maintained by
// common/service-status-store.js (a background timer refreshes every provider
// server/service-status-store.js (a background timer refreshes every provider
// on a fixed 5-minute schedule). Neither touches an upstream — request volume
// has no effect on upstream load. Splitting overview vs per-provider detail
// keeps the initial page load light: the detail (sub-services + incidents) is
@@ -13,7 +13,7 @@
// The detail handler's `id` query param is whitelisted by
// requireValidProviderId() in backend-server.js.
import { getServiceStatusOverview, getProviderDetail } from '../common/service-status-store.js';
import { getServiceStatusOverview, getProviderDetail } from '../server/service-status-store.js';
// Overview: every provider's status light, no heavy detail arrays.
export default async (req, res) => {
+1 -1
View File
@@ -17,7 +17,7 @@
import crypto from 'node:crypto';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { validateReport, REPORT_TTL_DAYS, REPORT_MAX_BYTES } from '../common/report-schema.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
// --- env / KV plumbing -------------------------------------------------------
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
import logger from '../server/logger.js';
export default async (req, res) => {
// defensive; app.put() in backend-server.js already gates method
+21 -19
View File
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url';
import { slowDown } from 'express-slow-down'
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import logger from './common/logger.js';
import logger from './server/logger.js';
import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId,
requireValidRecordType, requireValidReportId, normalizeAsnQuery } from './common/guards.js';
import { withTimeZone } from './common/ip-timezone.js';
requireValidRecordType, requireValidReportId, normalizeAsnQuery } from './server/guards.js';
import { withTimeZone } from './server/ip-timezone.js';
// Backend APIs
import mapHandler from './api/google-map.js';
@@ -23,7 +23,7 @@ import ipsbHandler from './api/ip-sb.js';
import maxmindHandler from './api/maxmind.js';
// Others
import cfRadarHandler from './api/cf-radar.js';
import { RADAR_VIEWS, isCompleteRadarAnswer } from './common/cf-radar.js';
import { RADAR_VIEWS, isCompleteRadarAnswer } from './server/cf-radar.js';
import asnHistoryHandler from './api/asn-history.js';
import asnConnectivityHandler from './api/asn-connectivity.js';
import ooniBlockingHandler from './api/ooni-blocking.js';
@@ -37,27 +37,28 @@ import getWhois from './api/get-whois.js';
import sentryTunnelHandler from './api/sentry-tunnel.js';
import createReportHandler, { getReport as getReportHandler } from './api/share-report.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import ipBlocklistHandler from './api/ip-blocklist.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
import personaEvaluateHandler from './api/persona.js';
import asnProfileHandler from './api/asn-profile.js';
import { isCompleteProfile } from './common/asn-profile.js';
import { isCompleteProfile } from './server/asn-profile.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
import updateUserAchievement from './api/update-user-achievement.js';
import { reloadMaxMindDatabases, isMaxMindReady, getMaxMindDbPaths } from './common/maxmind-service.js';
import { bootstrapDatasets, startDatasetScheduler, watchDatasets } from './common/dataset-updater.js';
import { datasets } from './common/datasets.js';
import { reloadMaxMindDatabases, isMaxMindReady, getMaxMindDbPaths } from './server/datasets/maxmind-service.js';
import { bootstrapDatasets, startDatasetScheduler, watchDatasets } from './server/datasets/dataset-updater.js';
import { datasets } from './server/datasets/datasets.js';
import {
bootstrapServiceStatus, startServiceStatusPolling, isServiceStatusPrimed,
} from './common/service-status-store.js';
import { isAsRelLoaded } from './common/as-rel-db.js';
import { isAsOrgLoaded } from './common/as-org-db.js';
import { isOuiLoaded } from './common/oui-db.js';
import { runOfflineBootstrap, requireOfflineData, isOfflineBootstrapping } from './common/offline-data.js';
import { cacheable } from './common/cache-control.js';
import { initUpstreamUserAgent } from './common/upstream-ua.js';
} from './server/service-status-store.js';
import { isAsRelLoaded } from './server/datasets/as-rel-db.js';
import { isAsOrgLoaded } from './server/datasets/as-org-db.js';
import { isOuiLoaded } from './server/datasets/oui-db.js';
import { runOfflineBootstrap, requireOfflineData, isOfflineBootstrapping } from './server/offline-data.js';
import { cacheable } from './server/cache-control.js';
import { initUpstreamUserAgent } from './server/upstream-ua.js';
dotenv.config({ quiet: true });
initUpstreamUserAgent();
@@ -216,17 +217,17 @@ if (speedLimitSet !== 0) {
app.use(express.json({ limit: '500kb' }));
// Default every /api/* response to no-store. Routes that want edge caching
// declare it explicitly via `cacheable(maxAge)` (common/cache-control.js).
// declare it explicitly via `cacheable(maxAge)` (server/cache-control.js).
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.
// check individually — see server/guards.js.
app.use('/api', requireReferer);
// Readiness gates for routes reading local data (common/offline-data.js):
// Readiness gates for routes reading local data (server/offline-data.js):
// 503 while that data is still downloading at boot, a no-op afterwards.
const needsMaxMind = requireOfflineData([isMaxMindReady]);
const needsAsGraph = requireOfflineData([isAsRelLoaded, isAsOrgLoaded]);
@@ -275,7 +276,7 @@ app.get('/api/asn-profile', requireValidASN(), cacheable(SEVEN_DAYS_CACHE, {
degradedMaxAge: () => (isOfflineBootstrapping() ? 0 : ONE_DAY_CACHE),
}), asnProfileHandler);
// All Cloudflare Radar data rides one route; `?view=` picks the dataset and
// the TTL comes from that view's registry entry (common/cf-radar.js) — 7d
// the TTL comes from that view's registry entry (server/cf-radar.js) — 7d
// for the ASN summary and prefix list (same as /api/asn-profile), 30d for
// country traffic, 1h for the outage feed. A partial answer (some upstream
// calls failed, or prefix countries MaxMind couldn't fully work out) is
@@ -294,6 +295,7 @@ app.get('/api/ipchecking', requirePublicIP(), withTimeZone(), ipCheckingHandler)
app.get('/api/dnsresolver', requireValidDomain('hostname'), requireValidRecordType(), dnsResolver);
app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult);
app.get('/api/invisibility', invisibilitytestHandler);
app.get('/api/ipblocklist', requirePublicIP(), ipBlocklistHandler);
app.get('/api/getuserinfo', getUserinfo);
app.put('/api/updateuserachievement', updateUserAchievement);
// Shared reports stay no-store: an edge cache could serve a report past its
+3 -3
View File
@@ -1,7 +1,7 @@
// The AS-number grammar shared by the ASN guards (common/guards.js) and the
// The AS-number grammar shared by the ASN guards (server/guards.js) and the
// tools that take a typed AS number (Whois, ASN Profile — via the
// frontend/utils/asn-input.js bridge): up to ten digits, AS1 … AS4294967295,
// `AS` prefix case-insensitive.
// frontend/utils/ip/asn-input.js bridge): up to ten digits, AS1 …
// AS4294967295, `AS` prefix case-insensitive.
export const MAX_ASN = 4294967295;
+1 -1
View File
@@ -10,7 +10,7 @@ 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. Also consumed by
// common/rdap.js for CIDR containment math.
// server/rdap.js for CIDR containment math.
export function expandIPv6(ip) {
const halves = ip.split('::');
if (halves.length > 2) return null;
+1 -1
View File
@@ -36,7 +36,7 @@ export async function fetchWithTimeout(url, init = {}) {
}
// Optional User-Agent for fetchUpstream calls. Backend boot injects a
// project-identifying UA (see common/upstream-ua.js) because some upstream
// project-identifying UA (see server/upstream-ua.js) because some upstream
// WAFs hard-block undici's default `User-Agent: node`. Injection keeps this
// file browser-safe: the frontend bundle never touches fs / process.
let upstreamUserAgent = null;
+11
View File
@@ -0,0 +1,11 @@
// Firebase Auth handler proxy: `/__/auth/*` on the site's own origin →
// `<projectId>.firebaseapp.com`, mounted by frontend-server.js (production)
// and vite.config.js (dev). Why: api/AGENTS.md "Firebase Auth handler proxy".
export const FIREBASE_AUTH_PROXY_PATH = '/__/auth';
// null without a project id: no Firebase, no proxy.
export const firebaseAuthProxyTarget = (projectId) => {
const id = typeof projectId === 'string' ? projectId.trim() : '';
return id ? `https://${id}.firebaseapp.com` : null;
};
+1 -1
View File
@@ -3,7 +3,7 @@
//
// The back end takes no part in that: which languages upstream *data* comes in
// is a separate set owned by the source itself (SUPPORTED_LANGS in
// common/maxmind-service.js), and every `?lang` consumer resolves an unfamiliar
// server/datasets/maxmind-service.js), and every `?lang` consumer resolves an unfamiliar
// tag onto its own family instead of rejecting it. A new UI locale is a
// front-end-only change.
+4 -4
View File
@@ -1,8 +1,8 @@
// MAC Lookup query parsing, shared by the /api/macchecker handler and the
// MacChecker form (via frontend/utils/mac-input.js). A query is a full
// 48-bit address or any leading part of one, down to a 24-bit vendor prefix,
// in the usual notations: colon / hyphen pairs, Cisco dotted quads, or bare
// hex.
// MacChecker form (via frontend/utils/features/mac-input.js). A query is a
// full 48-bit address or any leading part of one, down to a 24-bit vendor
// prefix, in the usual notations: colon / hyphen pairs, Cisco dotted quads,
// or bare hex.
export const MAC_HEX_LENGTH = 12;
export const MAC_PREFIX_MIN_HEX = 6;
+16 -3
View File
@@ -25,7 +25,7 @@ export const REPORT_TTL_DAYS = [1, 3, 7];
export const REPORT_MAX_BYTES = 256 * 1024;
// Anonymity verdict codes for ipinfo cards — made by
// utils/transform-ip-data.js, shared by the builder whitelist and the schema
// utils/ip/transform-ip-data.js, shared by the builder whitelist and the schema
// enum below.
export const ANONYMITY_CODES = [
'tor', 'relay', 'residential', 'suspected_residential',
@@ -33,7 +33,7 @@ export const ANONYMITY_CODES = [
];
// Connectivity status codes — single source shared by the recording component
// (ConnectivityTest.vue), the builder whitelist (utils/report-builders.js)
// (ConnectivityTest.vue), the builder whitelist (utils/report/report-builders.js)
// and the schema enum below, so a rename can't drift them apart.
export const CONNECTIVITY_STATUS = {
OK: 'ok',
@@ -165,7 +165,7 @@ const SECTION_SPECS = {
connectivity: obj({
testedAt: isoDate(),
// 72 = 7 built-in targets + the 60-target custom/import cap, plus
// headroom; matches the builder's slice in utils/report-builders.js.
// headroom; matches the builder's slice in utils/report/report-builders.js.
targets: arr(72, obj({
id: str(48),
name: str(64),
@@ -268,6 +268,19 @@ const SECTION_SPECS = {
rtt: opt(int(0, 600000)),
}))),
}),
// Ad & Tracker Blocking Test: per-category request counts only. Group
// ids stay capped strings — the probe list lives in the front end and
// changes with it.
adblocktest: obj({
testedAt: isoDate(),
groups: arr(16, obj({
id: str(16),
total: int(0, 1024),
blocked: int(0, 1024),
open: int(0, 1024),
unknown: int(0, 1024),
})),
}),
invisibility: obj({
testedAt: isoDate(),
ip: opt(ipValue()),
+5 -1
View File
@@ -35,6 +35,10 @@ function isIPv6(ip) {
return typeof ip === 'string' && ip.includes(':');
}
// Validate an IP of one specific version — safe on untrusted input.
const isValidIPv4 = (ip) => isValidIP(ip) && !isIPv6(ip);
const isValidIPv6 = (ip) => isValidIP(ip) && isIPv6(ip);
// Validate if a string is a syntactically plausible domain name.
// Matches the hostname pattern used by DnsResolver / Whois / CensorshipCheck:
// labels of [a-z0-9-], at least one dot, and a TLD of 2+ letters. Any label
@@ -119,4 +123,4 @@ const isUsablePublicIP = (ip) => {
return !(isIPv6(ip) ? isReservedV6(ip) : isReservedV4(ip));
};
export { isValidIP, isIPv6, isValidDomain, isUsablePublicIP };
export { isValidIP, isValidIPv4, isValidIPv6, isIPv6, isValidDomain, isUsablePublicIP };
+14 -1
View File
@@ -4,7 +4,8 @@ import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createProxyMiddleware } from 'http-proxy-middleware';
import logger from './common/logger.js';
import logger from './server/logger.js';
import { FIREBASE_AUTH_PROXY_PATH, firebaseAuthProxyTarget } from './common/firebase-auth-proxy.js';
dotenv.config({ quiet: true });
@@ -21,6 +22,18 @@ frontendApp.use('/api', createProxyMiddleware({
changeOrigin: true
}));
// Firebase's auth handler served first-party (api/AGENTS.md "Firebase Auth
// handler proxy"); ahead of the static layer and SPA fallback so it never
// resolves to index.html.
const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID);
if (firebaseAuthTarget) {
frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({
target: `${firebaseAuthTarget}${FIREBASE_AUTH_PROXY_PATH}`,
changeOrigin: true
}));
logger.info(`🛡️ Firebase Auth handler proxied: ${FIREBASE_AUTH_PROXY_PATH}/* → ${firebaseAuthTarget}`);
}
// 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
+76 -17
View File
@@ -13,9 +13,10 @@ over copied-in shadcn-vue primitives. No TypeScript, no `dark:` dual pairs.
```
frontend/
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js)
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js;
│ PWA / blocked-popup redirect sign-in: utils/auth-redirect.js)
├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
├── data/ ← static config (tools registry drives router+cards+drawer)
├── data/ ← static config (tools registry drives pages+cards+nav)
├── lib/ · utils/ · composables/ ← see "Helper placement"
└── components/ ← sections + ip-infos/ advanced-tools/ report/ widgets/ svgicons/ ui/
```
@@ -26,10 +27,14 @@ Every file opens with a header comment stating its purpose — read those.
- **Composition API** everywhere; no Options API. Alias `@` → `frontend/`.
- **Shared-with-backend helpers live in `common/`**, re-exported through a thin
`utils/` bridge so consumers keep `@/utils/...` imports (`utils/valid-ip.js`).
`utils/` bridge so consumers keep `@/utils/...` imports (`utils/ip/valid-ip.js`).
- **Helper placement:** Vue reactivity / lifecycle → `composables/` (`useXxx`);
otherwise `utils/` (never `use-` prefixed). `lib/` stays shadcn-only. A pure
function next to a composable exports from that composable's file.
- **Inside `utils/`:** app-wide plumbing at its root; IP / ASN data shaping
and validation → `ip/`; the report pipeline → `report/`; the pure layer of
one feature (a tool, a section, Pulse) → `features/`; per-source probe
modules keep their own dirs (`getips/`, `dnsleaks/`, `persona/`).
### Achievements are event-driven
@@ -39,7 +44,7 @@ events → slugs, `composables/use-achievement-engine.js` owns all guards (rules
wait for the remote snapshot; pre-sync hits parked). New achievement = entry +
rule + (only if no suitable event exists) a new event. The shareable report
rides the same bus: `<domain>:finished` → `use-report-collector.js` →
`utils/report-builders.js` → sections whitelisted by `common/report-schema.js`.
`utils/report/report-builders.js` → sections whitelisted by `common/report-schema.js`.
New reportable test = event + builder + schema entry in one change; new result
semantics = builder whitelist + schema enum too (builders fail soft, fixtures
are frozen: drift = quietly missing fields). Report links are public: the
@@ -56,18 +61,39 @@ reserved `auth` / `quota` / `input` (the bus adds `unavailable` / `timeout`).
Cross-component triggers use the bus, never template refs (refs stay for UI
chrome); tools register at setup, callers `waitForAppCommand` first.
### Advanced Tools gates decide listing only
### Advanced Tools are pages, kept alive
`data/tools.js` gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`,
e.g. `asn` → `cloudFlare`) are read only via `isToolAvailable()`, by the card
grid and the nav; deep links (`?tool=`, `/tools/:slug`) are never gated.
- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over `data/tools.js`),
and every opener routes there: cards are real `<a href>` (a plain click
`router.push`es, modified clicks stay the browser's), Nav, shortcuts
(`Advanced.vue`'s exposed `openTool`), in-app links. `/?tool=<slug>` is only
a replace-redirect in the router's `beforeEach` (`utils/legacy-tool-link.js`).
- Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` →
`cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()`
(card grid, Nav); a `/tools/:slug` link is never gated.
- `App.vue` keeps Home and `ToolPage` alive (`KeepAlive`); `ToolPage` keeps up
to 8 tools alive per slug, dropped on sign-out / account switch
(`utils/tool-cache.js`). Back / forward is plain history; the breadcrumb's
Home steps back when the previous entry is Home (`utils/back-target.js`), so
Home returns as left, scroll included.
- A cached page never unmounts, so whatever acts on the route, the document
head, window events or the store's one-shot triggers gates on
`use-route-active.js` (`useRouteActive` / `useActiveValue` /
`useActiveEventListener`), not on mount / unmount. Overlays on a page being
left are closed by `use-overlay-shortcuts.js` — their portal would outlive it;
App.vue's own (store `openSheet`, the `User` dialog) close on a path change.
- Homepage state reaches a tool page through the store, the app-events
collectors and the command bus: a cached Home keeps its commands registered,
so a caller dispatches in place and goes home first only when
`hasAppCommand()` says Home never mounted (Persona Check).
### ASN Profile
- One `/api/asn-profile` request, one loading state, then the whole page; a
section (reputation included — no `configs.originalSite` check) renders only
when its `status` is `ok`. `ASNConnectivity.vue` gets `:expandable="false"
:bordered="false"`; its defaults keep IPCard / QueryIP unchanged.
when its `status` is `ok`. `ASNConnectivity.vue` gets `:bordered="false"`
(the card draws the border) and keeps its expand-to-drawer; its defaults
keep IPCard / QueryIP unchanged.
- `peeringdb` is operator self-reporting: vocabularies → locale keys
(`PEERING_ENUMS`), names / cities as written. `ASN_PROFILE_TIMEOUT_MS` stays
above the largest backend section deadline (tested).
@@ -77,7 +103,7 @@ grid and the nav; deep links (`?tool=`, `/tools/:slug`) are never gated.
### Overlays take no keyboard shortcuts
One dispatcher (`utils/shortcut.js`) over the map `use-shortcuts.js` registers
(home route only; Home clears it on unmount; `registerShortcuts()` replaces it),
(home route only; Home clears it when left; `registerShortcuts()` replaces it),
suspended while an overlay is open — keyed off form: the `ui/` roots `Dialog` /
`Sheet` / `Drawer` call `use-overlay-shortcuts.js`, so anything built on them
inherits it; overlays nest; Esc and native scrolling still work.
@@ -99,7 +125,9 @@ masked; in the privacy policy). No backend 5xx capture; envelopes: `/api/monitor
Tailwind only when neither fits. Keep on upstream syncs: `Spinner` +
`ToolLoadingSkeleton`, `toggle` / `toggle-group`'s `primary` pressed pair, the
overlay roots' shortcut suspension, `select`'s trigger geometry (`py-1` + flex
value span, not `-webkit-box` — Safari shifts it ~2px).
value span, not `-webkit-box` — Safari shifts it ~2px). CLI trap: `shadcn-vue
add` here writes `import { cn } from "cn"` and installs the unrelated `cn`
package — drop the dependency, import from `@/lib/utils`.
### Design tokens
@@ -131,17 +159,48 @@ Every "business state → color" mapping goes through `use-status-tone.js`
`INLINE_TIERS`; `:max-lines="2"` on heroes); no length-threshold helpers.
- **Filter tags** — `ToggleGroup :spacing="2"` of detached `h-7 rounded-full`
pills, `w-full flex-wrap` (IPHistory, DnsResolver); never `spacing=0`.
- **Shareable tool input** — watch `route.query.q` (immediate, so mount and
later changes in an open drawer both run), `router.replace` it on every run
(AsnProfile); both `/tools/` and `?tool=` URLs.
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
pathOf: () => route.path })` (immediate, so mount, later changes and a `q`
changed while cached all run; another page's `q` never does), `router.replace`
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`. A `q` runs
the tool only when a run needs no sign-in or quota (AsnProfile,
IpCalculator); otherwise it just prefills (IpBlocklist).
- **Fixed option sets** — a closed list wider than one line is a `Select`.
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
- **Tables vs lists** — real per-column header semantics → `<table>`;
otherwise a bordered `<ul class="rounded-lg border bg-card divide-y">`.
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
- **Drawer vs Sheet** — bottom Drawer for Advanced Tools and full-bleed
expansions of an inline visual; side panels use `Sheet`.
- **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed;
`index.html` pads the body for it), never per page; `User` / `Preferences`
sit beside it, one each. What a click does on the current route is
`resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
no section is highlighted off Home.
- The link row collapses to the hamburger + Sheet when `use-nav-overflow.js`
measures it doesn't fit (no breakpoint); `isMobile` only drives phone behaviour.
- **Footer** — four columns on the Advanced Tools grid: brand / Project / Docs
& Support / Clients (only the CLI entry is gated, on the curl domains);
never repeats on-page content (tools, sections); About / Changelog /
Acknowledgement open in its About sheet, the CLI in its own dialog
(`Footer.vue`).
- **Page breadcrumb** — every page but Home opens its body with
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
helper, a bare last crumb `aria-current="page"`. A tool page ends on the
Advanced Tools crumb: its `<h1>` already names the tool.
- **Page transitions** — View Transitions hooked in the router's
`beforeResolve` (`utils/page-transition.js`: Home → page slides forward,
→ Home back, page → page fades; keyframes in `style/style.css`); none on a
query-only change, the first load, reduced motion or a history step the
browser animated itself. Only the Nav has a `view-transition-name` (it
stays still). After a history step, `utils/revive-animations.js` replays
the page's running CSS animations: iOS swipe-back leaves them frozen.
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
`hidden sm:flex` never shows: write `max-sm:hidden`.
- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an
inline visual (ASNConnectivity's graph); side panels use `Sheet`; a tool is
a page, never an overlay.
- **Motion** — hover lift `transition-transform duration-300 ease-out
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots.
+28 -22
View File
@@ -1,9 +1,19 @@
<template>
<!-- Thin app shell: only the globals that must exist on every route live
here (tooltip context, toast host, PWA install prompt, theme). The
homepage and the standalone tool pages are swapped in via <router-view>. -->
<!-- Thin app shell: the globals every route shares (Nav, account and
preferences dialogs, tooltip context, toast host, PWA prompt, theme).
Home and ToolPage stay alive, so coming back neither re-runs the tests nor
loses a tool's state; keyed by route name, every tool route shares one
ToolPage, which caches each tool per slug itself. -->
<TooltipProvider :delay-duration="150">
<router-view />
<NavBar />
<router-view v-slot="{ Component, route: viewRoute }">
<KeepAlive :include="['Home', 'ToolPage']">
<component :is="Component" :key="viewRoute.name" />
</KeepAlive>
</router-view>
<!-- Dialog hosts for the Nav's account menu and the tools' quota hints. -->
<User />
<Preferences />
<Alert />
<DocsAssistant />
<PWA v-if="offerPwaInstall" />
@@ -14,51 +24,47 @@
import { watch, ref, onMounted, defineAsyncComponent } from 'vue';
import { useRoute } from 'vue-router';
import { TooltipProvider } from '@/components/ui/tooltip';
import NavBar from '@/components/Nav.vue';
import Alert from '@/components/widgets/Toast.vue';
import DocsAssistant from '@/components/widgets/DocsAssistant.vue';
import { shouldOfferPwaInstall } from '@/utils/pwa.js';
import { sendVisitBeacon } from '@/utils/pulse-beacon.js';
import { sendVisitBeacon } from '@/utils/features/pulse-beacon.js';
import { useTheme } from '@/composables/use-theme.js';
import { useMainStore } from '@/store';
// PWA install prompt — async and eligibility-gated: ineligible visits (too
// few 12h-deduped uses, prompt cap reached, already installed) never load
// pwa-install or trigger its manifest fetch; eligible ones load it at the
// prompt's 30s mark.
const PWA = defineAsyncComponent(() => import('@/components/widgets/PWA.vue'));
// Dialogs nothing shows on first paint: their code stays off the boot path.
const User = defineAsyncComponent(() => import('@/components/User.vue'));
const Preferences = defineAsyncComponent(() => import('@/components/widgets/Preferences.vue'));
const offerPwaInstall = ref(false);
onMounted(() => {
if (shouldOfferPwaInstall()) {
setTimeout(() => { offerPwaInstall.value = true; }, 30 * 1000);
}
// Earth Online visit tick — app-level so every route counts, once per
// page load; the backend dedups per IP (utils/pulse-beacon.js).
// page load; the backend dedups per IP (utils/features/pulse-beacon.js).
sendVisitBeacon();
});
import { useAchievementEngine } from '@/composables/use-achievement-engine.js';
import { useReportCollector } from '@/composables/use-report-collector.js';
import { useAppPersonaCollector } from '@/composables/use-persona-collector.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', 'report']);
// A page change closes store.openSheet: a site-wide sheet would stay open over
// the next page, and one a left page hosted would reopen when it returns.
const store = useMainStore();
const route = useRoute();
watch(
() => STANDALONE_ROUTES.has(route.name),
(isStandalone) => {
document.body.classList.toggle('jn-standalone-page', isStandalone);
},
{ immediate: true },
);
watch(() => route.path, () => {
if (store.openSheet) store.setOpenSheet(null);
});
// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html.
// #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque
// overlay while the overlay plays its exit (text fade → logo shrink →
// removal).
// Runs once at root mount, so it covers both the homepage and a fresh load of
// a standalone tool page.
// removal). Runs once at root mount, whichever page loads first.
const loadingElement = document.getElementById('jn-loading');
const appElement = document.getElementById('app');
+5 -2
View File
@@ -151,12 +151,15 @@ const flipCard = (achievementName) => {
}
};
// Immediate: UserMenu raises the request from another page and goes Home
// first, and this async component can mount after the flag is already set.
watch(() => triggerAchievements.value, (newVal) => {
if (newVal) openAchievements();
if (!newVal) return;
openAchievements();
if (!remoteUserInfoFetched.value) {
store.setTriggerRemoteUserInfo(true);
}
});
}, { immediate: true });
</script>
<style scoped>
-108
View File
@@ -1,108 +0,0 @@
<template>
<!-- Curl Dialog -->
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent :title="t('curl.Title')">
<DialogHeader :icon="Terminal" :title="t('curl.Title')" />
<div v-if="curlDomainsHadSet" class="space-y-3">
<!-- Description -->
<div class="space-y-1 text-xs font-mono">
<p class="jn-comment"><span class="text-muted-foreground">{{ t('curl.Note1') }}</span></p>
<p class="jn-comment">
<span class="text-muted-foreground">{{ t('curl.Note2_1') }}
<Badge variant="outline" class="text-success">curl</Badge> {{ t('curl.Note2_2') }}</span>
</p>
<p class="jn-comment">
<span class="text-muted-foreground"><Badge variant="outline" class="text-success">geo</Badge> {{ t('curl.Note3') }}</span>
</p>
</div>
<!-- 3 curl command blocks -->
<div class="space-y-3">
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv4') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv4Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv6') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv6Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.get6and4') }}</p>
<pre class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ ipv64Domain }}<span class="text-success">/geo</span></pre>
</div>
</div>
</div>
<div v-else class="py-6 text-center">
<p class="text-sm text-muted-foreground">{{ t('curl.notAvailable') }}</p>
</div>
</DialogContent>
</Dialog>
<!-- Additional Tools: external product link bar -->
<div class="mx-auto text-center max-w-[98%]">
<div id="morefromipchecking" class="flex justify-center items-center gap-2 sm:gap-4 flex-wrap">
<a href="https://www.raycast.com/jason5ng32/ipcheck-ing" target="_blank" rel="noopener"
@click="trackEvent('Additional', 'AdditionalClick', 'Raycast')">
<img src="/additional/raycast.webp" alt="IPCheck.ing on Raycast"
class="w-[108px] sm:w-[180px] h-auto">
</a>
<button type="button" @click="openCurlModal"
aria-label="IPCheck.ing for Curl"
class="cursor-pointer bg-transparent border-0 p-0">
<img src="/additional/curl.webp" alt="IPCheck.ing for Curl"
class="w-[108px] sm:w-[180px] h-auto">
</button>
<a href="https://lite.ipcheck.ing" target="_blank" rel="noopener"
@click="trackEvent('Additional', 'AdditionalClick', 'Lite')">
<img src="/additional/lite.webp" alt="IPCheck.ing lite"
class="w-[108px] sm:w-[180px] h-auto">
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Terminal } from '@lucide/vue';
import { Badge } from '@/components/ui/badge';
const { t } = useI18n();
const store = useMainStore();
const ipv4Domain = computed(() => store.curl.ipv4Domain);
const ipv6Domain = computed(() => store.curl.ipv6Domain);
const ipv64Domain = computed(() => store.curl.ipv64Domain);
const curlDomainsHadSet = computed(() => store.curlDomainsHadSet);
const isOpen = ref(false);
const openCurlModal = () => {
isOpen.value = true;
trackEvent('Additional', 'AdditionalClick', 'Curl');
};
</script>
<style scoped>
.jn-curl::before {
content: '$ ';
color: var(--muted-foreground);
font-weight: 500;
margin-right: 0.25rem;
opacity: 0.7;
}
.jn-comment::before {
content: '// ';
color: var(--muted-foreground);
font-weight: 500;
opacity: 0.7;
}
</style>
+51 -162
View File
@@ -13,180 +13,93 @@
</div>
</header>
<!-- Card grid. Each card is a real <a> to the standalone /tools/:slug
page, so ⌘/Ctrl-click, middle-click and "open in new tab" all work.
A plain left-click (or Enter / Space) is intercepted to open the
in-page drawer instead. A dedicated ↗ corner button always opens the
standalone page in a new tab. -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<Card v-for="card in enabledCards" :key="card.slug"
:data-adv-slug="card.slug"
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<a :href="card.noStandalone ? `/?tool=${card.slug}` : `/tools/${card.slug}`"
class="block cursor-pointer no-underline text-inherit"
@click="onCardClick($event, card.slug)"
@keydown.enter.prevent="openTool(card.slug)"
@keydown.space.prevent="openTool(card.slug)">
<CardContent class="p-4">
<h3 class="text-xl font-medium text-primary mb-2 pr-10">
<PanelBottomOpen
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
{{ t(card.titleKey) }}
</h3>
<!-- Description -->
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
{{ t(card.noteKey) }}
</p>
<!-- Top right emoji -->
<span class="jn-emoji" aria-hidden="true">{{ card.icon }}</span>
</CardContent>
</a>
</Card>
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
card is a real <a> to /tools/:slug: modified clicks stay the
browser's, a plain click / Enter / Space routes in-app. -->
<div class="mt-4 space-y-6">
<div v-for="group in cardGroups" :key="group.id">
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
{{ t(group.titleKey) }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<Card v-for="card in group.tools" :key="card.slug"
:data-adv-slug="card.slug"
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<a :href="`/tools/${card.slug}`"
class="block cursor-pointer no-underline text-inherit"
@click="onCardClick($event, card.slug)"
@keydown.enter.prevent="openTool(card.slug)"
@keydown.space.prevent="openTool(card.slug)">
<CardContent class="p-4">
<h4 class="text-xl font-medium text-primary mb-2 pr-10">
<ArrowRight
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
{{ t(card.titleKey) }}
</h4>
<!-- Description -->
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
{{ t(card.noteKey) }}
</p>
<!-- Top right emoji -->
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
</CardContent>
</a>
</Card>
</div>
</div>
</div>
<!-- Tool details Drawer -->
<Drawer :open="isOpen" @update:open="onOpenChange" :dismissible="true">
<DrawerContent :title="activeTool ? t(activeTool.titleKey) : t('advancedtools.Title')"
:safe-area-top="isMobile || isFullScreen"
:class="['jn-tools-drawer overflow-hidden', (isMobile || isFullScreen) ? 'h-full rounded-none' : 'h-[85vh]']">
<!-- Drawer internal header -->
<div class="flex items-center gap-2 px-4 pt-1 pb-3 jn-drawer-header shrink-0">
<button v-if="!isMobile" type="button"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="fullScreen" :aria-label="isFullScreen ? 'Exit full screen' : 'Full screen'">
<Maximize v-if="!isFullScreen" class="size-4" />
<Minimize v-else class="size-4" />
</button>
<span v-if="activeTool" class="flex-1 text-base md:text-lg font-medium truncate"
:class="isMobile ? 'text-left' : 'text-center'">
<span class="mr-1">{{ activeTool.emoji }}</span>{{ t(activeTool.titleKey) }}
</span>
<span v-else class="flex-1" />
<!-- Open the current tool as a standalone page (hidden in a
PWA window, which has no new-tab affordance, and for
noStandalone tools, which have no such page) -->
<a v-if="activeTool && !isPwa && !activeTool.noStandalone" :href="`/tools/${activeTool.slug}`"
target="_blank" rel="noopener"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="t('advancedtools.OpenInNewTab')" :aria-label="t('advancedtools.OpenInNewTab')">
<SquareArrowOutUpRight class="size-4" />
</a>
<DrawerClose
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" />
</div>
<!-- Content area (scrollable) -->
<div class="flex-1 overflow-y-auto px-1 md:px-2 pb-6" ref="scrollContainer">
<div :class="isMobile ? 'w-full px-3' : 'jn-canvas-width px-6'">
<component :is="activeComponent" v-if="activeComponent" />
</div>
</div>
</DrawerContent>
</Drawer>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="advanced" />
</section>
</template>
<script setup>
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { ADVANCED_TOOLS, TOOL_BY_SLUG } from '@/data/tools.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
import { listedTools } from '@/utils/tool-availability.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { Card, CardContent } from '@/components/ui/card';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { Maximize, Minimize, PanelBottomOpen, SquareArrowOutUpRight } from '@lucide/vue';
import { ArrowRight } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Running as an installed PWA → hide the "open in new tab" affordance (a PWA
// window has no tab strip; it would pop out to the browser). See utils/pwa.js.
const isPwa = isRunningAsPwa();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
const scrollContainer = ref(null);
const route = useRoute();
const router = useRouter();
// Cards derive from the shared tool registry (frontend/data/tools.js). `icon`
// is mapped from the registry's `emoji` so the template key stays stable.
const cards = ADVANCED_TOOLS.map((tool) => ({ ...tool, icon: tool.emoji }));
// Card groups behind the deployment gates (original site / configs flag, see
// utils/tool-availability.js). Reactive on configs, so gated cards appear the
// moment configs land.
const cardGroups = computed(() => groupToolsByCategory(
listedTools(ADVANCED_TOOLS, configs.value),
));
// Deployment gates (original site / configs flag, see utils/tool-availability.js).
// Reactive on configs, so gated cards appear the moment configs land.
const enabledCards = computed(() => cards.filter((card) => isToolAvailable(card, configs.value)));
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
const activeTool = computed(() => {
const slug = route.query.tool;
return (typeof slug === 'string' && TOOL_BY_SLUG.get(slug)) || null;
});
const isOpen = computed(() => !!activeTool.value);
// Resolve each tool's lazy component once and cache it, so re-renders don't
// rebuild the async wrapper (which would remount the tool). The skeleton
// covers the chunk download; `delay` keeps fast loads flash-free.
const asyncToolCache = new Map();
const activeComponent = computed(() => {
const tool = activeTool.value;
if (!tool) return null;
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent({
loader: tool.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
}));
}
return asyncToolCache.get(tool.slug);
});
const isFullScreen = ref(false);
// Open a tool in the in-page drawer (just sets the query; isOpen reacts).
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
const openTool = (slug) => {
router.push({ path: '/', query: { tool: slug } });
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
router.push(`/tools/${slug}`);
trackEvent('Nav', 'NavClick', slug);
};
// Card left-click: open the drawer. Modifier / middle clicks fall through to
// the <a href> default so the browser opens the standalone page in a new tab.
const onCardClick = (e, slug) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
if (!isPlainClick(e)) return;
e.preventDefault();
openTool(slug);
};
const onOpenChange = (val) => {
// Drawer closed (drag / overlay / Esc / close button) → drop the ?tool query.
if (!val) {
if (route.query.tool) router.push({ path: '/', query: {} });
isFullScreen.value = false;
}
};
// Full screen toggle: height determined by DrawerContent's class
const fullScreen = () => {
isFullScreen.value = !isFullScreen.value;
};
onMounted(() => {
store.setMountingStatus('AdvancedTools', true);
});
defineExpose({
openTool, fullScreen,
openTool,
});
</script>
@@ -210,28 +123,4 @@ defineExpose({
:global(.dark) .jn-adv-card:hover .jn-emoji {
text-shadow: 0 0 10pt rgb(255 255 255 / 0.15);
}
/* Drawer content area width (desktop) */
.jn-canvas-width {
width: fit-content;
margin: auto;
max-width: 1400px;
}
.jn-drawer-header {
border-bottom: 1px solid var(--border);
}
/* Drawer root container needs flex-col, so that the header is fixed + content scrollable */
.jn-tools-drawer {
display: flex;
flex-direction: column;
}
/* Full screen toggle height transition */
:global(.jn-tools-drawer) {
transition:
transform 0.5s cubic-bezier(0.32, 0.72, 0, 1),
height 0.3s cubic-bezier(0.32, 0.72, 0, 1) !important;
}
</style>
+3 -3
View File
@@ -160,9 +160,9 @@ import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js';
import { CONNECTIVITY_STATUS } from '@/utils/report/report-schema.js';
import { TILE_PREVIEW, faviconPath, MINE_LIST_ID } from '@/data/connectivity-import-lists.js';
import { siteUrlOf, removeMember } from '@/utils/connectivity-lists.js';
import { siteUrlOf, removeMember } from '@/utils/features/connectivity-lists.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -386,7 +386,7 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
const testTime = Math.round(performance.now() - beginTime);
test.status = t('connectivity.StatusAvailable');
// Locale-free twin of the localized status label, consumed by the report
// builder (utils/report-builders.js) — the label itself can't be mapped
// builder (utils/report/report-builders.js) — the label itself can't be mapped
// back to an enum without string comparison against t() output.
test.statusCode = CONNECTIVITY_STATUS.OK;
test.mintime = test.mintime === 0 ? testTime : Math.min(test.mintime, testTime);
+2 -2
View File
@@ -107,13 +107,13 @@ import FitText from '@/components/widgets/FitText.vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
import {
ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak,
ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak, every1dns,
createDnsLeakRunner,
} from '@/utils/dnsleaks';
// All providers in priority order: the first SLOT_COUNT hold a card each,
// the rest are standbys. New provider = file under utils/dnsleaks + append.
const PROVIDERS = [ipApi, surfshark, myipstack, fastly, bashws, browserleaks, ipleak];
const PROVIDERS = [ipApi, every1dns, myipstack, fastly, surfshark, bashws, browserleaks, ipleak];
const SLOT_COUNT = 4;
const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT);
+210 -58
View File
@@ -1,65 +1,141 @@
<!-- Site footer on every page: brand / Project / Docs & Support / Clients
columns, plus a signature bar (with the copyright on self-hosted
deployments). About / Changelog / Acknowledgement open in its About
sheet, the CLI in its own dialog; nothing already on the page (tools,
sections) repeats here. -->
<template>
<footer class="pb-6 text-sm text-muted-foreground">
<!-- Primary footer links: Sponsor / Privacy / About / Changelog / Special
Thanks. The last three open the same About sheet on their own tab.
Text-only (no per-button icons) to keep the row calm. -->
<div class="flex flex-wrap items-center justify-center gap-x-3 mb-2 mx-auto">
<Button variant="link" size="default" as-child class="text-[#d63384] hover:text-[#d63384]">
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener">
{{ t('about.Sponsor') }}
</a>
</Button>
<Button variant="link" size="default" as-child class="cursor-pointer">
<RouterLink to="/privacy" @click="trackEvent('Footer', 'FooterClick', 'Privacy')">
{{ t('about.Privacy') }}
</RouterLink>
</Button>
<Button variant="link" size="default" as-child class="cursor-pointer">
<a :href="'https://docs.ipcheck.ing/knowledge-base/'+(locale === 'en' ? '' : locale)" target="_blank"
rel="noopener" @click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
</Button>
<Button variant="link" size="default" as-child class="cursor-pointer">
<a :href="'https://docs.ipcheck.ing/developer/'+(locale === 'en' ? '' : locale)" target="_blank" rel="noopener"
@click="trackEvent('Footer', 'FooterClick', 'Contribute')">
{{ t('about.Contribute') }}
</a>
</Button>
<Button variant="link" size="default" @click="openAboutTab('changelog', 'Changelog')" class="cursor-pointer">
{{ t('changelog.Title') }}
</Button>
<Button variant="link" size="default" @click="openAboutTab('acknowledgement', 'Acknowledgement')"
class="cursor-pointer">
{{ t('acknowledgement.Title') }}
</Button>
<Button variant="link" size="default" @click="openAbout" class="cursor-pointer">
{{ t('about.Title') }}
</Button>
</div>
<!-- Author + GitHub on one line, copyright dimmed beneath — low-emphasis signature block -->
<div class="flex flex-col items-center justify-center gap-y-1.5 text-xs opacity-70">
<div class="flex items-center justify-center gap-x-2">
<span>Created by Jason Ng with love</span>
<JnTooltip :text="t('Tooltips.GithubLink')" side="top">
<Button variant="ghost" size="icon" as-child class="size-5 text-foreground/70 hover:text-foreground">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" aria-label="Github"
<footer class="border-t border-secondary text-sm text-muted-foreground">
<div class="mx-auto w-full px-4 jn-container">
<!-- Link columns: four tracks on md+, matching the Advanced Tools grid;
on phones the brand spans the top and the rest flow two per row. -->
<div class="grid grid-cols-2 gap-8 py-12 md:grid-cols-4">
<!-- Brand -->
<div class="col-span-2 md:col-span-1">
<div class="inline-flex items-center gap-1.5 text-xl font-semibold text-foreground">
<brandIcon />
<span class="tracking-tight">
<span class="font-bold">IP</span><span class="font-extralight">Check.ing</span>
</span>
</div>
<p class="mt-2">{{ t('footer.Tagline') }}</p>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Github')">
<Icon icon="ri:github-line" />
<Icon icon="ri:github-line" class="size-4" />
<span>GitHub</span>
</a>
</Button>
</JnTooltip>
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener"
class="inline-flex items-center gap-1 text-sm text-[#d63384] no-underline transition-opacity hover:opacity-80">
<Icon icon="ri:heart-line" class="size-4" />
{{ t('about.Sponsor') }}
</a>
</div>
</div>
<!-- Project: the About sheet's tabs, privacy and license -->
<nav :aria-label="t('footer.Project')">
<h3 :class="headingClass">{{ t('footer.Project') }}</h3>
<ul class="space-y-2">
<li>
<button type="button" :class="linkClass" @click="openAbout">
{{ t('about.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('changelog', 'Changelog')">
{{ t('changelog.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('acknowledgement', 'Acknowledgement')">
{{ t('acknowledgement.Title') }}
</button>
</li>
<li>
<RouterLink to="/privacy" :class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'Privacy')">
{{ t('about.Privacy') }}
</RouterLink>
</li>
<li>
<a :href="`${t('page.footerLink')}/blob/main/LICENSE`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'License')">
{{ t('footer.License') }}
</a>
</li>
</ul>
</nav>
<!-- Docs & Support: locale-aware docs.ipcheck.ing links + issue tracker -->
<nav :aria-label="t('footer.Support')">
<h3 :class="headingClass">{{ t('footer.Support') }}</h3>
<ul class="space-y-2">
<li>
<a :href="docsUrl('knowledge-base', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'DeveloperDocs')">
{{ t('footer.DeveloperDocs') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale, 'getting-started/')" target="_blank" rel="noopener"
:class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'SelfHosting')">
{{ t('footer.SelfHosting') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/issues`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'ReportIssue')">
{{ t('footer.ReportIssue') }}
</a>
</li>
</ul>
</nav>
<!-- Clients: Raycast / Lite always; the CLI entry needs the curl
domains configured. -->
<nav :aria-label="t('footer.Clients')">
<h3 :class="headingClass">{{ t('footer.Clients') }}</h3>
<ul class="space-y-2">
<li>
<a href="https://www.raycast.com/jason5ng32/ipcheck-ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Raycast')">
<Icon icon="simple-icons:raycast" class="size-4 shrink-0" />
{{ t('footer.Raycast') }}
</a>
</li>
<li v-if="curlDomainsHadSet">
<button type="button" :class="linkClass" @click="openCurl">
<Terminal class="size-4 shrink-0" />
{{ t('curl.Title') }}
</button>
</li>
<li>
<a href="https://lite.ipcheck.ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Lite')">
<Feather class="size-4 shrink-0" />
{{ t('footer.Lite') }}
</a>
</li>
</ul>
</nav>
</div>
<template v-if="!configs.originalSite">
<span>
<!-- Signature bar: every deployment; the copyright only when self-hosted -->
<div class="flex flex-col items-center gap-y-1.5 border-t border-secondary py-6 text-center text-xs opacity-70">
<span>Created by Jason Ng with love</span>
<span v-if="!configs.originalSite">
{{ t('page.copyRightName') }}
<a :href="t('page.copyRightLink')" target="_blank" rel="noopener"
class="text-foreground/80 hover:text-foreground hover:underline">
{{ t('page.copyRightLinkName') }}
</a>
</span>
</template>
</div>
</div>
<!-- About Sheet -->
@@ -151,10 +227,55 @@
</li>
</ul>
</TabsContent>
</div>
</Tabs>
</SheetContent>
</Sheet>
<!-- Command Line API dialog: only opened from the Clients link, which
is listed only when the curl domains are configured. -->
<Dialog :open="isCurlOpen" @update:open="isCurlOpen = $event">
<DialogContent :title="t('curl.Title')">
<DialogHeader :icon="Terminal" :title="t('curl.Title')" />
<div class="space-y-3">
<!-- Description -->
<div class="space-y-1 text-xs font-mono">
<p class="jn-comment"><span class="text-muted-foreground">{{ t('curl.Note1') }}</span></p>
<p class="jn-comment">
<span class="text-muted-foreground">{{ t('curl.Note2_1') }}
<Badge variant="outline" class="text-success">curl</Badge> {{ t('curl.Note2_2') }}
</span>
</p>
<p class="jn-comment">
<span class="text-muted-foreground">
<Badge variant="outline" class="text-success">geo</Badge> {{ t('curl.Note3') }}
</span>
</p>
</div>
<!-- 3 curl command blocks -->
<div class="space-y-3">
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv4Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv6') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv6Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.get6and4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv64Domain }}<span class="text-success">/geo</span></pre>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</footer>
</template>
@@ -164,25 +285,31 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import changelogData from '@/data/changelog.json';
import { trackEvent } from '@/utils/analytics';
import { docsUrl } from '@/utils/docs-url.js';
import { formatIsoDate } from '@/utils/time-utils';
import { fallbackChain } from '@/utils/locale-registry.js';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { JnTooltip } from '@/components/ui/tooltip';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Compass, ExternalLink, CircleFadingArrowUp, CirclePlus, BugOff, Smile } from '@lucide/vue';
import { Compass, ExternalLink, CircleFadingArrowUp, CirclePlus, BugOff, Smile, Terminal, Feather } from '@lucide/vue';
import brandIcon from './svgicons/Brand.vue';
import { Icon } from '@iconify/vue';
import { useRouteActive } from '@/composables/use-route-active.js';
const { t, tm, rt, locale } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
const configs = computed(() => store.configs);
const curl = computed(() => store.curl);
const curlDomainsHadSet = computed(() => store.curlDomainsHadSet);
const tabs = ['about', 'changelog', 'acknowledgement'];
const content = ref('about');
const headingClass = 'mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground';
const linkClass = 'inline-flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-foreground cursor-pointer';
// Static data from JSON — reverse once via computed so the template stays tidy.
const changelogReversed = computed(() => changelogData.slice().reverse());
@@ -240,8 +367,10 @@ const changelogBadgeClass = (type) => {
return '';
};
// Sheet toggle and store.openSheet bidirectional binding
const isOpen = computed(() => store.openSheet === 'about');
// Sheet toggle and store.openSheet bidirectional binding. Home and ToolPage are
// both cached with a Footer each; only the page on screen opens its sheet.
const routeActive = useRouteActive();
const isOpen = computed(() => routeActive.value && store.openSheet === 'about');
const onOpenChange = (val) => {
store.setOpenSheet(val ? 'about' : null);
};
@@ -261,6 +390,12 @@ const openAboutTab = (tab, trackLabel) => {
trackEvent('Footer', 'FooterClick', trackLabel);
};
const isCurlOpen = ref(false);
const openCurl = () => {
isCurlOpen.value = true;
trackEvent('Footer', 'FooterClick', 'Curl');
};
watch(content, () => {
nextTick(() => {
if (sheetBody.value) sheetBody.value.scrollTop = 0;
@@ -268,3 +403,20 @@ watch(content, () => {
});
</script>
<style scoped>
.jn-curl::before {
content: '$ ';
color: var(--muted-foreground);
font-weight: 500;
margin-right: 0.25rem;
opacity: 0.7;
}
.jn-comment::before {
content: '// ';
color: var(--muted-foreground);
font-weight: 500;
opacity: 0.7;
}
</style>
+7 -17
View File
@@ -1,8 +1,5 @@
<template>
<NavBar />
<User ref="userRef" />
<Achievements ref="achievementsRef" />
<Preferences />
<main id="mainpart" class="mx-auto w-full px-4 jn-container">
<div class="rounded-md">
<IPCheck />
@@ -21,23 +18,19 @@
<QueryIP ref="queryIPRef" />
</FloatingDock>
<HelpModal ref="helpModalRef" />
<Additional />
<Footer />
</template>
<script setup>
// The homepage. Holds every top-level section plus the Advanced Tools drawer.
// Split out of App.vue when the app moved to history-mode routing: App is now a
// thin shell, and this component is what /'s <router-view> renders. The truly
// global widgets (tooltip provider, toast, PWA, theme) stay in App.
// The homepage (`/`): every top-level section plus the Advanced Tools cards;
// what every route shares lives in App.vue.
//
// Components — the test sections and the always-visible chrome load
// synchronously; everything the first paint can't show (dialogs, drawers,
// the below-fold Additional/Footer) is an async component so its code stays
// synchronously; everything the first paint can't show (dialogs, the
// below-fold Footer) is an async component so its code stays
// out of the route chunk and out of the mount's critical path. Their
// template refs are null until the chunk lands — consumers (use-shortcuts)
// must optional-chain.
import NavBar from './Nav.vue';
import IPCheck from './IpInfos.vue';
import Connectivity from './ConnectivityTest.vue';
import WebRTC from './WebRtcTest.vue';
@@ -51,11 +44,8 @@ import FloatingDock from './widgets/FloatingDock.vue';
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
// Async (off-critical-path) components
const Additional = defineAsyncComponent(() => import('./Additional.vue'));
const Footer = defineAsyncComponent(() => import('./Footer.vue'));
const User = defineAsyncComponent(() => import('./User.vue'));
const Achievements = defineAsyncComponent(() => import('./Achievements.vue'));
const Preferences = defineAsyncComponent(() => import('./widgets/Preferences.vue'));
const QueryIP = defineAsyncComponent(() => import('./widgets/QueryIP.vue'));
const HelpModal = defineAsyncComponent(() => import('./widgets/Help.vue'));
const IPHistory = defineAsyncComponent(() => import('./widgets/IPHistory.vue'));
@@ -70,6 +60,8 @@ import { useShortcuts } from '@/composables/use-shortcuts.js';
import { useSectionTracking } from '@/composables/use-section-tracking.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
defineOptions({ name: 'Home' });
const { t } = useI18n();
const store = useMainStore();
const configs = computed(() => store.configs);
@@ -77,7 +69,6 @@ const userPreferences = computed(() => store.userPreferences);
// Template refs — UI chrome only; the test sections are reached through the
// command bus (utils/app-commands.js), not refs.
const userRef = ref(null);
const achievementsRef = ref(null);
const queryIPRef = ref(null);
const helpModalRef = ref(null);
@@ -113,8 +104,7 @@ useSectionTracking();
// Localized homepage head. Provide title/description explicitly via t() rather
// than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is
// taken at module load, before the (now async) locale messages land, so it would
// pin the head to index.html's English title. Reactive t() also re-applies the
// right copy when SPA-navigating back from a /tools/:slug page.
// pin the head to index.html's English title.
useDocumentMeta(() => ({
title: t('page.title'),
description: t('page.description'),
+4 -4
View File
@@ -35,9 +35,9 @@ import { ref, computed, onMounted, reactive, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { isUsablePublicIP } from '@/utils/valid-ip.js';
import { isUsablePublicIP } from '@/utils/ip/valid-ip.js';
import { toApiTag } from '@/utils/locale-registry.js';
import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
import { transformDataFromIPapi } from '@/utils/ip/transform-ip-data.js';
import { getIPFromIPIP, getIPFromCloudflare_V4, getIPFromCloudflare_V6, getIPFromIPChecking64, getIPFromIPChecking4, getIPFromIPChecking6 } from '@/utils/getips';
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
import { useAppCommand } from '@/composables/use-app-command.js';
@@ -70,8 +70,8 @@ const createDefaultCard = () => ({
isp: "",
asn: "",
asnlink: "",
mapUrl: '/res/defaultMap.webp',
mapUrl_dark: '/res/defaultMap_dark.webp',
mapUrl: "",
mapUrl_dark: "",
});
// IP data cards
+113 -244
View File
@@ -1,46 +1,49 @@
<template>
<!-- iOS PWA safe-area painter. Pairs with apple-mobile-web-app-status-bar-style=black-translucent
in index.html — the only way to get a live status-bar tint on iOS PWA, since WebKit
ignores JS theme-color writes and media-variant theme-color tags in standalone mode.
Color tracks --page-bg (style.css), which follows .dark class. -->
<div class="fixed top-0 left-0 right-0 z-50 pointer-events-none transition-colors duration-300"
style="height: env(safe-area-inset-top); background: var(--page-bg);" aria-hidden="true"></div>
<!-- The iOS status bar is opaque (no apple-mobile-web-app-status-bar-style), so the
inset is non-zero only on landscape notch edges. jn-site-nav: see style.css. -->
<header
class="fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
:class="{ '-translate-y-full': isNavHidden,
'bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur': !isPwa || (isPwa && !isMobile),
'bg-page-bg': isPwa && isMobile }">
<nav id="navbar-top" class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<nav id="navbar-top" ref="navEl"
class="relative mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<!-- Left: Hamburger (only mobile) + Brand -->
<div class="flex items-center gap-2">
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
<!-- Left: Hamburger (collapsed nav only) + Brand. Expanded, every group
keeps its natural width so use-nav-overflow measures true sizes. -->
<div ref="brandEl" class="flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<Button v-if="isNavCollapsed" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
aria-label="Toggle navigation menu" @click="store.toggleSheet('navMenu')">
<Menu />
</Button>
<a href="#" @click="handleLogoClick"
<a :href="isHome ? '#' : homeHref" @click="handleLogoClick"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-lg font-semibold text-foreground no-underline hover:opacity-80 transition-opacity">
<brandIcon />
<span class="tracking-tight truncate">
<span class="font-bold">IP</span><span class="font-extralight">Check.</span>
<span class="font-extralight"
:class="{ 'jn-shimmer-light': !loaded && !isDarkMode, 'jn-shimmer-dark': !loaded && isDarkMode }">ing</span>
:class="{ 'jn-shimmer-light': shimmer && !isDarkMode, 'jn-shimmer-dark': shimmer && isDarkMode }">ing</span>
</span>
</a>
</div>
<!-- Middle: Desktop nav links + GitHub star badge (left aligned, next to brand) -->
<div v-if="!isMobile" class="flex items-center gap-0.5">
<!-- Middle: nav links + GitHub star badge (left aligned, next to brand).
Always rendered so its width stays measurable; collapsed, it is
invisible, out of flow and out of the a11y tree / tab order. -->
<div ref="linksEl" class="flex w-max shrink-0 items-center gap-0.5"
:class="{ 'invisible absolute left-0 top-0 pointer-events-none': isNavCollapsed }"
:aria-hidden="isNavCollapsed ? 'true' : undefined" :inert="isNavCollapsed || undefined">
<template v-for="item in navItems" :key="item">
<!-- Advanced Tools: hover reveals the sub-tools, click scrolls to the
section (disable-click-trigger frees the click from toggling the
menu; viewport=false anchors the panel under the trigger). -->
<!-- Advanced Tools: hover reveals the sub-tools, click goes to the
section like the other links (disable-click-trigger frees the
click from toggling the menu; viewport=false anchors the panel
under the trigger). -->
<NavigationMenu v-if="item === 'AdvancedTools'" as="div" :viewport="false" :disable-click-trigger="true"
class="flex-none">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger :class="['h-auto bg-transparent', navLinkClass(item)]"
@click="scrollToSection('AdvancedTools'); trackEvent('Nav', 'NavClick', item)">
<NavigationMenuTrigger :class="['h-auto bg-transparent whitespace-nowrap', navLinkClass(item)]"
@click="goToSection(item)">
{{ t(`nav.${item}`) }}
</NavigationMenuTrigger>
<NavigationMenuContent class="z-50">
@@ -49,7 +52,7 @@
<span aria-hidden="true"
class="pointer-events-none absolute inset-y-1 left-1/2 w-px -translate-x-1/2 bg-border"></span>
<li v-for="tool in advancedTools" :key="tool.slug">
<NavigationMenuLink as-child class="cursor-pointer">
<NavigationMenuLink as-child class="cursor-pointer" :active="tool.slug === currentToolSlug">
<button type="button" class="w-full text-left leading-snug" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
@@ -60,9 +63,8 @@
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<!-- All other sections stay plain smooth-scroll anchors. -->
<a v-else href="#" :class="navLinkClass(item)"
@click.prevent="scrollToSection(item); trackEvent('Nav', 'NavClick', item)">
<!-- All other sections -->
<a v-else href="#" :class="[navLinkClass(item), 'whitespace-nowrap']" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
@@ -80,129 +82,22 @@
</div>
<!-- Right: Action area (ml-auto push to the right) -->
<div class="ml-auto flex items-center gap-2">
<div ref="actionsEl" class="ml-auto flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<!-- Earth Online entry (code name: pulse) -->
<Pulse />
<!-- Docs assistant entry point (ask box on desktop, icon on mobile) -->
<!-- Docs assistant entry point (ask box from xl up, icon below) -->
<DocsSearch />
<!-- Preferences — standalone cog only for Firebase-less self-hosted
instances (no user menu to host it). With the user system on,
preferences lives inside the user dropdown for every state. -->
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@click="OpenPreferences">
<Cog />
</Button>
</JnTooltip>
<!-- Sign In / User Dropdown -->
<DropdownMenu v-if="isFireBaseSet">
<DropdownMenuTrigger as-child>
<!-- Not signed in: the solid block reads as the "sign in"
call-to-action, and the menu opens on the sign-in options, so
the affordance is self-explaining one click deep. -->
<Button v-if="!isSignedIn" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1.5 cursor-pointer"
aria-label="User menu">
<UserRound class="size-5" />
<ChevronDown class="opacity-60" />
</Button>
<!-- Signed in: avatar + chevron -->
<Button v-else variant="ghost" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1 cursor-pointer"
aria-label="User menu">
<span class="inline-flex size-7 overflow-hidden rounded-full">
<img :src="userPhotoURL" :alt="userName" :title="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<ChevronDown class="opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 shadow-md">
<!-- Signed in -->
<template v-if="isSignedIn">
<div class="px-2 pt-2 pb-3">
<div class="flex items-center gap-3">
<span class="inline-flex size-10 overflow-hidden rounded-full shrink-0">
<img :src="userPhotoURL" :alt="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-sm font-semibold leading-none">{{ userName }}</span>
<span v-if="remoteUserInfoFetched && remoteUserInfo.userLevel">
<Badge :class="levelBadgeClass"
class="border-transparent text-[10px] font-medium px-1.5 py-0 h-4">
{{ t('user.Level.' + remoteUserInfo.userLevel) }}
</Badge>
</span>
<span v-else-if="!remoteUserInfoFetched" class="text-xs text-muted-foreground">{{
t('user.Fields.Fetching') }}</span>
</div>
</div>
<dl class="mt-3 space-y-1 text-xs">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.CreatedAt') }}</dt>
<dd class="font-medium">{{ userCreatedAt }}</dd>
</div>
<!-- How this account signs in. One account per email
address, so this is also the only way in. -->
<div v-if="linkedProviders.length" class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.SignInMethods') }}</dt>
<dd class="flex min-w-0 items-center gap-1.5 font-medium">
<span v-for="provider in linkedProviders" :key="provider.providerId"
class="inline-flex items-center gap-1" :title="provider.label">
<Icon v-if="provider.icon" :icon="provider.icon" class="size-3.5 shrink-0" />
<span>{{ provider.label }}</span>
</span>
</dd>
</div>
</dl>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerAchievements(true)">
<Award />
<span>{{ t('user.MyAchievements') }}</span>
</DropdownMenuItem>
</template>
<!-- Not signed in -->
<template v-else>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGoogle">
<Icon icon="ri:google-line" />
<span>{{ t('user.SignInWithGoogle') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGithub">
<Icon icon="ri:github-line" />
<span>{{ t('user.SignInWithGithub') }}</span>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="OpenPreferences">
<Cog />
<span>{{ t('nav.preferences.title') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerUserBenefits(true)">
<HeartHandshake />
<span>{{ t('user.Benefits.Title') }}</span>
</DropdownMenuItem>
<template v-if="isSignedIn">
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.signOut">
<LogOut />
<span>{{ t('user.SignOut') }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
<UserMenu />
</div>
</nav>
<!-- Mobile navigation drawer. Flex column so the link list scrolls instead
of clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<!-- Navigation sheet of the collapsed nav (a phone, or a viewport where the
links don't fit). Flex column so the link list scrolls instead of
clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isNavCollapsed" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h5 class="m-0 text-base font-semibold">{{ t('nav.Navigation') }}</h5>
@@ -224,16 +119,15 @@
<CollapsibleContent>
<div class="my-0.5 ml-3 flex flex-col gap-0.5 border-l pl-3">
<button v-for="tool in advancedTools" :key="tool.slug" type="button"
class="block w-full rounded-md px-3 py-1.5 text-left text-sm leading-snug text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
@click="openTool(tool.slug)">
class="block w-full rounded-md px-3 py-1.5 text-left text-sm leading-snug text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground aria-[current=page]:bg-accent/50 aria-[current=page]:text-foreground"
:aria-current="tool.slug === currentToolSlug ? 'page' : undefined" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
</div>
</CollapsibleContent>
</Collapsible>
<!-- All other sections stay plain smooth-scroll anchors. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })"
@click.prevent="scrollToSection(item); trackEvent('Nav', 'NavClick', item); store.setOpenSheet(null)">
<!-- All other sections, as in the expanded nav. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
@@ -256,12 +150,13 @@
</template>
<script setup>
// The site navigation bar, rendered once by App.vue above every route; what a
// section or tool entry does on the current route is utils/nav-target.js.
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { unixToDateTime } from '@/utils/time-utils';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import {
@@ -274,50 +169,62 @@ import {
} from '@/components/ui/navigation-menu';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { JnTooltip } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Award, ChevronDown, UserRound, HeartHandshake,
LogOut, Menu, Cog,
} from '@lucide/vue';
import { ChevronDown, Menu } from '@lucide/vue';
import DocsSearch from '@/components/widgets/DocsSearch.vue';
import Pulse from '@/components/widgets/Pulse.vue';
import UserMenu from '@/components/UserMenu.vue';
import { Icon } from '@iconify/vue';
import brandIcon from './svgicons/Brand.vue';
import { SECTION_IDS } from '@/data/sections';
import { ADVANCED_TOOLS } from '@/data/tools.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { listedTools } from '@/utils/tool-availability.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { formatStarCount } from '@/utils/format-star-count.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { useNavOverflow } from '@/composables/use-nav-overflow.js';
const { t, locale } = useI18n();
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const { navigateTo } = useNavTarget();
const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile);
const currentSection = computed(() => store.currentSection);
const loaded = computed(() => store.allHasLoaded);
// Running as an installed PWA (chromeless window). Distinct from the app's
// "standalone tool pages" — see utils/pwa.js.
// The link row collapses into the hamburger + Sheet on a phone, or wherever
// it doesn't fit beside the brand and actions. isMobile alone drives phone
// behaviour (scroll-hide, PWA background).
const NAV_GAP_PX = 8; // the nav's gap-2
const navEl = ref(null);
const brandEl = ref(null);
const linksEl = ref(null);
const actionsEl = ref(null);
const { overflow } = useNavOverflow({
nav: navEl, brand: brandEl, links: linksEl, actions: actionsEl, gap: NAV_GAP_PX,
});
const isNavCollapsed = computed(() => isMobile.value || overflow.value);
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
const isHome = computed(() => route.name === 'home');
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
const homeHref = router.resolve('/').href;
const loaded = computed(() => store.allHasLoaded);
// The brand shimmers while the homepage tests run.
const shimmer = computed(() => isHome.value && !loaded.value);
// Running as an installed PWA (chromeless window) — see utils/pwa.js.
const isPwa = isRunningAsPwa();
const navItems = SECTION_IDS;
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: gated tools
// stay hidden where the deployment lacks them. Reactive on configs.
// Gated tools stay hidden where the deployment lacks them, as in the card grid.
const configs = computed(() => store.configs);
const advancedTools = computed(() => ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)));
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
// Sheet: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// GitHub star count for the repo badge. Fetched from our own edge-cached
@@ -344,51 +251,20 @@ const navLinkClass = (item, { block = false } = {}) => {
return [base, state, block ? 'block' : ''].filter(Boolean).join(' ');
};
// Firebase / User
const isFireBaseSet = computed(() => store.isFireBaseSet);
const isSignedIn = computed(() => store.isSignedIn);
const userName = computed(() => store.user?.displayName);
const userPhotoURL = computed(() => store.user?.photoURL);
const userCreatedAt = computed(() => unixToDateTime(store.user?.metadata.createdAt, locale.value));
const remoteUserInfo = computed(() => store.remoteUserInfo);
const remoteUserInfoFetched = computed(() => store.remoteUserInfoFetched);
// Sign-in methods attached to this account.
const linkedProviders = computed(() => store.linkedProviders);
// Level Badge Color: mapped to semantic token, keep each level color distinction
const levelBadgeClass = computed(() => {
const level = remoteUserInfo.value?.userLevel;
switch (level) {
case 'Premium': return 'bg-action text-action-foreground';
case 'Owner': return 'bg-foreground text-background';
case 'Developer': return 'bg-success text-success-foreground';
case 'HonoraryMember': return 'bg-warning text-warning-foreground';
case 'Standard':
default: return 'bg-muted-foreground text-background';
}
});
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
store.setTriggerRemoteUserInfo(true);
};
const isNavMenuOpen = computed(() => store.openSheet === 'navMenu');
const onNavMenuChange = (val) => {
store.setOpenSheet(val ? 'navMenu' : null);
};
// Opens the Preferences sheet
const OpenPreferences = () => {
store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences');
};
// At top → full refresh; mid-page → smooth scroll up. preventDefault
// avoids the native instant-jump of <a href="#">.
// Off the homepage → home (a real link there, so modified clicks open a new
// tab). On it: at top → full refresh; mid-page → smooth scroll up.
// preventDefault avoids the native instant-jump of <a href="#">.
const handleLogoClick = (e) => {
if (window.scrollY === 0) {
if (!isHome.value) {
if (!isPlainClick(e)) return;
e.preventDefault();
router.push('/');
} else if (window.scrollY === 0) {
store.setRefreshEveryThing(true);
} else {
e.preventDefault();
@@ -397,29 +273,24 @@ const handleLogoClick = (e) => {
trackEvent('Nav', 'NavClick', 'Logo');
};
// Menu scroll (leave space for sticky header)
const scrollToSection = (el, offset = 70) => {
const element = typeof el === 'string' ? document.getElementById(el) : el;
if (!element) return;
const y = element.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: 'smooth' });
// Section and tool entries close the nav Sheet first and navigate once
// its slide-out is over: WebKit records the back/forward snapshot of a page
// as it navigates away, and a swipe back would show the sheet still open.
const SHEET_CLOSE_MS = 300; // ui/sheet data-[state=closed]:duration-300
const afterSheetClosed = (fn) => {
if (!isNavMenuOpen.value) return fn();
store.setOpenSheet(null);
setTimeout(fn, SHEET_CLOSE_MS);
};
const goToSection = (section) => {
afterSheetClosed(() => navigateTo({ section }));
trackEvent('Nav', 'NavClick', section);
};
// Open a tool from the nav: scroll to the Advanced Tools section, then raise the
// drawer (driven by the `?tool=` query Advanced.vue watches). Scrolls twice — the
// mobile nav Sheet locks body scroll until it closes, so the first scroll is a
// no-op there and the deferred one lands after the Sheet is gone.
let openToolTimer = null;
const openTool = (slug) => {
store.setOpenSheet(null); // close the mobile nav Sheet (no-op on desktop)
scrollToSection('AdvancedTools');
clearTimeout(openToolTimer);
openToolTimer = setTimeout(() => {
scrollToSection('AdvancedTools');
router.push({ path: '/', query: { tool: slug } });
}, 300);
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
afterSheetClosed(() => navigateTo({ tool: slug }));
trackEvent('Nav', 'NavClick', slug);
};
// Mobile: hide nav on scroll-down, show on scroll-up.
@@ -440,7 +311,7 @@ const onScroll = () => {
if (y <= SHOW_AT_TOP) {
isNavHidden.value = false;
} else if (Math.abs(dy) > SCROLL_DELTA) {
// Keep nav visible while the menu drawer is open so its close
// Keep nav visible while the menu sheet is open so its close
// affordance stays in place.
if (dy > 0 && !isNavMenuOpen.value) {
isNavHidden.value = true;
@@ -453,27 +324,25 @@ const onScroll = () => {
});
};
watch(isMobile, (mobile) => {
if (!mobile) {
isNavHidden.value = false;
window.removeEventListener('scroll', onScroll);
// Mobile only; attaching resyncs lastScrollY so the first scroll isn't a phantom jump.
const setScrollListener = (on) => {
if (on) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
} else {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
window.removeEventListener('scroll', onScroll);
}
}, { immediate: false });
};
onMounted(() => {
if (isMobile.value) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
}
fetchGithubStars();
});
watch(isMobile, (mobile) => {
if (!mobile) isNavHidden.value = false;
setScrollListener(mobile);
}, { immediate: true });
onMounted(fetchGithubStars);
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll);
clearTimeout(openToolTimer);
setScrollListener(false);
});
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<!-- Breadcrumb above the <h1> of every page but Home: ← Home / … / current page.
Markup and classes follow shadcn-vue's Breadcrumb (ui/ has no copy-in). -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
<!-- Home: back in history when Home is the previous entry, so it returns as left -->
<li class="inline-flex items-center gap-1.5">
<a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
@click="onHome">
<ArrowLeft class="size-3.5" aria-hidden="true" />
{{ t('nav.Home') }}
</a>
</li>
<template v-for="(crumb, index) in items" :key="index">
<li role="presentation" aria-hidden="true">/</li>
<li class="inline-flex min-w-0 items-center gap-1.5">
<a v-if="crumb.section" :href="homeHref" class="transition-colors hover:text-foreground"
@click="onSection($event, crumb.section)">{{ crumb.label }}</a>
<span v-else role="link" aria-disabled="true" aria-current="page"
class="font-normal text-foreground">{{ crumb.label }}</span>
</li>
</template>
</ol>
</nav>
</template>
<script setup>
// `items` are the localized crumbs after Home: `{ label, section }` links to a
// homepage section, reached as the Nav does; a bare `{ label }` is the current
// page. A trail may end on a section when the <h1> beside it names the page.
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ArrowLeft } from '@lucide/vue';
import { resolveBackTarget } from '@/utils/back-target.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { trackEvent } from '@/utils/analytics';
defineProps({
items: { type: Array, default: () => [] },
});
const { t } = useI18n();
const router = useRouter();
const { navigateTo } = useNavTarget();
const homeHref = router.resolve('/').href;
const onHome = (event) => {
if (!isPlainClick(event)) return;
event.preventDefault();
if (resolveBackTarget(window.history.state) === 'back') router.back();
else router.push('/');
trackEvent('Nav', 'BreadcrumbClick', 'Home');
};
const onSection = (event, section) => {
if (!isPlainClick(event)) return;
event.preventDefault();
navigateTo({ section });
trackEvent('Nav', 'BreadcrumbClick', section);
};
</script>
+5 -7
View File
@@ -1,17 +1,15 @@
<template>
<!-- Standalone privacy page at /privacy (shareable + crawlable) -->
<div class="flex min-h-screen flex-col">
<!-- Slim header (shared with the standalone tool pages). -->
<StandalonePageHeader :title="t('about.Privacy')" />
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
<PageBreadcrumb :items="[{ label: t('about.Privacy') }]" />
<!-- Content -->
<main class="flex-1">
<!-- Brief loading state while the locale pack is fetched on first paint. -->
<div v-if="!ready" class="flex justify-center py-20">
<Spinner />
</div>
<article v-else class="mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
<article v-else>
<h1 class="mb-1 text-2xl md:text-3xl font-semibold tracking-tight">{{ t('privacy.Title') }}</h1>
<p class="mb-6 text-xs text-muted-foreground">{{ t('privacy.UpdatedLabel') }}: {{ LAST_UPDATED }}</p>
@@ -49,7 +47,7 @@ import { isDocsConfigured } from '@/composables/use-docs-assistant.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
import Footer from '@/components/Footer.vue';
import StandalonePageHeader from '@/components/StandalonePageHeader.vue';
import PageBreadcrumb from '@/components/PageBreadcrumb.vue';
import { Spinner } from '@/components/ui/spinner';
const { t, tm, rt, locale, mergeLocaleMessage } = useI18n();
+30 -3
View File
@@ -157,6 +157,19 @@
{{ t('speedtest.quality.' + state.speedTest.rtcQuality) }}
</span>
</p>
<!-- Next step: the latency / route tools. Real links (modified
clicks stay the browser's); break-normal undoes the block's
break-all for prose. -->
<i18n-t keypath="speedtest.ToolHint" tag="p" scope="global" class="m-0 break-normal">
<template #ping>
<a href="/tools/pingtest" :class="toolLinkClass" @click="openTool($event, 'pingtest')">
{{ t('pingtest.Title') }}</a>
</template>
<template #mtr>
<a href="/tools/mtrtest" :class="toolLinkClass" @click="openTool($event, 'mtrtest')">
{{ t('mtrtest.Title') }}</a>
</template>
</i18n-t>
</span>
</div>
</div>
@@ -179,16 +192,18 @@
<script setup>
import { reactive, ref, computed, onMounted, markRaw, onUnmounted, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { isPlainClick } from '@/utils/nav-target.js';
import { emitAppEvent } from '@/utils/app-events.js';
import { useAppCommand } from '@/composables/use-app-command.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { isValidIP } from '@/utils/valid-ip.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { isValidIP } from '@/utils/ip/valid-ip.js';
import { parseTrace } from '@/utils/ip/parse-trace.js';
import getCountryName from '@/data/country-name.js';
import SpeedTestEngine from '@cloudflare/speedtest';
import { createSpeedTestSession, getSpeedTestLiveValues } from '@/utils/speedtest-session.js';
import { createSpeedTestSession, getSpeedTestLiveValues } from '@/utils/features/speedtest-session.js';
import useSpeedTestCharts from '@/composables/use-speedtest-charts.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
@@ -203,6 +218,18 @@ import {
import { Icon } from '@iconify/vue';
const { t } = useI18n();
const router = useRouter();
// Tool links in the result copy. A plain click routes in-app and sends
// ToolEntry (category = this section, label = slug), which is how GA tells
// this entrance apart from the card grid's NavClick.
const toolLinkClass = 'font-medium underline underline-offset-4 decoration-current/40 hover:decoration-current';
const openTool = (event, slug) => {
if (!isPlainClick(event)) return;
event.preventDefault();
trackEvent('SpeedTest', 'ToolEntry', slug);
router.push({ path: `/tools/${slug}` });
};
const store = useMainStore();
const lang = computed(() => store.lang);
const userPreferences = computed(() => store.userPreferences);
@@ -1,59 +0,0 @@
<template>
<!-- Slim sticky header shared by the standalone pages (/tools/:slug,
/privacy, /r/:id). Brand → home, an optional breadcrumb title, and a
back-to-home button. This is NOT the homepage nav (Nav.vue) — that one
carries the full navigation, user menu, mobile drawer, and hides on
scroll; this one stays pinned. It carries the iOS safe-area inset itself
(body.jn-standalone-page in index.html reserves nothing), so when sticky
pins it at the viewport top the row sits below the Dynamic Island and
the blurred background paints the status-bar strip. -->
<header class="sticky top-0 z-40 border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur pt-[env(safe-area-inset-top)]">
<div class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-4 h-14">
<RouterLink to="/"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-lg font-semibold text-foreground no-underline hover:opacity-80 transition-opacity"
aria-label="IPCheck.ing">
<brandIcon />
<span class="tracking-tight">
<span class="font-bold">IP</span><span class="font-extralight">Check.</span><span
class="font-extralight">ing</span>
</span>
</RouterLink>
<!-- Breadcrumb: " / <title>" — only when the page passes a title. -->
<template v-if="title">
<span class="text-muted-foreground" aria-hidden="true">/</span>
<span class="min-w-0 truncate font-medium">{{ title }}</span>
</template>
<!-- Button `as-child` renders the RouterLink as its root, so this is a
single <a> styled as a button — not a <button> nested in an <a>.
Icon-only below `sm`: brand (132px) + this button's label (up to
178px in pt-BR) + gaps + px-4 exceeded 320px, and since the title is
the only item that can shrink, it collapsed to 0 and the row still
overflowed — a dangling "/" plus a clipped button. `sr-only` (not
`hidden`) keeps the accessible name in every locale, and being out of
flow it costs no `gap-2`. -->
<Button as-child variant="default" size="icon" class="ml-auto shrink-0 cursor-pointer sm:w-auto sm:px-4">
<RouterLink to="/">
<ArrowLeft class="size-4" />
<span class="sr-only sm:not-sr-only">{{ t('advancedtools.BackToHome') }}</span>
</RouterLink>
</Button>
</div>
</header>
</template>
<script setup>
// Shared slim header for standalone pages. The breadcrumb label is passed in as
// `title` (already localized by the caller); everything else is fixed chrome.
import { useI18n } from 'vue-i18n';
import brandIcon from '@/components/svgicons/Brand.vue';
import { ArrowLeft } from '@lucide/vue';
import { Button } from '@/components/ui/button';
defineProps({
// Breadcrumb text shown after the brand (e.g. "🌐 MTR Test" or "Privacy").
// Empty string renders just the brand + back button.
title: { type: String, default: '' },
});
const { t } = useI18n();
</script>
-77
View File
@@ -1,77 +0,0 @@
<template>
<!-- Standalone page for a single advanced tool, reached at /tools/:slug
(new-window / shareable / crawlable). Renders the exact same tool
component the drawer does, just inside a minimal page chrome instead of
the homepage + drawer. -->
<div class="flex min-h-screen flex-col">
<!-- User system dialogs host (Benefits & Usage) — the sign-in gated tools
link to it from their quota hints, and it fetches the user's usage
snapshot so their frontend quota gate works here too. -->
<User />
<!-- Slim header: brand → home, current tool breadcrumb, back link. -->
<StandalonePageHeader :title="tool ? `${tool.emoji} ${t(tool.titleKey)}` : ''" />
<!-- Content: an <h1> for the tool (SEO), then the tool body itself -->
<main class="flex-1">
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
<h1 v-if="tool" class="mb-4 flex items-center gap-2 text-2xl md:text-3xl font-semibold tracking-tight">
<span aria-hidden="true">{{ tool.emoji }}</span>
{{ t(tool.titleKey) }}
</h1>
<component :is="toolComponent" v-if="toolComponent" />
</div>
</main>
<Footer />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import Footer from '@/components/Footer.vue';
import StandalonePageHeader from '@/components/StandalonePageHeader.vue';
import User from '@/components/User.vue';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
// noStandalone tools are blacklisted from this page entirely — they depend on
// homepage state, so the drawer on the homepage is their only home.
const registered = TOOL_BY_SLUG.get(route.params.slug) || null;
const tool = computed(() => (registered && !registered.noStandalone ? registered : null));
// The skeleton covers the chunk download — same treatment as the homepage
// drawer; `delay` keeps fast loads flash-free.
const toolComponent = computed(() => (tool.value
? defineAsyncComponent({
loader: tool.value.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
})
: null));
// Per-tool head: localized title + description, self-referential canonical.
useDocumentMeta(() => {
if (!tool.value) return {};
return {
title: `${t(tool.value.titleKey)} · IPCheck.ing`,
description: t(tool.value.noteKey),
canonical: `${window.location.origin}/tools/${tool.value.slug}`,
};
});
// Unknown slug → bounce to the homepage rather than show an empty shell; a
// blacklisted tool keeps its destination by reopening as the homepage drawer.
if (!tool.value) {
router.replace(registered?.noStandalone
? { path: '/', query: { tool: registered.slug } }
: '/');
}
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<!-- The page of one advanced tool, /tools/:slug. At least a viewport tall
below the fixed Nav, so the footer sits at the bottom of a short page. -->
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<main class="flex-1">
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
<PageBreadcrumb v-if="tool" :items="breadcrumb" />
<h1 v-if="tool" class="mb-4 flex items-center gap-2 text-2xl md:text-3xl font-semibold tracking-tight">
<span aria-hidden="true">{{ tool.emoji }}</span>
{{ t(tool.titleKey) }}
</h1>
<!-- One cached instance per visited slug -->
<KeepAlive :key="authEpoch" :max="8">
<component :is="toolComponent" v-if="toolComponent" :key="tool.slug" />
</KeepAlive>
</div>
</main>
<Footer />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import { shouldDropToolCache } from '@/utils/tool-cache.js';
import Footer from '@/components/Footer.vue';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
import PageBreadcrumb from '@/components/PageBreadcrumb.vue';
defineOptions({ name: 'ToolPage' });
// One async wrapper per slug, built once: a new wrapper would remount the tool
// and lose its cached state. `delay` keeps fast loads skeleton-free.
const asyncToolCache = new Map();
const asyncToolFor = (tool) => {
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent({
loader: tool.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
}));
}
return asyncToolCache.get(tool.slug);
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useMainStore();
// Held while another route shows: the cached page keeps rendering its tool.
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
const slug = computed((previous) => routeSlug.value ?? previous ?? null);
const tool = computed(() => TOOL_BY_SLUG.get(slug.value) || null);
const breadcrumb = computed(() => [
{ label: t('advancedtools.Title'), section: 'AdvancedTools' },
]);
const toolComponent = computed(() => (tool.value ? asyncToolFor(tool.value) : null));
// Per-tool head: localized title + description, self-referential canonical.
useDocumentMeta(() => {
if (!tool.value) return {};
return {
title: `${t(tool.value.titleKey)} · IPCheck.ing`,
description: t(tool.value.noteKey),
canonical: `${window.location.origin}/tools/${tool.value.slug}`,
};
});
// Unknown slug → homepage, on every arrival (a cached instance included).
watch(routeSlug, (next) => {
if (next && !tool.value) router.replace('/');
}, { immediate: true });
// Re-keys the KeepAlive when the account goes away (utils/tool-cache.js), so
// a sign-out or account switch drops every cached tool.
const authEpoch = ref(0);
watch(() => store.user?.uid ?? null, (nextUid, prevUid) => {
if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1;
});
</script>
+13 -16
View File
@@ -123,9 +123,8 @@ const store = useMainStore();
const route = useRoute();
const { dotClass } = useStatusTone();
// Mounted on a /tools/:slug standalone page (vs the homepage) — these pages
// skip the homepage loading pipeline, which changes when user info can load.
const isStandalonePage = computed(() => route.path.startsWith('/tools/'));
// `matched` is empty until the first navigation resolves the route.
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
const isSignedIn = computed(() => store.isSignedIn);
const remoteUserInfo = computed(() => store.remoteUserInfo);
@@ -167,6 +166,7 @@ const USAGE_LABEL_KEYS = {
invisibility_test: 'invisibilitytest.Title',
dns_leak_test: 'enhanceddnsleaktest.Title',
persona_check: 'personacheck.Title',
ip_hitlist: 'ipblocklist.Title',
};
// Rows for the usage dialog. Empty until the (re)fetched user info lands or
@@ -248,25 +248,22 @@ const updateUserAchievement = async (achievementName) => {
}
};
watch(() => store.allHasLoaded, (newVal) => {
if (newVal) getUserInfo();
});
// The unknown-auth-hint boot resolves sign-in AFTER allHasLoaded (background
// probe in main.js), so the watcher above already ran and skipped; retry when
// the signed-in state lands. getUserInfo self-guards against double fetches.
// Standalone tool pages never run the homepage loading pipeline (allHasLoaded
// stays false there), so sign-in alone is enough to fetch; immediate covers
// auth having resolved before this component mounted.
watch(isSignedIn, (signed) => {
if (signed && (store.allHasLoaded || isStandalonePage.value)) getUserInfo();
// User info loads once signed in and, on Home, after its tests (allHasLoaded);
// other pages never run them. getUserInfo self-guards against double fetches.
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
if (ready) getUserInfo();
}, { immediate: true });
watch(() => triggerUserBenefits.value, (newVal) => {
if (newVal) openUserBenefits();
});
// One-shot trigger: cleared here so the next request from Nav / Achievements
// A page change closes the dialog.
watch(() => route.path, () => {
isOpen.value = false;
});
// One-shot trigger: cleared here so the next request from UserMenu / Achievements
// is a fresh false → true edge rather than a no-op write.
watch(() => triggerRemoteUserInfo.value, (newVal) => {
if (!newVal) return;
+184
View File
@@ -0,0 +1,184 @@
<template>
<!-- Account menu at the right of the site Nav: a preferences cog on a
Firebase-less instance, the user dropdown otherwise. -->
<!-- Firebase-less: standalone preferences cog. -->
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@click="openPreferences">
<Cog />
</Button>
</JnTooltip>
<!-- Sign In / User Dropdown -->
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<!-- Not signed in: the solid block reads as the "sign in"
call-to-action, and the menu opens on the sign-in options, so
the affordance is self-explaining one click deep. -->
<Button v-if="!isSignedIn" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1.5 cursor-pointer"
aria-label="User menu">
<UserRound class="size-5" />
<ChevronDown class="opacity-60" />
</Button>
<!-- Signed in: avatar + chevron -->
<Button v-else variant="ghost" size="sm" @click="getUserInfo" class="h-8 gap-1 px-1 cursor-pointer"
aria-label="User menu">
<span class="inline-flex size-7 overflow-hidden rounded-full">
<img :src="userPhotoURL" :alt="userName" :title="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<ChevronDown class="opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 shadow-md">
<!-- Signed in: account card + achievements -->
<template v-if="isSignedIn">
<div class="px-2 pt-2 pb-3">
<div class="flex items-center gap-3">
<span class="inline-flex size-10 overflow-hidden rounded-full shrink-0">
<img :src="userPhotoURL" :alt="userName" class="size-full object-cover"
referrerpolicy="no-referrer">
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-sm font-semibold leading-none">{{ userName }}</span>
<span v-if="remoteUserInfoFetched && remoteUserInfo.userLevel">
<Badge :class="levelBadgeClass"
class="border-transparent text-[10px] font-medium px-1.5 py-0 h-4">
{{ t('user.Level.' + remoteUserInfo.userLevel) }}
</Badge>
</span>
<span v-else-if="!remoteUserInfoFetched" class="text-xs text-muted-foreground">{{
t('user.Fields.Fetching') }}</span>
</div>
</div>
<dl class="mt-3 space-y-1 text-xs">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.CreatedAt') }}</dt>
<dd class="font-medium">{{ userCreatedAt }}</dd>
</div>
<!-- How this account signs in. One account per email
address, so this is also the only way in. -->
<div v-if="linkedProviders.length" class="flex items-baseline justify-between gap-2">
<dt class="text-muted-foreground">{{ t('user.Fields.SignInMethods') }}</dt>
<dd class="flex min-w-0 items-center gap-1.5 font-medium">
<span v-for="provider in linkedProviders" :key="provider.providerId"
class="inline-flex items-center gap-1" :title="provider.label">
<Icon v-if="provider.icon" :icon="provider.icon" class="size-3.5 shrink-0" />
<span>{{ provider.label }}</span>
</span>
</dd>
</div>
</dl>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="openAchievements">
<Award />
<span>{{ t('user.MyAchievements') }}</span>
</DropdownMenuItem>
</template>
<!-- Not signed in: sign-in providers -->
<template v-else>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGoogle">
<Icon icon="ri:google-line" />
<span>{{ t('user.SignInWithGoogle') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.signInWithGithub">
<Icon icon="ri:github-line" />
<span>{{ t('user.SignInWithGithub') }}</span>
</DropdownMenuItem>
</template>
<!-- Every state: preferences + benefits & usage -->
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="openPreferences">
<Cog />
<span>{{ t('nav.preferences.title') }}</span>
</DropdownMenuItem>
<DropdownMenuItem class="cursor-pointer" @select="store.setTriggerUserBenefits(true)">
<HeartHandshake />
<span>{{ t('user.Benefits.Title') }}</span>
</DropdownMenuItem>
<template v-if="isSignedIn">
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @select="store.signOut">
<LogOut />
<span>{{ t('user.SignOut') }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
// What the menu opens is raised through store triggers (Preferences / User
// live in App.vue, Achievements on Home).
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { unixToDateTime } from '@/utils/time-utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { JnTooltip } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Award, ChevronDown, Cog, HeartHandshake, LogOut, UserRound } from '@lucide/vue';
import { Icon } from '@iconify/vue';
const { t, locale } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
// Firebase / User
const isFireBaseSet = computed(() => store.isFireBaseSet);
const isSignedIn = computed(() => store.isSignedIn);
const userName = computed(() => store.user?.displayName);
const userPhotoURL = computed(() => store.user?.photoURL);
const userCreatedAt = computed(() => unixToDateTime(store.user?.metadata?.createdAt, locale.value));
const remoteUserInfo = computed(() => store.remoteUserInfo);
const remoteUserInfoFetched = computed(() => store.remoteUserInfoFetched);
// Sign-in methods attached to this account.
const linkedProviders = computed(() => store.linkedProviders);
// Level Badge Color: mapped to semantic token, keep each level color distinction
const levelBadgeClass = computed(() => {
const level = remoteUserInfo.value?.userLevel;
switch (level) {
case 'Premium': return 'bg-action text-action-foreground';
case 'Owner': return 'bg-foreground text-background';
case 'Developer': return 'bg-success text-success-foreground';
case 'HonoraryMember': return 'bg-warning text-warning-foreground';
case 'Standard':
default: return 'bg-muted-foreground text-background';
}
});
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
store.setTriggerRemoteUserInfo(true);
};
const openPreferences = () => {
store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences');
};
// Achievements live on Home only, so go there first.
const openAchievements = async () => {
if (route.name !== 'home') {
const failure = await router.push('/');
if (failure) return;
}
store.setTriggerAchievements(true);
};
</script>
@@ -0,0 +1,380 @@
<template>
<div class="adblock-test-section my-4 space-y-4">
<!-- Top note: what it tests, how to read it, what leaves the browser -->
<div class="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<p>{{ t('adblocktest.Note') }}</p>
<p>{{ t('adblocktest.Note2') }}</p>
<p>{{ t('adblocktest.Note3') }}</p>
</div>
<!-- Probe picker (left) + results (right), GlobalLatencyTest's layout -->
<Card>
<CardContent class="p-0">
<div class="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x">
<!-- Left: tally + bulk actions on top (sticky, as in the
Globalping picker), then one section per group whose
header doubles as select-all. Once results exist the
column matches their height. -->
<div class="col-span-1" :class="hasResults ? 'md:relative md:min-h-96' : ''">
<div class="max-h-72 overflow-y-auto"
:class="hasResults ? 'md:max-h-none md:absolute md:inset-0' : 'md:max-h-128'">
<div class="px-4 py-3 space-y-4">
<div
class="sticky top-0 z-10 -mt-3 pt-3 bg-card pb-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
{{ t('adblocktest.Selected') }}
<span class="tabular-nums">{{ selectedTargetCount }}</span>
</span>
<span class="flex items-center gap-3">
<button type="button" class="cursor-pointer hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="isRunning" @click="selectAll">
{{ t('adblocktest.SelectAll') }}
</button>
<button type="button" class="cursor-pointer hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="isRunning" @click="invert">
{{ t('adblocktest.Invert') }}
</button>
</span>
</div>
<div v-for="group in ADBLOCK_PROBE_GROUPS" :key="group.id" class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium cursor-pointer">
<Checkbox :model-value="groupState(group)" :disabled="isRunning"
@update:model-value="(v) => toggleGroup(group, v)">
<Minus v-if="groupState(group) === 'indeterminate'" class="size-3" />
<Check v-else class="size-3.5" />
</Checkbox>
{{ t(`adblocktest.group.${group.id}.title`) }}
</label>
<div class="flex flex-col gap-1.5 pl-6">
<label v-for="vendor in group.vendors" :key="vendor.id"
class="flex items-center gap-2 text-sm min-w-0"
:class="isRunning ? 'opacity-50' : 'cursor-pointer'">
<Checkbox :model-value="isSelected(group, vendor)" :disabled="isRunning"
@update:model-value="(v) => setVendor(group, vendor, v)" />
<span class="truncate">{{ vendorName(group, vendor) }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Right: toolbar (hint + run / run again), then results -->
<div class="col-span-3 min-w-0 flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-b">
<span class="text-sm text-muted-foreground">{{ t('adblocktest.Hint') }}</span>
<Button variant="action" :disabled="!canRun" class="cursor-pointer ml-auto" @click="startTest">
<Spinner v-if="isRunning" />
<RotateCw v-else-if="hasFinished" class="size-4 shrink-0" />
<Play v-else class="size-4 shrink-0" />
{{ t(hasFinished ? 'adblocktest.Rerun' : 'adblocktest.Run') }}
</Button>
</div>
<div v-if="hasResults" class="p-4 space-y-5">
<!-- Summary: score, counts, where the blocking happens -->
<div class="space-y-3">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="rounded-md bg-muted/50 px-3 py-2">
<div class="text-xs text-muted-foreground">{{ t('adblocktest.Score') }}</div>
<div class="text-2xl font-semibold tabular-nums">
{{ requestTally.score ?? '—' }}<span class="text-base">%</span>
</div>
</div>
<div v-for="key in STATUS_KEYS" :key="key" class="rounded-md bg-muted/50 px-3 py-2">
<div class="flex items-center gap-1.5 text-xs" :class="textClass(statusTone(key))">
<component :is="STATUS_ICON[key]" class="size-3.5 shrink-0" />
{{ t(`adblocktest.count.${key}`) }}
</div>
<div class="text-2xl font-semibold tabular-nums"
:class="requestTally[key] > 0 ? textClass(statusTone(key)) : 'text-muted-foreground'">
{{ requestTally[key] }}
</div>
</div>
</div>
<p v-if="isRunning" class="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner class="size-4" />
{{ t('adblocktest.Progress', { done: answeredCount, total: runPlan.length }) }}
</p>
<Alert v-else-if="layer">
<Info />
<AlertDescription>{{ t(`adblocktest.layer.${layer}`) }}</AlertDescription>
</Alert>
</div>
<!-- Filter tags -->
<ToggleGroup :model-value="filter" type="single" variant="outline" :spacing="2"
class="w-full flex-wrap justify-start"
@update:model-value="(v) => v && (filter = v)">
<ToggleGroupItem v-for="f in FILTERS" :key="f" :value="f" :class="tagClass">
{{ t(`adblocktest.filter.${f}`) }}
</ToggleGroupItem>
</ToggleGroup>
<!-- One block per tested group -->
<section v-for="group in visibleGroups" :key="group.id" class="space-y-2">
<header class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
<h3 class="text-sm font-semibold m-0">{{ t(`adblocktest.group.${group.id}.title`) }}</h3>
<span class="text-xs text-muted-foreground tabular-nums">
{{ t('adblocktest.GroupCount', { blocked: group.counts.blocked, total: group.counts.total }) }}
</span>
<p class="basis-full text-xs text-muted-foreground m-0">
{{ t(`adblocktest.group.${group.id}.desc`) }}
</p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2">
<div v-for="vendor in group.vendors" :key="vendor.key"
class="rounded-lg border bg-card min-w-0">
<!-- Tile header: name + a count per status (no blended
color); toggles the per-address list -->
<button type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm cursor-pointer"
:aria-expanded="isExpanded(vendor)"
@click="toggleExpanded(vendor)">
<span class="truncate flex-1 font-medium">{{ vendor.name }}</span>
<Spinner v-if="isRunning && vendor.counts.pending > 0"
class="size-3.5 text-muted-foreground" />
<template v-for="key in STATUS_KEYS" :key="key">
<span v-if="vendor.counts[key] > 0"
class="flex items-center gap-0.5 text-xs tabular-nums shrink-0"
:class="textClass(statusTone(key))"
:title="t(`adblocktest.status.${key}`)">
<component :is="STATUS_ICON[key]" class="size-3.5" />
{{ vendor.counts[key] }}
</span>
</template>
<ChevronDown class="size-3.5 shrink-0 text-muted-foreground transition-transform"
:class="isExpanded(vendor) ? 'rotate-180' : ''" />
</button>
<ul v-if="isExpanded(vendor)" class="border-t px-3 py-2 space-y-1">
<li v-for="item in vendor.items" :key="item.id"
class="flex items-center gap-2 text-xs min-w-0"
:title="item.url ? `${item.url} · ${t(`adblocktest.status.${statusOf(item)}`)}` : ''">
<Spinner v-if="statusOf(item) === 'pending'" class="size-3.5 text-muted-foreground" />
<component v-else :is="STATUS_ICON[statusOf(item)]" class="size-3.5 shrink-0"
:class="textClass(statusTone(statusOf(item)))" />
<span v-if="item.kind === 'cosmetic'">
{{ t(`adblocktest.cosmeticStatus.${statusOf(item)}`) }}
</span>
<span v-else class="font-mono truncate">{{ item.target }}</span>
</li>
</ul>
</div>
</div>
</section>
<p v-if="!visibleGroups.length" class="text-sm text-muted-foreground text-center py-4">
{{ t(filter === 'blocked' ? 'adblocktest.NothingBlocked' : 'adblocktest.NothingOpen') }}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Probe list version + what the probes cannot tell -->
<div v-if="hasResults" class="text-xs text-muted-foreground m-0 space-y-1">
<p class="text-xs text-muted-foreground m-0">
{{ t('adblocktest.Version', { version: ADBLOCK_PROBES_VERSION }) }}
</p>
<p class="text-xs text-muted-foreground mt-1">{{ t('adblocktest.Limitation') }}</p>
</div>
<!-- Cosmetic baits live here during a run, off-screen but laid out -->
<div ref="baitHost" aria-hidden="true" class="pointer-events-none fixed left-[-10000px] top-0 w-24"></div>
</div>
</template>
<script setup>
// Ad & Tracker Blocking Test (/tools/adblocktest): checks what the visitor's
// blocker — extension, browser, DNS service, VPN or router — lets through.
// Nothing runs on open; every probe group starts selected. Pure logic lives
// in utils/features/adblock-test.js, the probe list in data/adblock-probes.js.
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { ADBLOCK_PROBE_GROUPS, ADBLOCK_PROBES_VERSION } from '@/data/adblock-probes.js';
import {
COSMETIC_BAITS, COSMETIC_POLL_MS, COSMETIC_WAIT_MS,
allVendorKeys, blockingLayer, buildPlan, invertSelection, isBaitHidden,
probeLimits, probeRequest, runWithConcurrency, statusTone, tally, vendorKey,
} from '@/utils/features/adblock-test.js';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Spinner } from '@/components/ui/spinner';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Ban, Check, ChevronDown, CircleArrowRight, CircleQuestionMark, Info, Minus, Play, RotateCw,
} from '@lucide/vue';
const { t } = useI18n();
const { textClass } = useStatusTone();
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
const FILTERS = ['all', 'open', 'blocked'];
// Answered statuses, in display order (tiles, tile counts).
const STATUS_KEYS = ['blocked', 'open', 'unknown'];
const STATUS_ICON = { blocked: Ban, open: CircleArrowRight, unknown: CircleQuestionMark };
/* ---------- Selection (left column) ---------- */
const ALL_KEYS = allVendorKeys(ADBLOCK_PROBE_GROUPS);
const selected = ref(new Set(ALL_KEYS));
const isSelected = (group, vendor) => selected.value.has(vendorKey(group.id, vendor.id));
const setVendor = (group, vendor, checked) => {
const next = new Set(selected.value);
if (checked) next.add(vendorKey(group.id, vendor.id));
else next.delete(vendorKey(group.id, vendor.id));
selected.value = next;
};
const groupState = (group) => {
const on = group.vendors.filter((v) => isSelected(group, v)).length;
if (on === group.vendors.length) return true;
return on > 0 ? 'indeterminate' : false;
};
const toggleGroup = (group, checked) => {
const next = new Set(selected.value);
for (const v of group.vendors) {
if (checked === true) next.add(vendorKey(group.id, v.id));
else next.delete(vendorKey(group.id, v.id));
}
selected.value = next;
};
const selectAll = () => { selected.value = new Set(ALL_KEYS); };
const invert = () => { selected.value = new Set(invertSelection(ALL_KEYS, selected.value)); };
const vendorName = (group, vendor) => (group.kind === 'cosmetic'
? t(`adblocktest.cosmetic.${vendor.id}`)
: vendor.name);
const selectedTargetCount = computed(() => buildPlan(ADBLOCK_PROBE_GROUPS, selected.value).length);
/* ---------- Run state ---------- */
// 'idle' | 'running' | 'finished'
const status = ref('idle');
const isRunning = computed(() => status.value === 'running');
const canRun = computed(() => !isRunning.value && selectedTargetCount.value > 0);
const runPlan = ref([]);
// Set by the first completed run; the run button then reads "run again".
const hasFinished = ref(false);
const statuses = ref({}); // plan item id → probe status
const hasResults = computed(() => runPlan.value.length > 0);
const statusOf = (item) => statuses.value[item.id] ?? 'pending';
const requestItems = computed(() => runPlan.value.filter((p) => p.kind !== 'cosmetic'));
const cosmeticItems = computed(() => runPlan.value.filter((p) => p.kind === 'cosmetic'));
const requestTally = computed(() => tally(requestItems.value.map(statusOf)));
const cosmeticTally = computed(() => tally(cosmeticItems.value.map(statusOf)));
const answeredCount = computed(() => runPlan.value.length
- requestTally.value.pending - cosmeticTally.value.pending);
const layer = computed(() => (status.value === 'finished'
? blockingLayer({ requests: requestTally.value, cosmetic: cosmeticTally.value })
: null));
/* ---------- Results (right column) ---------- */
const filter = ref('all');
const expanded = ref(new Set());
const isExpanded = (vendor) => filter.value !== 'all' || expanded.value.has(vendor.key);
const toggleExpanded = (vendor) => {
const next = new Set(expanded.value);
if (next.has(vendor.key)) next.delete(vendor.key);
else next.add(vendor.key);
expanded.value = next;
};
// Groups → vendors → items of the last run, with counts; the "open" /
// "blocked" filters keep only items of that status (and drop what is left empty).
const visibleGroups = computed(() => ADBLOCK_PROBE_GROUPS.map((group) => {
const vendors = group.vendors.map((vendor) => {
const all = runPlan.value.filter((p) => p.groupId === group.id && p.vendorId === vendor.id);
const items = filter.value === 'all' ? all : all.filter((p) => statusOf(p) === filter.value);
return {
key: vendorKey(group.id, vendor.id),
name: vendorName(group, vendor),
items,
counts: tally(all.map(statusOf)),
};
}).filter((v) => v.items.length > 0);
const all = runPlan.value.filter((p) => p.groupId === group.id);
return { id: group.id, vendors, counts: tally(all.map(statusOf)) };
}).filter((g) => g.vendors.length > 0));
/* ---------- Probes ---------- */
const baitHost = ref(null);
// Place every selected bait, then watch until each is hidden or the wait
// runs out. A host no longer in the document (the page was left mid-run)
// proves nothing, so its baits read unknown.
const runCosmetic = async (items) => {
const host = baitHost.value;
if (!items.length || !host) return;
const baits = items.map((item) => {
const spec = COSMETIC_BAITS[item.target];
const el = document.createElement(spec.tag);
el.className = spec.className;
for (const [k, v] of Object.entries(spec.attrs)) el.setAttribute(k, v);
el.style.cssText = 'display:block;width:80px;height:20px;';
el.innerHTML = '&nbsp;';
host.appendChild(el);
return { item, el };
});
const hidden = (el) => {
const style = getComputedStyle(el);
return isBaitHidden({
connected: el.isConnected,
offsetHeight: el.offsetHeight,
display: style.display,
visibility: style.visibility,
});
};
const started = Date.now();
let pending = baits;
while (pending.length && Date.now() - started < COSMETIC_WAIT_MS) {
await new Promise((resolve) => setTimeout(resolve, COSMETIC_POLL_MS));
if (!host.isConnected) break;
pending = pending.filter(({ item, el }) => {
if (!hidden(el)) return true;
statuses.value[item.id] = 'blocked';
return false;
});
}
for (const { item } of pending) statuses.value[item.id] = host.isConnected ? 'open' : 'unknown';
host.replaceChildren();
};
const startTest = async () => {
if (!canRun.value) return;
trackEvent('Section', 'StartClick', 'AdblockTest');
status.value = 'running';
filter.value = 'all';
expanded.value = new Set();
const plan = buildPlan(ADBLOCK_PROBE_GROUPS, selected.value);
runPlan.value = plan;
statuses.value = {};
const { timeoutMs, concurrency } = probeLimits(navigator.userAgent, navigator.platform);
const tasks = plan.filter((p) => p.kind !== 'cosmetic').map((item) => async () => {
statuses.value[item.id] = await probeRequest(item.url, { timeoutMs });
});
await Promise.all([
runCosmetic(plan.filter((p) => p.kind === 'cosmetic')),
runWithConcurrency(tasks, concurrency),
]);
status.value = 'finished';
hasFinished.value = true;
// Domain event: per-address outcome for the report collector.
emitAppEvent('adblocktest:finished', {
items: plan.map((p) => ({ groupId: p.groupId, kind: p.kind, status: statusOf(p) })),
});
};
</script>
@@ -1,6 +1,6 @@
<!-- ASN Profile (advanced tool, slug `asn`): everything known about one AS
number, from one request to /api/asn-profile (the backend gathers every
source and answers once; see common/asn-profile.js). The page shows one
source and answers once; see server/asn-profile.js). The page shows one
loading state, then renders whole; a section whose source had nothing or
failed is simply left out.
Order: hero (identity, key facts, registration) beside a compact
@@ -10,7 +10,7 @@
One source per concept: relationships = local CAIDA (graph, counts and
lists alike); prefixes, addresses and RPKI = the pfx2as list; identity =
Radar → RDAP → ASRank. The query rides the URL as `?q=` (shareable,
written back on every run). Shaping lives in utils/asn-profile-view.js. -->
written back on every run). Shaping lives in utils/ip/asn-profile-view.js. -->
<template>
<div class="my-4 space-y-4">
<!-- Top note -->
@@ -330,10 +330,10 @@
</div>
<!-- Upstream topology: the IP-card component on its cache
contract, borderless (this card draws the border) and
without its drawer (the page is a drawer already). -->
contract, borderless (this card draws the border), with
its expand-to-drawer for the full-width graph. -->
<ASNConnectivity v-if="hasTopology(graph)" :asn="String(profileAsn)"
:asnConnectivityInfos="connectivityInfos" :expandable="false" :bordered="false" />
:asnConnectivityInfos="connectivityInfos" :expandable="true" :bordered="false" />
<div v-if="graph" class="grid gap-4 md:grid-cols-3">
<div v-for="group in groups" :key="group.kind" class="min-w-0">
@@ -428,9 +428,10 @@ import { Icon } from '@iconify/vue';
import { ChevronRight, Download, ExternalLink, Globe, Search, ShieldAlert, ShieldCheck, ShieldX } from '@lucide/vue';
import { trackEvent } from '@/utils/analytics';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { useActiveValue } from '@/composables/use-route-active.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { parseAsnInput } from '@/utils/asn-input.js';
import { buildTrafficPairs, pickConnectionQuality } from '@/utils/asn-metrics.js';
import { parseAsnInput } from '@/utils/ip/asn-input.js';
import { buildTrafficPairs, pickConnectionQuality } from '@/utils/ip/asn-metrics.js';
import {
asnProfileUrl, ASN_PROFILE_TIMEOUT_MS, sectionData, isProfileEmpty, failedSources,
hasTopology, isTier1Origin, heroIdentity, registrationFields, keyFacts, coneAsns,
@@ -438,7 +439,7 @@ import {
countryShareRows, neighbourGroups,
BASELINE_POSITION, reputationMeters, proxyListedCount, verdictLevel, VERDICT_TONE,
peeringEnumKey, peeringHero, peeringFacts, formatPortSpeed, exchangeRows, facilityRows, hasPeeringCard, visibleRows,
} from '@/utils/asn-profile-view.js';
} from '@/utils/ip/asn-profile-view.js';
import { formatIsoDate } from '@/utils/time-utils.js';
import getCountryName from '@/data/country-name.js';
import AsnConnectionQuality from '@/components/ip-infos/AsnConnectionQuality.vue';
@@ -535,10 +536,10 @@ const onSubmit = () => {
if (query.value.trim()) start(query.value);
};
// `?q=` on mount, and any later change while the drawer stays open (an ASN
// link followed from inside it, history navigation). Our own write-back
// equals `query` already, so it doesn't run twice.
watch(() => route.query.q, (q) => {
// Shareable `?q=` (use-route-active.js), including an ASN link followed from
// inside the page; our own write-back equals `query`, so it doesn't run twice.
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q !== query.value) {
query.value = q;
start(q, { track: false });
@@ -186,7 +186,7 @@ import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { useGlobalpingMeasurement } from '@/composables/use-globalping-measurement';
import GlobalpingCountryPicker from './GlobalpingCountryPicker.vue';
import { isValidDomain } from '@/utils/valid-ip.js';
import { isValidDomain } from '@/utils/ip/valid-ip.js';
import getCountryName from '@/data/country-name.js';
import { formatIsoDate } from '@/utils/time-utils.js';
import { Input } from '@/components/ui/input';
@@ -213,7 +213,7 @@ const MAX_TEST_COUNTRIES = 15;
const PROBES_PER_COUNTRY = 2;
// OONI tier / blocking-method display maps (tiers computed by the backend,
// see common/ooni-blocking.js).
// see server/ooni-blocking.js).
const TIER_META = {
confirmed: { labelKey: 'TierConfirmed', badgeClass: 'bg-destructive/15 text-destructive border-transparent' },
likely: { labelKey: 'TierLikely', badgeClass: 'bg-warning/15 text-warning border-transparent' },
@@ -110,8 +110,8 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Icon } from '@iconify/vue';
import { trackEvent } from '@/utils/analytics';
import { isValidDomain } from '@/utils/valid-ip.js';
import { DNS_RECORD_TYPES } from '@/utils/dns-record-types.js';
import { isValidDomain } from '@/utils/ip/valid-ip.js';
import { DNS_RECORD_TYPES } from '@/utils/features/dns-record-types.js';
import getCountryName from '@/data/country-name.js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -33,7 +33,7 @@
<span>
{{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span>
</div>
@@ -266,7 +266,6 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -294,18 +293,8 @@ import { Play, Check, X, Hourglass, Info, Server, Hash, Route, MapPin, ShieldChe
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const lang = computed(() => store.lang);
// Open the Benefits & Usage dialog. Inside the Advanced Tools drawer the
// dialog would stack on top of it — close the drawer first (it's driven by
// the ?tool query; standalone pages don't carry it, nothing to close).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
// NS-capture session config. Token + probe FQDNs are generated client-side;
// the NS passively captures any {nonce}.{token}.{DOMAIN} that shows up.
const PROBE_DOMAIN = 'leak.ipcheck.ing';
@@ -6,9 +6,7 @@
<p v-if="!isMobile">{{ t('pingtest.Note2') }}</p>
</div>
<!-- Input area. With stored IPs (homepage drawer) the user can pick one
OR switch to manual entry; on the standalone page allIPs is empty,
so it's manual entry only. -->
<!-- Input area -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium">
@@ -185,7 +183,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Icon } from '@iconify/vue';
import { Info, Play } from '@lucide/vue';
import { renderWorldMapChart } from '@/utils/world-map-chart.js';
import { renderWorldMapChart } from '@/utils/features/world-map-chart.js';
const { t } = useI18n();
@@ -207,9 +205,8 @@ const pickerSections = computed(() => [
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
]);
// Manual entry is forced when there are no stored IPs (the standalone page,
// where the homepage never ran). When stored IPs exist, the "use stored IP"
// switch (on by default) toggles between the dropdown and manual entry.
// Manual entry is forced when no IPs are stored (the homepage never ran);
// otherwise the switch (on by default) picks between dropdown and typing.
const useStored = ref(true);
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
const manualIP = ref('');
@@ -47,7 +47,7 @@
import { ref, computed, onMounted } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { getProbeInventory } from '@/utils/globalping-probes.js';
import { getProbeInventory } from '@/utils/features/globalping-probes.js';
import getCountryName from '@/data/country-name.js';
import { Checkbox } from '@/components/ui/checkbox';
import { Icon } from '@iconify/vue';
@@ -43,7 +43,7 @@
<span>
{{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span>
</div>
</div>
@@ -108,7 +108,7 @@
</Card>
</Transition>
<!-- Detailed list: 14 items, divide-y list -->
<!-- Detailed list: one row per detection item, divide-y list -->
<Transition name="fade-slide">
<Card>
<CardContent class="p-0">
@@ -156,6 +156,16 @@
{{ t('invisibilitytest.latencyVSWS.fromWS') }}<strong>{{ item.extras.ws
}}ms</strong>
</template>
<template v-else-if="item.key === 'tlsRtt'">
{{ t('invisibilitytest.tlsRtt.fromTCP') }}<strong>{{
item.extras.tcp }}ms</strong>,
{{ t('invisibilitytest.tlsRtt.fromTLS') }}<strong>{{ item.extras.tls
}}ms</strong>
</template>
<template v-else-if="item.key === 'proxyAi'">
{{ t('invisibilitytest.proxyAi.score') }}<strong>{{ item.extras.score
}}</strong>
</template>
<template v-else-if="item.key === 'webrtc'">
{{ t('invisibilitytest.webrtc.ipsAre') }}
<strong class="font-mono">{{ item.extras.ips.join(', ') }}</strong>
@@ -174,7 +184,6 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -188,16 +197,6 @@ import { CircleCheck, CircleX, Hourglass, Info, ListChecks, Lock, Shield, Play }
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
// Open the Benefits & Usage dialog. Inside the Advanced Tools drawer the
// dialog would stack on top of it — close the drawer first (it's driven by
// the ?tool query; standalone pages don't carry it, nothing to close).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
const checkingStatus = ref('idle');
const errorMsg = ref('');
@@ -214,7 +213,7 @@ const scoreToneClass = (score) => {
return 'text-destructive';
};
// 14 items detection data: label goes i18n {key}.title; positive/negative text is also
// Detection items: label goes i18n {key}.title; positive/negative text is also
// key determined will be used by template to pull t(`invisibilitytest.${key}.title|positive|negative`) from i18n.
const detectionItems = computed(() => {
const r = testResults.value;
@@ -241,6 +240,13 @@ const detectionItems = computed(() => {
flagged: Boolean(r.latency_vs_ws.proxy),
extras: r.latency_vs_ws.proxy ? { tcp: r.latency_vs_ws.tcpTime, ws: r.latency_vs_ws.wsTime } : null,
},
{
key: 'tlsRtt',
flagged: Boolean(r.tls_rtt.proxy),
extras: r.tls_rtt.proxy && r.tls_rtt.tcpRtt != null && r.tls_rtt.tlsMs != null
? { tcp: r.tls_rtt.tcpRtt, tls: r.tls_rtt.tlsMs }
: null,
},
{
key: 'timezone',
flagged: Boolean(r.timezone.proxy || r.timezone.vpn),
@@ -252,10 +258,15 @@ const detectionItems = computed(() => {
{
key: 'webrtc',
flagged: Boolean(r.webrtc.proxy),
extras: r.webrtc.proxy ? { ips: [...(r.webrtc.allips || []), r.webrtc.ip].filter(Boolean) } : null,
extras: r.webrtc.proxy ? { ips: [...new Set([...(r.webrtc.allips || []), r.webrtc.ip].filter(Boolean))] } : null,
},
{ key: 'flow', flagged: Boolean(r.flow.proxy) },
{ key: 'highlatency', flagged: Boolean(r.highlatency.proxy) },
{
key: 'proxyAi',
flagged: Boolean(r.proxy_ai.proxy),
extras: r.proxy_ai.proxy && r.proxy_ai.score != null ? { score: r.proxy_ai.score.toFixed(2) } : null,
},
];
});
@@ -334,7 +345,7 @@ const getResult = async () => {
} else {
testResults.value = data;
// Achievement rules (HiddenWell / SlipUp) live in data/achievement-rules.js;
// the report collector consumes ip + the 14 detection flags.
// the report collector consumes ip + the detection flags.
emitAppEvent('invisibility:result', {
proxyScore: Math.floor(data.score.proxy),
vpnScore: Math.floor(data.score.vpn),
@@ -0,0 +1,389 @@
<template>
<div class="ip-blocklist-section my-4 space-y-4">
<!-- Top note -->
<div class="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<p>{{ t('ipblocklist.Note') }}</p>
<p>{{ t('ipblocklist.Note2') }}</p>
</div>
<!-- Input area -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium">
{{ manualMode ? t('ipblocklist.EnterIPLabel') : t('ipblocklist.SelectIPLabel') }}
</Label>
<div v-if="allIPs.length" class="flex items-center gap-2 shrink-0">
<Switch id="blocklistUseStored" v-model="useStored" :disabled="isRunning" />
<Label for="blocklistUseStored" class="font-normal text-muted-foreground cursor-pointer">
{{ t('ipblocklist.UseStored') }}
</Label>
</div>
</div>
<div class="flex items-center gap-2">
<Select v-if="!manualMode" v-model="selectedIP" :disabled="isRunning">
<SelectTrigger id="blocklistIP" aria-label="Select IP to check" class="flex-1 min-w-0">
<SelectValue :placeholder="t('ipblocklist.SelectIP')" class="truncate font-mono" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="item in allIPs" :key="item.ip" :value="item.ip">
<span class="flex items-center gap-1 min-w-0">
<Icon v-if="item.country" :icon="'circle-flags:' + item.country.toLowerCase()"
class="size-3.5 md:size-4 shrink-0" />
<span class="font-mono truncate text-xs md:text-sm">{{ item.ip }}</span>
</span>
</SelectItem>
</SelectContent>
</Select>
<Input v-else id="blocklistIPManual" v-model="manualIP" class="flex-1 font-mono"
:placeholder="t('ipblocklist.EnterIPPlaceholder')" :disabled="isRunning"
:aria-invalid="inputInvalid" autocomplete="off"
autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true"
@keyup.enter="runCheck" />
<Button variant="action" class="cursor-pointer" :disabled="!canRun" @click="runCheck"
aria-label="Run blocklist check">
<Spinner v-if="isRunning" />
<Play v-else class="size-4 shrink-0" />
</Button>
</div>
<p v-if="inputInvalid" class="text-sm text-destructive">{{ t('ipblocklist.InvalidIP') }}</p>
<!-- Login hint -->
<div v-if="!store.user"
class="flex items-start gap-2 p-3 rounded-md border border-info/30 bg-info/10 text-sm text-info">
<Info class="size-4 mt-0.5 shrink-0" />
<span>{{ t('user.SignInToUse') }}</span>
</div>
<!-- Running: a cold DNSBL pass can take a while -->
<p v-if="isRunning" class="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner class="size-4" />{{ t('ipblocklist.Checking') }}
</p>
<p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
<!-- Monthly quota exhausted: not an error — explain + usage / sponsor path -->
<div v-if="quotaExceeded"
class="flex items-start gap-2 p-3 rounded-md border border-warning/30 bg-warning/10 text-sm text-warning">
<Hourglass class="size-4 mt-0.5 shrink-0" />
<span>
{{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer"
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span>
</div>
</div>
<!-- Result -->
<template v-if="result">
<!-- Summary: IP, verdict, four counts -->
<Card>
<CardContent class="p-4 md:p-6 space-y-4">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span class="font-mono font-semibold text-lg break-all">{{ result.ip }}</span>
<span class="text-xs text-muted-foreground">
{{ t('ipblocklist.CheckedAt', { time: isoToDateTime(result.checkedAt, lang) }) }}
</span>
</div>
<div class="flex items-start gap-2 text-sm font-medium" :class="TONE_TEXT[verdict]">
<component :is="TONE_ICON[verdict]" class="size-4 mt-0.5 shrink-0" />
<span>{{ t(`ipblocklist.verdict.${verdict}`) }}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div v-for="tile in tiles" :key="tile.key" class="rounded-md bg-muted/50 px-3 py-2">
<div class="text-xs text-muted-foreground">{{ t(`ipblocklist.count.${tile.key}`) }}</div>
<div class="text-2xl font-semibold tabular-nums"
:class="tile.value > 0 ? TONE_TEXT[tile.key] : 'text-muted-foreground'">
{{ tile.value }}
</div>
</div>
</div>
<p v-if="result.summary?.error > 0" class="text-xs text-muted-foreground">
{{ t('ipblocklist.SomeFailed', { n: result.summary.error }) }}
</p>
</CardContent>
</Card>
<!-- One card per part: hits open, the rest folded -->
<Card v-for="part in parts" :key="part.id">
<CardContent class="p-0">
<header class="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 px-4 py-3 border-b">
<div class="min-w-0">
<h3 class="flex items-center gap-2 text-sm font-semibold m-0">
<component :is="part.icon" class="size-4 text-muted-foreground shrink-0" />
{{ t(`ipblocklist.part.${part.id}.title`) }}
</h3>
<p class="text-xs text-muted-foreground mt-0.5 mb-0">
{{ t(`ipblocklist.part.${part.id}.desc`) }}
</p>
</div>
<span class="text-xs text-muted-foreground tabular-nums">
{{ partCounts(part.summary) }}
</span>
</header>
<!-- Hits -->
<ul v-if="part.hits.length" class="divide-y list-none p-0 m-0">
<li v-for="item in part.hits" :key="item.id" class="flex items-start gap-3 px-4 py-3">
<component :is="TONE_ICON[itemTone(item)]" class="size-4 mt-0.5 shrink-0"
:class="TONE_TEXT[itemTone(item)]" />
<div class="flex-1 min-w-0 space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{{ item.name }}</span>
<Badge variant="outline" class="font-normal text-muted-foreground">
{{ categoryLabel(item.category) }}
</Badge>
</div>
<div v-if="item.reasons.length" class="flex flex-wrap gap-1.5">
<span v-for="reason in item.reasons" :key="reason"
class="rounded-md px-2 py-0.5 text-xs" :class="TONE_CHIP[itemTone(item)]">
{{ reasonLabel(reason) }}
</span>
</div>
<p v-if="item.lastSeen" class="text-xs text-muted-foreground m-0">
{{ t('ipblocklist.LastSeen', { date: formatIsoDate(item.lastSeen, lang) }) }}
</p>
</div>
<a v-if="item.removalUrl && item.status === 'listed'" :href="item.removalUrl"
target="_blank" rel="noopener"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground whitespace-nowrap">
{{ t('ipblocklist.RequestRemoval') }}
<ExternalLink class="size-3" />
</a>
</li>
</ul>
<p v-else class="px-4 py-3 text-sm text-muted-foreground m-0">
{{ t('ipblocklist.NoHits') }}
</p>
<!-- Everything else, folded -->
<Collapsible v-if="part.rest.length" v-model:open="openRest[part.id]">
<CollapsibleTrigger as-child>
<button type="button"
class="flex w-full items-center justify-center gap-1.5 border-t px-4 py-2.5 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground cursor-pointer">
<ChevronDown class="size-4 transition-transform duration-200"
:class="{ 'rotate-180': openRest[part.id] }" />
{{ openRest[part.id] ? t('ipblocklist.HideRest') : t('ipblocklist.ShowRest', { n: part.rest.length }) }}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-1.5 list-none m-0 px-4 py-3 border-t">
<li v-for="item in part.rest" :key="item.id"
class="flex items-center gap-2 min-w-0 text-sm">
<component :is="TONE_ICON[itemTone(item)]" class="size-4 shrink-0"
:class="TONE_TEXT[itemTone(item)]" />
<span class="truncate" :class="{ 'text-muted-foreground': item.status !== 'clean' }"
:title="item.name">{{ item.name }}</span>
<span v-if="item.status === 'error'" class="shrink-0 text-xs text-muted-foreground">
{{ t('ipblocklist.CheckFailed') }}
</span>
</li>
</ul>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
<!-- Legend: what each mark means, for every row above -->
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1.5 list-none m-0 p-0 text-xs text-muted-foreground">
<li v-for="tone in LEGEND" :key="tone" class="flex items-start gap-1.5">
<component :is="TONE_ICON[tone]" class="size-3.5 mt-px shrink-0" :class="TONE_TEXT[tone]" />
<span>
<span class="font-medium text-foreground">{{ t(`ipblocklist.legend.${tone}.label`) }}</span>
{{ t(`ipblocklist.legend.${tone}.desc`) }}
</span>
</li>
</ul>
</template>
</div>
</template>
<script setup>
// IP Blocklist Check: which blocklists an IP is on — live DNSBLs and static
// threat lists — via /api/ipblocklist (the private API's /iphitlist; signed-in
// users, original site only). Verdict / row split live in
// utils/features/ip-blocklist.js. A `?q=` IP is prefilled, never run: the
// visitor may be signed out or out of quota.
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { authenticatedFetch } from '@/utils/authenticated-fetch';
import { isoToDateTime, formatIsoDate } from '@/utils/time-utils.js';
import { blocklistVerdict, listedCounts, splitPart, itemTone, reasonMessage } from '@/utils/features/ip-blocklist.js';
import { selectableIPs, classifyTarget } from '@/composables/use-globalping-measurement';
import { useActiveValue } from '@/composables/use-route-active.js';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Icon } from '@iconify/vue';
import {
ChevronDown, CircleAlert, CircleCheck, CircleHelp, CircleX, ExternalLink, Hourglass, Info, ListChecks, Play, Radar,
} from '@lucide/vue';
const { t, te } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const lang = computed(() => store.lang);
// The backend waits up to 30s on the upstream; give it room to answer first.
const CLIENT_TIMEOUT_MS = 35000;
// Tone → classes / icons, one circled-icon family for every row and the
// verdict: abuse ✕, anonymity !, notice i, clean ✓, error ?.
const TONE_TEXT = {
abuse: 'text-destructive', anonymity: 'text-warning', notice: 'text-info', clean: 'text-success', error: 'text-muted-foreground',
};
const TONE_CHIP = { abuse: 'bg-destructive/10 text-destructive', anonymity: 'bg-warning/10 text-warning', notice: 'bg-info/10 text-info' };
const TONE_ICON = { abuse: CircleX, anonymity: CircleAlert, notice: Info, clean: CircleCheck, error: CircleHelp };
const LEGEND = ['abuse', 'anonymity', 'notice', 'clean', 'error'];
// ── Input ────────────────────────────────────────────────────────────────
// Manual entry is forced when no IPs are stored (the homepage never ran);
// otherwise the switch (on by default) picks between dropdown and typing.
const allIPs = computed(() => selectableIPs(store.allIPs));
const useStored = ref(true);
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
const selectedIP = ref('');
const manualIP = ref('');
// The typed IP is validated on submit, not while typing (like Whois): the
// button only needs something entered, and a rejected value flags the input
// until the next run.
const inputInvalid = ref(false);
const targetIP = computed(() => (manualMode.value ? manualIP.value.trim() : selectedIP.value));
const status = ref('idle'); // 'idle' | 'running'
const isRunning = computed(() => status.value === 'running');
const canRun = computed(() => !isRunning.value && !!store.user && !!targetIP.value);
// ── Result ───────────────────────────────────────────────────────────────
const result = ref(null);
const errorMsg = ref('');
const quotaExceeded = ref(false);
const openRest = ref({});
// The IP the last run asked about; `result` and `errorMsg` belong to it.
let lastRunIP = '';
// ── Shareable `?q=` (use-route-active.js) ────────────────────────────────
// A stored IP is picked in the dropdown, any other goes to manual entry.
// Another IP's result is cleared: a link means "check this one".
const prefill = (ip) => {
inputInvalid.value = false;
if (allIPs.value.some((item) => item.ip === ip)) {
useStored.value = true;
selectedIP.value = ip;
} else {
useStored.value = false;
manualIP.value = ip;
}
if (ip !== lastRunIP) {
result.value = null;
errorMsg.value = '';
}
};
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q.trim() !== targetIP.value) prefill(q.trim());
}, { immediate: true });
// Keep `?q=` in step with the last run without growing history.
const syncQuery = (q) => {
if (route.query.q === q) return;
router.replace({ query: { ...route.query, q } });
};
const verdict = computed(() => blocklistVerdict(result.value?.summary));
const tiles = computed(() => {
const s = result.value?.summary ?? {};
const listed = listedCounts(s);
return [
{ key: 'abuse', value: listed.abuse },
{ key: 'anonymity', value: listed.anonymity },
{ key: 'notice', value: s.notice ?? 0 },
{ key: 'clean', value: s.clean ?? 0 },
];
});
const parts = computed(() => [
{ id: 'dnsbl', icon: Radar },
{ id: 'static', icon: ListChecks },
]
.filter(({ id }) => result.value?.[id])
.map((p) => ({ ...p, summary: result.value[p.id].summary, ...splitPart(result.value[p.id]) })));
// "2 abuse · 1 anonymity · 41 clean" — non-zero counts only.
const partCounts = (s) => [
['abuse', listedCounts(s).abuse],
['anonymity', listedCounts(s).anonymity],
['notice', s?.notice],
['clean', s?.clean],
['error', s?.error],
]
.filter(([, n]) => n > 0)
.map(([key, n]) => `${t(`ipblocklist.count.${key}`)} ${n}`)
.join(' · ');
// Unknown keys (added upstream before a translation lands) show as-is.
const reasonLabel = (reason) => {
const { key, params } = reasonMessage(reason);
return te(key) ? t(key, params) : reason;
};
const categoryLabel = (category) => {
const key = `ipblocklist.category.${category}`;
return te(key) ? t(key) : category;
};
// ── Run ──────────────────────────────────────────────────────────────────
const runCheck = async () => {
if (!canRun.value) return;
// Public IPv4 / IPv6 only ('invalid' / 'unreachable' = not an IP / not public).
inputInvalid.value = manualMode.value && classifyTarget(targetIP.value) !== 'ok';
if (inputInvalid.value) return;
// Frontend first line: the quota snapshot already says the month is used
// up. The backend enforces the same limit (429) as the real guard.
if (store.quotaExceeded.ip_hitlist) {
quotaExceeded.value = true;
return;
}
syncQuery(targetIP.value);
status.value = 'running';
errorMsg.value = '';
quotaExceeded.value = false;
result.value = null;
openRest.value = {};
trackEvent('Section', 'StartClick', 'IpBlocklist');
const ip = targetIP.value;
lastRunIP = ip;
// A link can swap the IP mid-run (inputs are disabled, `?q=` isn't);
// the old IP's answer is then dropped.
const isStale = () => targetIP.value !== ip;
try {
const body = await authenticatedFetch(`/api/ipblocklist?ip=${encodeURIComponent(ip)}`, 'GET', null, CLIENT_TIMEOUT_MS);
if (!isStale()) result.value = body;
} catch (error) {
// 429: monthly quota exhausted — show the quota hint, not an error.
if (error.status === 429) {
quotaExceeded.value = true;
store.markQuotaExhausted('ip_hitlist');
return;
}
console.error('IP blocklist check failed:', error);
if (isStale()) return;
// 401/403: the visitor's sign-in state, not a failure to retry.
if (error.status === 401 || error.status === 403) {
errorMsg.value = error.message.includes('Invalid token') ? t('user.InvalidUserToken') : t('user.SignInToUse');
} else {
errorMsg.value = t('ipblocklist.FetchError');
}
} finally {
status.value = 'idle';
}
};
</script>
@@ -1,12 +1,11 @@
<!-- IP Calculator (advanced tool, slug `ipcalculator`): one smart input
(IPv4 / IPv6, prefix, number, range, prefix list — never a MAC, that is
MAC Lookup's job) dispatched to the matching card under ./ip-calculator/.
Pure local computation via utils/ip-calc.js.
Pure local computation via utils/features/ip-calc.js.
The query rides the URL as `?q=` on both /tools/ipcalculator and
/?tool=ipcalculator, written back on every run so results are shareable.
Two collapsed folds under the input hold example pills (one per accepted
syntax) and, on the home page, the visitor's own IPs (store.allIPs). -->
The query rides the URL as `?q=`, written back on every run so results
are shareable. Two collapsed folds under the input hold example pills
(one per accepted syntax) and the visitor's own IPs (store.allIPs). -->
<template>
<div class="ip-calculator-section my-4 space-y-4">
<!-- Top note -->
@@ -85,16 +84,17 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Icon } from '@iconify/vue';
import { Calculator, ChevronRight } from '@lucide/vue';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { isValidIP } from '@/utils/valid-ip.js';
import { isValidIP } from '@/utils/ip/valid-ip.js';
import { selectableIPs } from '@/composables/use-globalping-measurement.js';
import { analyzeCidr, calculate } from '@/utils/ip-calc.js';
import { useActiveValue } from '@/composables/use-route-active.js';
import { analyzeCidr, calculate } from '@/utils/features/ip-calc.js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -200,13 +200,15 @@ const onPrefixCommit = (prefix) => {
syncQuery();
};
onMounted(() => {
const q = route.query.q;
if (typeof q === 'string' && q.trim()) {
// Shareable `?q=` (use-route-active.js); our own write-back equals the
// trimmed input, so it doesn't run twice.
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q !== query.value.trim()) {
query.value = q;
run(q);
}
});
}, { immediate: true });
/* ------------------------------------------------------------------ */
/* Result dispatch */
@@ -117,7 +117,7 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import getCountryName from '@/data/country-name.js';
import { normalizeMacQuery } from '@/utils/mac-input.js';
import { normalizeMacQuery } from '@/utils/features/mac-input.js';
import { ChevronRight, CircleCheck, CircleX, Factory, ListChecks, Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -6,9 +6,7 @@
<p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p>
</div>
<!-- Input area. With stored IPs (homepage drawer) the user can pick one
OR switch to manual entry; on the standalone page allIPs is empty,
so it's manual entry only. -->
<!-- Input area -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium">
@@ -201,7 +199,7 @@ import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import { useGlobalpingMeasurement, GLOBALPING_SUGGESTED_COUNTRIES, GLOBALPING_MAX_COUNTRIES, selectableIPs, classifyTarget } from '@/composables/use-globalping-measurement';
import GlobalpingCountryPicker from './GlobalpingCountryPicker.vue';
import { parseMtrOutput } from '@/utils/mtr-parse.js';
import { parseMtrOutput } from '@/utils/features/mtr-parse.js';
import getCountryName from '@/data/country-name.js';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card, CardContent } from '@/components/ui/card';
@@ -235,9 +233,8 @@ const pickerSections = computed(() => [
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
]);
// Manual entry is forced when there are no stored IPs (the standalone page,
// where the homepage never ran). When stored IPs exist, the "use stored IP"
// switch (on by default) toggles between the dropdown and manual entry.
// Manual entry is forced when no IPs are stored (the homepage never ran);
// otherwise the switch (on by default) picks between dropdown and typing.
const useStored = ref(true);
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
const manualIP = ref('');
@@ -233,7 +233,7 @@
<span>
{{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span>
</div>
</div>
@@ -264,12 +264,12 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import { REGEXP_ONLY_DIGITS } from 'vue-input-otp';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { dispatchAppCommand, waitForAppCommand } from '@/utils/app-commands.js';
import { dispatchAppCommand, hasAppCommand, waitForAppCommand } from '@/utils/app-commands.js';
import { authenticatedFetch, fetchErrorLabel } from '@/utils/authenticated-fetch';
import { buildObservation, usePersonaSnapshots } from '@/composables/use-persona-collector.js';
import { localProfile } from '@/utils/persona/local-profile.js';
@@ -286,7 +286,6 @@ import PersonaReport from '@/components/advanced-tools/PersonaReport.vue';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const { snapshots, missingSources } = usePersonaSnapshots();
@@ -384,11 +383,11 @@ const runDependencies = async () => {
if (!missing.length) return;
runningDependencies.value = true;
try {
// Off the homepage, the owners aren't mounted yet — navigate first,
// then wait for each command to be registered before dispatching.
if (route.name !== 'home') await router.push('/');
await Promise.allSettled(missing.map(async (source) => {
const command = DEPENDENCY_COMMANDS[source];
// The owners are homepage sections: a cached Home runs them in place;
// one that never mounted (direct visit) needs a trip home first.
const commands = missing.map((source) => DEPENDENCY_COMMANDS[source]);
if (!commands.every(hasAppCommand)) await router.push('/');
await Promise.allSettled(commands.map(async (command) => {
await waitForAppCommand(command, { timeoutMs: DEPENDENCY_RUN_TIMEOUT });
await dispatchAppCommand(command, {}, { timeoutMs: DEPENDENCY_RUN_TIMEOUT });
}));
@@ -408,11 +407,6 @@ const quotaExceeded = ref(false);
const canRun = computed(() =>
signedIn.value && hasProfile.value && !missingSources.value.length);
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
const run = async () => {
// Pre-flight gate, same as the other quota-metered tools: when the local
// snapshot already says the month is spent, every run would 429.
@@ -433,7 +427,7 @@ const run = async () => {
{ persona: persona.value, observation });
runStatus.value = 'finished';
// The shareable report keeps only each check's conclusion — the
// builder in utils/report-builders.js drops every detail field.
// builder in utils/report/report-builders.js drops every detail field.
emitAppEvent('persona:finished', {
country: persona.value.country,
grade: report.value.grade,
@@ -1,7 +1,7 @@
<template>
<div class="rule-test-section my-4 space-y-4">
<!-- Top note -->
<p class="text-sm text-muted-foreground">{{ t('ruletest.Note') }}</p>
<p class="text-sm text-muted-foreground">{{ t('ruletest.Note', { n: ruleTests.length }) }}</p>
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
@@ -85,7 +85,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { emitAppEvent } from '@/utils/app-events.js';
import { parseTrace } from '@/utils/parse-trace.js';
import { parseTrace } from '@/utils/ip/parse-trace.js';
import getCountryName from '@/data/country-name.js';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -113,7 +113,7 @@ const createDefaultCard = () => ({
org: t('ruletest.StatusWait'),
});
const ruleTests = ref(Array.from({ length: 8 }, (_, index) => ({
const ruleTests = ref(Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
url: `ptest-${index + 1}.ipcheck.ing`,
...createDefaultCard(),
@@ -120,7 +120,7 @@
</nav>
<!-- Right 2/3: current category details -->
<div id="checklist" class="md:col-span-2">
<div id="checklist" class="md:col-span-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))]">
<Card>
<CardContent class="p-4 md:p-6 space-y-4">
<!-- Category title + description + expandable intro -->
@@ -130,7 +130,7 @@
class="size-5 text-muted-foreground" />
{{ fullList[currentList].title }}
</h2>
<Collapsible :open="isCategoryIntroOpen" @update:open="isCategoryIntroOpen = $event">
<Collapsible :open="isCategoryIntroOpen" @update:open="toggleCategoryIntro">
<span class="flex items-center">
<span class="text-sm text-muted-foreground leading-relaxed">
{{ fullList[currentList].description }}
@@ -171,25 +171,20 @@
</span>
</div>
<!-- Filter Toggle -->
<!-- Filter Toggle: each tooltip sits inside its item on a full-bleed span.
Wrapping the item instead would overwrite its data-state, and the
mobile wrapper span would break the segmented first/last borders. -->
<div class="flex justify-start">
<ToggleGroup v-model="filterTag" type="single" variant="outline"
@update:model-value="(v) => v && filterChecklist(v)">
<ToggleGroupItem value="all" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowAll')">
<ListChecks class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="unchecked" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowUnchecked')">
<Circle class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="checked" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowChecked')">
<CircleCheck class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="ignored" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowIgnored')">
<CirclePause class="size-4" />
<ToggleGroupItem v-for="filter in filterOptions" :key="filter.value"
:value="filter.value" class="flex-1 gap-1.5"
:aria-label="t(filter.label)">
<JnTooltip :text="t(filter.label)">
<span class="-mx-3 flex items-center self-stretch px-3">
<component :is="filter.icon" class="size-4" />
</span>
</JnTooltip>
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -199,10 +194,10 @@
<li v-for="(item, index) in filterList" :key="item.slug" class="transition-colors"
:class="item.checked ? 'bg-success/10' : 'hover:bg-muted/30'">
<div class="flex items-start gap-3 px-3 py-2.5">
<!-- Status switch button -->
<!-- Status switch button (h-5 = the text's first line) -->
<button type="button" @click="checkItem(item)"
class="shrink-0 mt-0.5 text-muted-foreground cursor-pointer"
:class="{ 'text-success': item.checked, 'text-muted-foreground/60 cursor-not-allowed': item.ignored }"
class="flex h-5 shrink-0 items-center text-muted-foreground cursor-pointer"
:class="{ 'text-success': item.checked, 'text-info cursor-not-allowed': item.ignored }"
:disabled="item.ignored" :aria-label="item.checked ? 'Uncheck' : 'Check'">
<component
:is="item.checked ? CircleCheck : item.ignored ? CirclePause : Circle"
@@ -223,16 +218,24 @@
</div>
</div>
<!-- Priority Badge -->
<Badge class="shrink-0 rounded-full font-normal shadow-none"
<!-- Priority Badge (22px, centred on the 20px first line) -->
<Badge class="-my-px shrink-0 rounded-full font-normal shadow-none"
:class="priorityBadgeClass(item.priority)">
{{ t('securitychecklist.' + item.priority) }}
</Badge>
<!-- Ignore Switch -->
<Switch class="shrink-0" :model-value="item.ignored"
@update:model-value="() => ignoreItem(item)"
:aria-label="t('securitychecklist.Ignore')" />
<!-- Ignore toggle (24px, centred on the first line). The span is the
tooltip trigger, so the trigger's data-state can't hide the pressed state. -->
<JnTooltip :text="t('securitychecklist.Ignore')">
<span class="-my-0.5 inline-flex shrink-0">
<Toggle :model-value="item.ignored"
@update:model-value="() => ignoreItem(item)"
class="h-6 min-w-6 px-0 text-muted-foreground cursor-pointer data-[state=on]:bg-info/10 data-[state=on]:text-info data-[state=on]:hover:bg-info/15 data-[state=on]:hover:text-info"
:aria-label="t('securitychecklist.Ignore')">
<CirclePause class="size-4" />
</Toggle>
</span>
</JnTooltip>
</div>
<!-- Info details expand -->
@@ -265,7 +268,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { JnTooltip } from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Toggle } from '@/components/ui/toggle';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Spinner } from '@/components/ui/spinner';
import {
@@ -307,10 +310,17 @@ const checklistIconMap = {
};
const getChecklistIcon = (slug) => checklistIconMap[slug] || Info;
// GA labels read as a funnel: ChangeList → ItemInfo / CategoryIntro → Check / Ignore.
const isCategoryIntroOpen = ref(false);
const toggleCategoryIntro = (open) => {
isCategoryIntroOpen.value = open;
if (open) trackEvent('SecurityChecklist', 'SecurityChecklist', 'CategoryIntro');
};
const checklistInfoOpen = ref({});
const toggleChecklistInfo = (index) => {
checklistInfoOpen.value[index] = !checklistInfoOpen.value[index];
if (checklistInfoOpen.value[index]) trackEvent('SecurityChecklist', 'SecurityChecklist', 'ItemInfo');
};
const { t, locale } = useI18n();
@@ -433,6 +443,14 @@ const filterList = ref({});
const listToShow = ref([]);
const filterTag = ref('all');
// Filter toggle items: value, icon, and the key shared by aria-label and tooltip
const filterOptions = [
{ value: 'all', icon: ListChecks, label: 'securitychecklist.ShowAll' },
{ value: 'unchecked', icon: Circle, label: 'securitychecklist.ShowUnchecked' },
{ value: 'checked', icon: CircleCheck, label: 'securitychecklist.ShowChecked' },
{ value: 'ignored', icon: CirclePause, label: 'securitychecklist.ShowIgnored' },
];
const changeList = (listName, shouldScroll = true) => {
const previousList = currentList.value;
listToShow.value = fullList.value[listName];
@@ -443,45 +461,15 @@ const changeList = (listName, shouldScroll = true) => {
if (previousList !== currentList.value) {
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
}
// Scroll the details area into view when switching category — but *only*
// by moving the drawer's own scroll container, never by letting the
// scroll request bubble to <body>.
//
// The earlier version used `scrollIntoView({ block: 'nearest' })`. On
// desktop the target element (#checklist) was already visible in the
// md 3-column grid, so the call no-op'd and everything worked. On iOS
// Safari however, the drawer library (vaul) scroll-locks the page by
// setting `body { position: fixed; top: -Npx }`. When scrollIntoView
// walked up past the drawer's own scrollable div to any ancestor that
// still negotiated with the browser's root scroller, its implicit
// side-effect of "settle page scroll" knocked vaul's recorded offset
// out of sync — the entire drawer visually slid up by the scroll
// distance (header disappearing off the top, blank space at bottom).
//
// Manually walking to the nearest scrollable ancestor and calling
// `scrollBy` on that element keeps the effect fully contained inside
// the drawer, and the PC path still short-circuits via the
// "already-visible" check.
// On a narrow screen the details stack below the list: bring them into
// view, clear of the fixed Nav (scroll-mt-* on #checklist).
nextTick(() => {
const el = document.getElementById('checklist');
if (!el) return;
let scrollParent = el.parentElement;
while (scrollParent) {
const { overflowY } = getComputedStyle(scrollParent);
if (overflowY === 'auto' || overflowY === 'scroll') break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
const containerRect = scrollParent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const alreadyVisible =
elRect.top >= containerRect.top &&
elRect.bottom <= containerRect.bottom;
if (alreadyVisible) return;
scrollParent.scrollBy({
top: elRect.top - containerRect.top - 8,
behavior: 'smooth',
});
const { top } = el.getBoundingClientRect();
const headerBottom = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (top >= headerBottom && top < window.innerHeight * 0.75) return;
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
}
};
@@ -498,12 +486,14 @@ const ignoreItem = (item) => {
item.ignored = !item.ignored;
item.checked = false;
updateSlugs(item.slug, item.ignored ? 'ignored' : '');
if (item.ignored) trackEvent('SecurityChecklist', 'SecurityChecklist', 'Ignore');
};
const checkItem = (item) => {
if (item.ignored) return;
item.checked = !item.checked;
updateSlugs(item.slug, item.checked ? 'checked' : '');
if (item.checked) trackEvent('SecurityChecklist', 'SecurityChecklist', 'Check');
// Achievement rules (SurfaceCheck / HalfwayThere / FullySecured) live in
// data/achievement-rules.js.
emitAppEvent('securitychecklist:progress', {
@@ -1,9 +1,8 @@
<template>
<!-- Service Status advanced tool: shows whether a set of well-known
products are currently up, expandable per-card into sub-services and
recent incidents. Lives inside the Advanced Tools drawer (its title is
rendered by the drawer header), so this template starts at the note row
rather than a section <h2>. -->
recent incidents. ToolPage.vue owns the title, so this starts at the
note row. -->
<div class="service-status-section my-4 space-y-4">
<!-- Top note -->
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p>
@@ -165,11 +164,10 @@
// /api/service-status/detail → one provider's sub-services + incidents (on expand)
// so the initial load stays light and detail is pulled only when a card opens.
//
// Moved out of the homepage into the Advanced Tools drawer: it mounts fresh
// each time the tool is opened (loadOverview on mount) and is no longer wired
// into the homepage's global-refresh orchestrator — the bottom Refresh button
// here is the only re-pull path.
import { ref, reactive, computed, onMounted } from 'vue';
// Not wired into the homepage's global-refresh orchestrator: it loads on
// mount, again when shown after a backend refresh cycle (the page is kept
// alive), and on the bottom Refresh button.
import { ref, reactive, computed, onMounted, onActivated } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -178,7 +176,7 @@ import { useStatusTone } from '@/composables/use-status-tone.js';
import { unixToDateTime } from '@/utils/time-utils.js';
import {
indicatorToTone, componentStatusToTone, impactLevel, incidentStatusTone,
} from '@/utils/service-status-tone.js';
} from '@/utils/features/service-status-tone.js';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
@@ -326,9 +324,12 @@ const floorDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Lazy per-provider detail loader: one request returns both the sub-services
// and the recent incidents (a user who opens a card looks at both). Guards
// against duplicate fetches; a successful result is cached until refresh, while
// an error leaves `loaded` false so re-expanding retries.
// an error leaves `loaded` false so re-expanding retries. An answer from
// before a reload is dropped: stored, a reopened card would skip its fetch.
let detailEpoch = 0;
const loadDetail = async (id) => {
if (detailState[id]?.loading || detailState[id]?.loaded) return;
const epoch = detailEpoch;
detailState[id] = { loading: true, error: false, loaded: false, components: [], incidents: [] };
try {
const [res] = await Promise.all([
@@ -337,12 +338,14 @@ const loadDetail = async (id) => {
]);
if (!res.ok) throw new Error(`status ${res.status}`);
const json = await res.json();
if (epoch !== detailEpoch) return;
detailState[id] = {
loading: false, error: false, loaded: true,
components: json.components || [],
incidents: json.incidents || [],
};
} catch {
if (epoch !== detailEpoch) return;
detailState[id] = { loading: false, error: true, loaded: false, components: [], incidents: [] };
}
};
@@ -356,16 +359,29 @@ const onToggle = (p) => {
}
};
const refresh = () => {
trackEvent('Section', 'RefreshClick', 'ServiceStatus');
// Collapse any open cards (mirrors IPCard / WebRTC refresh behavior) and drop
// cached detail so a reopened card refetches the fresh snapshot.
// Collapse any open cards (mirrors IPCard / WebRTC refresh behavior) and drop
// cached detail so a reopened card refetches the fresh snapshot.
const reload = () => {
detailEpoch += 1;
for (const k of Object.keys(openState)) openState[k] = false;
for (const k of Object.keys(detailState)) delete detailState[k];
loadOverview();
};
const refresh = () => {
trackEvent('Section', 'RefreshClick', 'ServiceStatus');
reload();
};
onMounted(() => {
loadOverview();
});
// The backend republishes its snapshot every 5 minutes; a cached page shown
// again after that long is as stale as a fresh mount would be. The first
// activation follows the mount, when no pull has completed yet.
const SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
onActivated(() => {
if (lastRefreshedAt.value && Date.now() - lastRefreshedAt.value >= SNAPSHOT_INTERVAL_MS) reload();
});
</script>
+2 -2
View File
@@ -66,8 +66,8 @@ import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { isValidIP, isValidDomain, isUsablePublicIP } from '@/utils/valid-ip.js';
import { parseAsnInput } from '@/utils/asn-input.js';
import { isValidIP, isValidDomain, isUsablePublicIP } from '@/utils/ip/valid-ip.js';
import { parseAsnInput } from '@/utils/ip/asn-input.js';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -78,7 +78,7 @@ import CalcSection from './CalcSection.vue';
import ValueRow from './ValueRow.vue';
import PrefixBitmap from './PrefixBitmap.vue';
import SubnetSplitter from './SubnetSplitter.vue';
import { countLabel } from '@/utils/ip-calc.js';
import { countLabel } from '@/utils/features/ip-calc.js';
const props = defineProps({
analysis: { type: Object, required: true },
@@ -90,7 +90,7 @@ import CalcSection from './CalcSection.vue';
import ValueRow from './ValueRow.vue';
import PrefixBitmap from './PrefixBitmap.vue';
import SubnetSplitter from './SubnetSplitter.vue';
import { countLabel } from '@/utils/ip-calc.js';
import { countLabel } from '@/utils/features/ip-calc.js';
const props = defineProps({
analysis: { type: Object, required: true },
@@ -42,7 +42,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Slider } from '@/components/ui/slider';
import { toOctets, toHextets } from '@/utils/ip-math.js';
import { toOctets, toHextets } from '@/utils/ip/ip-math.js';
const props = defineProps({
value: { type: BigInt, required: true },
@@ -47,7 +47,7 @@ import { Card, CardContent } from '@/components/ui/card';
import CopyButton from '@/components/widgets/CopyButton.vue';
import CalcSection from './CalcSection.vue';
import ValueRow from './ValueRow.vue';
import { countLabel } from '@/utils/ip-calc.js';
import { countLabel } from '@/utils/features/ip-calc.js';
const props = defineProps({
analysis: { type: Object, required: true },
@@ -39,8 +39,8 @@ import { Split } from '@lucide/vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import CopyButton from '@/components/widgets/CopyButton.vue';
import CalcSection from './CalcSection.vue';
import { splitCidr } from '@/utils/ip-math.js';
import { countLabel, formatCount } from '@/utils/ip-calc.js';
import { splitCidr } from '@/utils/ip/ip-math.js';
import { countLabel, formatCount } from '@/utils/features/ip-calc.js';
const props = defineProps({
cidr: { type: String, required: true },
@@ -189,7 +189,7 @@ const props = defineProps({
// Numeric string — parent strips the "AS" prefix to keep cache keys consistent.
asn: { type: String, required: true },
asnConnectivityInfos: { type: Object, required: true },
// false hides the expand-to-drawer button (ASN Profile already sits in a drawer).
// false hides the expand-to-drawer button.
expandable: { type: Boolean, default: true },
// false drops only the outer border (tint and padding stay) — for a host
// card that already draws one (ASN Profile).
+9 -11
View File
@@ -27,12 +27,11 @@
<!-- Traffic shares (pair bars) -->
<AsnTrafficShares :info="asnInfos[asn]" />
<!-- Full profile: a real link to the standalone page (new tab on
modifier clicks); a plain click opens the drawer instead -->
<!-- Full profile: a real link; a plain click routes in-app -->
<div v-if="profileHref" class="pt-1">
<Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs">
<a :href="profileHref" @click="openProfile">
<PanelBottomOpen class="size-3.5" />{{ t('ipInfos.ASNInfo.openProfile') }}
<ArrowUpRight class="size-3.5" />{{ t('ipInfos.ASNInfo.openProfile') }}
</a>
</Button>
</div>
@@ -55,12 +54,13 @@ import AsnConnectionQuality from './AsnConnectionQuality.vue';
import AsnTrafficShares from './AsnTrafficShares.vue';
import { useRouter } from 'vue-router';
import { trackEvent } from '@/utils/analytics';
import { parseAsnInput } from '@/utils/asn-input.js';
import { parseAsnInput } from '@/utils/ip/asn-input.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { Button } from '@/components/ui/button';
import { Icon } from '@iconify/vue';
import { PanelBottomOpen } from '@lucide/vue';
import { ArrowUpRight } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
@@ -75,8 +75,7 @@ const props = defineProps({
asnInfos: { type: Object, required: true }
});
// Fired before a plain-click open, so a host dialog (QueryIP) can close
// first instead of stacking under the drawer.
// Fired before a plain-click open, so a host dialog (QueryIP) closes first.
const emit = defineEmits(['open-profile']);
// Extract basic information (non-pair data)
@@ -94,8 +93,7 @@ const basicInfo = computed(() => {
});
// ASN Profile entry: hidden when the tool is gated off (no Cloudflare key)
// or the ASN doesn't parse. Opens like an Advanced Tools card: the `?tool=`
// drawer, with `q` making the tool run the lookup on mount.
// or the ASN doesn't parse; `q` makes the tool run the lookup.
const router = useRouter();
const profileAsn = computed(() => {
if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null;
@@ -105,10 +103,10 @@ const profileAsn = computed(() => {
const profileHref = computed(() => (profileAsn.value ? `/tools/asn?q=${profileAsn.value}` : ''));
const openProfile = (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile');
emit('open-profile');
router.push({ path: '/', query: { tool: 'asn', q: profileAsn.value } });
router.push({ path: '/tools/asn', query: { q: profileAsn.value } });
};
</script>
@@ -23,7 +23,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { pickConnectionQuality } from '@/utils/asn-metrics.js';
import { pickConnectionQuality } from '@/utils/ip/asn-metrics.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { CircleQuestionMark } from '@lucide/vue';
@@ -15,7 +15,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildTrafficPairs } from '@/utils/asn-metrics.js';
import { buildTrafficPairs } from '@/utils/ip/asn-metrics.js';
import DataPairBar from './DataPairBar.vue';
const props = defineProps({
+1 -1
View File
@@ -65,7 +65,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { isValidIP } from '@/utils/valid-ip.js';
import { isValidIP } from '@/utils/ip/valid-ip.js';
import FitText from '@/components/widgets/FitText.vue';
import { HERO_TIERS } from '@/composables/use-fit-text.js';
import { createMaskGate } from '@/composables/use-info-mask.js';
+36 -8
View File
@@ -234,6 +234,16 @@
</div>
</dl>
</div>
<!-- Point to a blocklist check of this IP. -->
<p v-if="blocklistHref"
class="border-t pt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-relaxed text-muted-foreground">
<span>{{ t('ipInfos.scoreDetails.blocklistHint') }}</span>
<a :href="blocklistHref" @click="openBlocklist"
class="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline">
{{ t('ipInfos.scoreDetails.blocklistLink') }}<ArrowRight class="size-3" />
</a>
</p>
</div>
</div>
</CollapsibleContent>
@@ -311,13 +321,17 @@
import { ref, computed, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n, I18nT } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { trackEvent } from '@/utils/analytics';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { loadAsnInfoInto } from '@/composables/use-asn-info.js';
import { loadAsnConnectivityInto } from '@/utils/asn-connectivity.js';
import { toBgpPrefix } from '@/utils/bgp-prefix.js';
import { loadAsnConnectivityInto } from '@/utils/ip/asn-connectivity.js';
import { toBgpPrefix } from '@/utils/ip/bgp-prefix.js';
import { getZoneUtcOffset, getZoneLocalTime, formatIsoDate } from '@/utils/time-utils.js';
import { buildScoreExplanation, listScoreTags } from '@/utils/ip-score-details.js';
import { buildScoreExplanation, listScoreTags } from '@/utils/ip/ip-score-details.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { isPlainClick } from '@/utils/nav-target.js';
import ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import CountryTraffic from './CountryTraffic.vue';
@@ -335,6 +349,7 @@ import { Button } from '@/components/ui/button';
import { Icon } from '@iconify/vue';
import {
Activity,
ArrowRight,
Building2,
Check,
ChevronDown,
@@ -383,10 +398,8 @@ const props = defineProps({
countryTrafficMode: { type: String, default: 'off' },
});
// Consumers rendering this panel inside a dialog listen to close themselves
// first — the Benefits & Usage dialog (`view-usage`) or an Advanced Tools
// drawer (`open-tool`, from ASN Info's profile link) would otherwise stack
// on top of them.
// A host dialog listens to close itself first: before the Benefits & Usage
// dialog stacks on it (`view-usage`) or a tool link (ASN Profile, Blocklist Check) changes page (`open-tool`).
const emit = defineEmits(['view-usage', 'open-tool']);
const openUsageDialog = () => {
@@ -497,6 +510,21 @@ const toggleScoreDetails = () => {
// A different IP starts collapsed.
watch(() => props.data.ip, () => { isScoreDetailsOpen.value = false; });
// Blocklist Check entry: hidden when the tool is gated off; `q` only prefills.
const router = useRouter();
const blocklistHref = computed(() => (
isToolAvailable(TOOL_BY_SLUG.get('blocklist'), props.configs) && props.data.ip
? `/tools/blocklist?q=${encodeURIComponent(props.data.ip)}`
: ''
));
const openBlocklist = (e) => {
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'BlocklistCTAClick', 'Open Blocklist Check');
emit('open-tool');
router.push({ path: '/tools/blocklist', query: { q: props.data.ip } });
};
// Consumer opt-in + a Cloudflare key on the deployment + a country to query.
const canShowCountryTraffic = computed(() =>
props.countryTrafficMode !== 'off' && !props.collapsed
@@ -606,7 +634,7 @@ const getASNHistory = async (prefix) => {
}
};
// Fetch + versioning live in utils/asn-connectivity.js; the cache is the
// Fetch + versioning live in utils/ip/asn-connectivity.js; the cache is the
// owner's, handed down as asnConnectivityInfos.
const getASNConnectivity = async (asn) => {
trackEvent('IPCheck', 'ASNConnectivityClick', 'Show ASN Connectivity');
@@ -10,21 +10,27 @@
<span class="font-mono shrink-0 truncate whitespace-nowrap">{{ data.longitude }}, {{ data.latitude
}}</span>
</div>
<!-- No map URL (no coordinates, or the card is reloading): a same-sized
muted box instead of a broken image. -->
<span>
<img :src="isDarkMode ? data.mapUrl_dark : data.mapUrl"
<img v-if="mapSrc" :src="mapSrc"
class="w-full rounded-md border bg-muted aspect-2/1 object-cover" alt="Map">
<span v-else class="block w-full rounded-md border bg-muted aspect-2/1" aria-hidden="true"></span>
</span>
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Earth } from '@lucide/vue';
const { t } = useI18n();
defineProps({
const props = defineProps({
// The IP payload — coordinates / mapUrl(_dark).
data: { type: Object, required: true },
isDarkMode: { type: Boolean, required: true },
});
const mapSrc = computed(() => (props.isDarkMode ? props.data.mapUrl_dark : props.data.mapUrl));
</script>
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more