mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
349 files changed
+8326
-2583
No files matched your search
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||

|
||||
[](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
@@ -22,7 +22,7 @@
|
||||

|
||||
[](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
@@ -22,7 +22,7 @@
|
||||

|
||||
[](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
@@ -22,7 +22,7 @@
|
||||

|
||||
[](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
@@ -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">
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://ipcheck.ing)
|
||||

|
||||
|
||||

|
||||

|
||||
[](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.
|
||||
|
||||
[](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
@@ -22,7 +22,7 @@
|
||||

|
||||
[](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
@@ -22,7 +22,7 @@
|
||||

|
||||
[](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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ' ';
|
||||
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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in new issue
Block a user