mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(report): share dialog with link, AI-markdown and JSON exports
New dock entry opens the diagnostic-report dialog: pick collected sections (original-site-only tools hidden for self-hosts), optionally mask IP tails, preview exactly what leaves the device, then export — a KV-backed /r/:id link (TTL 1/3/7 days, default 1; the backend forces out-of-whitelist values down to 1 day), a self-describing Markdown prompt for AI assistants, or a JSON download. Copy localized in all four locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41b31dd960
commit
89141aefc7
14 files changed
+825
-18
No files matched your search
+7
-5
@@ -32,6 +32,11 @@ const kvConfig = () => {
|
||||
// Exposed for /api/configs (booleanized there).
|
||||
export const isReportSharingConfigured = () => kvConfig() !== null;
|
||||
|
||||
// A tampered ttlDays is not worth a 400 — anything outside the whitelist is
|
||||
// forced down to the shortest retention (1 day). Exported for tests.
|
||||
export const normalizeTtlDays = (ttlDays) =>
|
||||
(REPORT_TTL_DAYS.includes(ttlDays) ? ttlDays : REPORT_TTL_DAYS[0]);
|
||||
|
||||
const kvValueUrl = ({ accountId, namespaceId }, key, queryString = '') =>
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}${queryString}`;
|
||||
|
||||
@@ -53,10 +58,7 @@ const createReport = async (req, res) => {
|
||||
return res.status(503).json({ error: 'Report sharing is not configured' });
|
||||
}
|
||||
|
||||
const { report, ttlDays = 7 } = req.body ?? {};
|
||||
if (!REPORT_TTL_DAYS.includes(ttlDays)) {
|
||||
return res.status(400).json({ error: 'Invalid ttlDays' });
|
||||
}
|
||||
const { report, ttlDays } = req.body ?? {};
|
||||
const { ok, errors } = validateReport(report);
|
||||
if (!ok) {
|
||||
return res.status(400).json({ error: 'Invalid report', details: errors });
|
||||
@@ -68,7 +70,7 @@ const createReport = async (req, res) => {
|
||||
|
||||
try {
|
||||
const id = crypto.randomBytes(16).toString('base64url');
|
||||
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
||||
const ttlSeconds = normalizeTtlDays(ttlDays) * 24 * 60 * 60;
|
||||
// The KV write endpoint requires multipart/form-data with `value` +
|
||||
// `metadata` fields (a raw body is a 400); fetch derives the
|
||||
// multipart boundary from the FormData, so no manual Content-Type.
|
||||
|
||||
@@ -11,9 +11,9 @@ import { isValidIP, isIPv6, isValidDomain } from './valid-ip.js';
|
||||
export const REPORT_VERSION = 1;
|
||||
|
||||
// TTL choices offered at creation time (days) — shared by the share dialog
|
||||
// and the backend's ttlDays whitelist. First entry is NOT the default; the
|
||||
// product default lives with the dialog (7).
|
||||
export const REPORT_TTL_DAYS = [1, 7, 30];
|
||||
// and the backend. The first entry (1 day) is the default AND the value the
|
||||
// backend forces when a client sends anything outside this list.
|
||||
export const REPORT_TTL_DAYS = [1, 3, 7];
|
||||
|
||||
// Serialized size ceiling enforced by the backend before storing.
|
||||
export const REPORT_MAX_BYTES = 32 * 1024;
|
||||
|
||||
+10
-1
@@ -30,7 +30,8 @@ frontend/
|
||||
│ (app-events bus / getips/ / valid-ip / analytics / …)
|
||||
├── composables/ ← Vue-aware `useXxx` logic
|
||||
└── components/ ← Home / StandaloneTool / top-level sections, plus
|
||||
ip-infos/ · advanced-tools/ · widgets/ · svgicons/ · ui/
|
||||
ip-infos/ · advanced-tools/ · report/ · widgets/ ·
|
||||
svgicons/ · ui/
|
||||
```
|
||||
|
||||
Directory-level only — every file opens with a header comment stating its
|
||||
@@ -60,6 +61,14 @@ New achievement = entry in `data/achievements.js` + rule + (only if no
|
||||
suitable event exists) a new domain event. Tests:
|
||||
`tests/achievement-rules.test.js`, `tests/composable-achievement-engine.test.js`.
|
||||
|
||||
The shareable diagnostic report rides the same bus: every "my network" test
|
||||
emits `<domain>:finished` with its full structured result;
|
||||
`composables/use-report-collector.js` (init'd once in App.vue) normalizes
|
||||
payloads through `utils/report-builders.js` into sections whitelisted by
|
||||
`common/report-schema.js`, and `components/report/` consumes the snapshots
|
||||
(share dialog + read-only /r/:id page). New reportable test = emit event +
|
||||
builder + schema entry, in the same change.
|
||||
|
||||
### Error monitoring (Sentry) is env-gated and invisible to app code
|
||||
|
||||
`sentry-init.js` loads via a build-time-gated dynamic import: no
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<FloatingDock :ready="showMaskButton" :mask-active="infoMaskLevel > 0">
|
||||
<InfoMask :showMaskButton.value="showMaskButton" :infoMaskLevel.value="infoMaskLevel"
|
||||
:toggleInfoMask="toggleInfoMask" />
|
||||
<ShareReport />
|
||||
<IPHistory />
|
||||
<QueryIP ref="queryIPRef" />
|
||||
</FloatingDock>
|
||||
@@ -49,6 +50,7 @@ import QueryIP from './widgets/QueryIP.vue';
|
||||
import HelpModal from './widgets/Help.vue';
|
||||
import InfoMask from './widgets/InfoMask.vue';
|
||||
import IPHistory from './widgets/IPHistory.vue';
|
||||
import ShareReport from './report/ShareReportDialog.vue';
|
||||
import FloatingDock from './widgets/FloatingDock.vue';
|
||||
|
||||
// Vue + Store
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<!-- Dock trigger (stateless panel → default variant, per FAB semantics) -->
|
||||
<JnTooltip :text="t('Tooltips.ShareReport')" side="left">
|
||||
<Button size="icon" type="button" variant="default" aria-label="Diagnostic Report"
|
||||
class="rounded-full shadow-lg cursor-pointer" @click="openDialog">
|
||||
<Share2 class="size-4" />
|
||||
</Button>
|
||||
</JnTooltip>
|
||||
|
||||
<Dialog :open="isOpen" @update:open="onOpenChange">
|
||||
<DialogContent :title="t('report.Title')" class="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader :icon="Share2" :title="t('report.Title')" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">{{ t('report.Note') }}</p>
|
||||
|
||||
<!-- Section checklist: completed tests toggle on/off, the rest
|
||||
stay disabled with a "not tested" hint. -->
|
||||
<div class="space-y-2">
|
||||
<Label class="font-medium">{{ t('report.SectionsLabel') }}</Label>
|
||||
<ToggleGroup v-model="selectedIds" type="multiple" variant="outline" :spacing="2"
|
||||
class="w-full flex-wrap justify-start" :aria-label="t('report.SectionsLabel')">
|
||||
<ToggleGroupItem v-for="id in visibleSectionIds" :key="id" :value="id"
|
||||
:disabled="!availableSectionIds.includes(id)"
|
||||
class="rounded-full border px-3 h-8 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground cursor-pointer disabled:cursor-not-allowed">
|
||||
{{ t(SECTION_TITLE_KEYS[id]) }}
|
||||
<span v-if="!availableSectionIds.includes(id)" class="opacity-60">
|
||||
({{ t('report.NotTested') }})
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<!-- Privacy + retention options -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="reportMaskTail" v-model="maskTail" />
|
||||
<Label for="reportMaskTail" class="font-normal cursor-pointer">
|
||||
{{ t('report.MaskTail') }}
|
||||
</Label>
|
||||
</div>
|
||||
<div v-if="sharingEnabled" class="flex items-center gap-2">
|
||||
<Label for="reportTtl" class="font-normal text-muted-foreground">
|
||||
{{ t('report.TtlLabel') }}
|
||||
</Label>
|
||||
<Select v-model="ttlDays">
|
||||
<SelectTrigger id="reportTtl" class="h-8 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="days in REPORT_TTL_DAYS" :key="days" :value="days">
|
||||
{{ t(`report.Ttl${days}`) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview: exactly what leaves the device (masking applied) -->
|
||||
<Collapsible v-model:open="previewOpen">
|
||||
<CollapsibleTrigger
|
||||
class="flex flex-row items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
<span>{{ t('report.Preview') }}<ChevronRight class="size-4 transition-transform" :class="previewOpen ? 'rotate-90' : ''" />
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre v-if="hasSelection"
|
||||
class="mt-2 p-3 rounded-md bg-muted font-mono text-[11px] leading-relaxed max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word">{{ previewMarkdown }}</pre>
|
||||
<p v-else class="mt-2 text-xs text-muted-foreground">{{ t('report.NoSelection') }}</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button v-if="sharingEnabled" type="button" variant="action" class="cursor-pointer"
|
||||
:disabled="!hasSelection || creating" @click="onCreateLink">
|
||||
<Spinner v-if="creating" />
|
||||
<Link2 v-else class="size-4 shrink-0" />
|
||||
{{ t('report.CreateLink') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="cursor-pointer" :disabled="!hasSelection"
|
||||
@click="onCopyForAI">
|
||||
<Bot class="size-4 shrink-0" />
|
||||
{{ t('report.CopyAI') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="cursor-pointer" :disabled="!hasSelection"
|
||||
@click="onDownloadJson">
|
||||
<FileJson class="size-4 shrink-0" />
|
||||
{{ t('report.DownloadJson') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="shareError" class="text-sm text-destructive">{{ t('report.CreateFailed') }}</p>
|
||||
|
||||
<!-- The created link — shown once, with its expiry -->
|
||||
<div v-if="shareLink" class="rounded-lg border bg-card p-3 space-y-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-mono text-sm break-all flex-1">{{ shareLink }}</span>
|
||||
<CopyButton :value="shareLink" :tooltip="t('report.CopyLink')" />
|
||||
</div>
|
||||
<p class="text-xs text-warning">{{ t('report.LinkOnce') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('report.ExpiresAt', { time: expiresAtDisplay }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Share-report dialog + its dock trigger. Reads the collector's snapshots,
|
||||
// lets the user pick sections / mask IP tails / choose the link TTL, and
|
||||
// fans out to the three exports: share link (env-gated via
|
||||
// configs.reportSharing), Markdown for AI assistants, JSON download.
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMainStore } from '@/store';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { useCollectedReport } from '@/composables/use-report-collector.js';
|
||||
import { useReportShare } from '@/composables/use-report-share.js';
|
||||
import { REPORT_SECTION_IDS, REPORT_TTL_DAYS } from '@/utils/report-schema.js';
|
||||
import { SECTION_TITLE_KEYS, buildShareReport, reportToMarkdown, downloadReportJson } from '@/utils/report-export.js';
|
||||
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import CopyButton from '@/components/widgets/CopyButton.vue';
|
||||
import { Share2, Link2, Bot, FileJson, ChevronRight } from '@lucide/vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
const { sections, availableSectionIds } = useCollectedReport();
|
||||
const { creating, shareLink, expiresAt, shareError, createShareLink, resetShareLink } = useReportShare();
|
||||
|
||||
const sharingEnabled = computed(() => store.configs?.reportSharing === true);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const selectedIds = ref([]);
|
||||
const maskTail = ref(false);
|
||||
const ttlDays = ref(REPORT_TTL_DAYS[0]);
|
||||
const previewOpen = ref(false);
|
||||
|
||||
// The two original-site-only tools (sign-in + private API) don't exist for
|
||||
// self-host visitors — same gate as Advanced.vue's card grid. Hiding them
|
||||
// here keeps the checklist honest: no permanently-"not tested" entries.
|
||||
const ORIGINAL_SITE_ONLY_SECTIONS = new Set(['invisibility', 'enhanceddnsleak']);
|
||||
const visibleSectionIds = computed(() => REPORT_SECTION_IDS.filter((id) =>
|
||||
!ORIGINAL_SITE_ONLY_SECTIONS.has(id) || store.configs?.originalSite === true));
|
||||
|
||||
const hasSelection = computed(() => selectedIds.value.length > 0);
|
||||
|
||||
const openDialog = () => {
|
||||
trackEvent('SideButtons', 'ToggleClick', 'ShareReport');
|
||||
// Fresh run each time: preselect everything that has data, drop any
|
||||
// previously created link (its report may no longer match the data).
|
||||
selectedIds.value = [...availableSectionIds.value];
|
||||
resetShareLink();
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const onOpenChange = (value) => { isOpen.value = value; };
|
||||
|
||||
// A stale link must never sit next to controls that no longer describe it.
|
||||
watch([selectedIds, maskTail], () => resetShareLink());
|
||||
|
||||
const assembleReport = () => buildShareReport({
|
||||
sections,
|
||||
selectedIds: selectedIds.value,
|
||||
maskTail: maskTail.value,
|
||||
locale: store.lang,
|
||||
origin: window.location.hostname,
|
||||
});
|
||||
|
||||
const previewMarkdown = computed(() => {
|
||||
if (!isOpen.value || !hasSelection.value) return '';
|
||||
return reportToMarkdown(assembleReport(), t, { masked: maskTail.value });
|
||||
});
|
||||
|
||||
const onCreateLink = async () => {
|
||||
trackEvent('Section', 'StartClick', 'ShareReportLink');
|
||||
await createShareLink(assembleReport(), ttlDays.value);
|
||||
};
|
||||
|
||||
const onCopyForAI = async () => {
|
||||
trackEvent('Section', 'StartClick', 'ShareReportCopyAI');
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportToMarkdown(assembleReport(), t, { masked: maskTail.value }));
|
||||
store.setAlert(true, 'text-success', t('report.CopiedMessage'), t('report.CopiedTitle'));
|
||||
} catch (error) {
|
||||
console.error('Copy for AI failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadJson = () => {
|
||||
trackEvent('Section', 'StartClick', 'ShareReportDownload');
|
||||
downloadReportJson(assembleReport());
|
||||
};
|
||||
|
||||
const expiresAtDisplay = computed(() =>
|
||||
expiresAt.value ? new Date(expiresAt.value).toLocaleString() : '');
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
// Share-link creation flow for the report dialog: POST the assembled report
|
||||
// to /api/report and expose the resulting /r/:id link plus creating/error
|
||||
// state. Feature availability (configs.reportSharing) is the dialog's check,
|
||||
// not this composable's.
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { fetchWithTimeout } from '../utils/fetch-with-timeout.js';
|
||||
|
||||
export const useReportShare = () => {
|
||||
const creating = ref(false);
|
||||
const shareLink = ref('');
|
||||
const expiresAt = ref('');
|
||||
const shareError = ref(false);
|
||||
|
||||
const createShareLink = async (report, ttlDays) => {
|
||||
creating.value = true;
|
||||
shareError.value = false;
|
||||
shareLink.value = '';
|
||||
expiresAt.value = '';
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ report, ttlDays }),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
if (!response.ok) throw new Error(`report create responded ${response.status}`);
|
||||
const data = await response.json();
|
||||
shareLink.value = `${window.location.origin}/r/${data.id}`;
|
||||
expiresAt.value = data.expiresAt;
|
||||
} catch (error) {
|
||||
console.error('Creating share link failed:', error);
|
||||
shareError.value = true;
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetShareLink = () => {
|
||||
shareLink.value = '';
|
||||
expiresAt.value = '';
|
||||
shareError.value = false;
|
||||
};
|
||||
|
||||
return { creating, shareLink, expiresAt, shareError, createShareLink, resetShareLink };
|
||||
};
|
||||
@@ -535,9 +535,51 @@
|
||||
"ViewOnMap": "View location on map",
|
||||
"InfoMask": "Hide IP Information",
|
||||
"IPHistory": "View IP History",
|
||||
"ShareReport": "Create Diagnostic Report",
|
||||
"QueryIP": "Query IP Address",
|
||||
"GithubLink": "View Source Code on GitHub"
|
||||
},
|
||||
"report": {
|
||||
"Title": "Diagnostic Report",
|
||||
"Note": "Bundle your completed test results into one report and share it with a friend or an AI assistant for network diagnosis. Nothing leaves this device until you copy, download or create a link.",
|
||||
"SectionsLabel": "Tests to include",
|
||||
"NotTested": "not tested",
|
||||
"MaskTail": "Mask IP tails",
|
||||
"TtlLabel": "Auto-delete after",
|
||||
"Ttl1": "1 day",
|
||||
"Ttl3": "3 days",
|
||||
"Ttl7": "7 days",
|
||||
"Preview": "Preview shared content",
|
||||
"CreateLink": "Create Share Link",
|
||||
"CopyAI": "Copy to ask AI",
|
||||
"DownloadJson": "Download JSON",
|
||||
"CopyLink": "Copy Link",
|
||||
"NoSelection": "Select at least one test first.",
|
||||
"CreateFailed": "Creating the share link failed, please try again later.",
|
||||
"LinkOnce": "This link is shown only once — copy it now. Anyone with the link can view the report until it expires.",
|
||||
"ExpiresAt": "The report auto-deletes at {time}.",
|
||||
"CopiedTitle": "Copied",
|
||||
"CopiedMessage": "Report copied — paste it to any AI assistant.",
|
||||
"ai": {
|
||||
"Heading": "MyIP Network Diagnostic Report",
|
||||
"Intro": "This report was generated by MyIP ({origin}) at {time} from network tests run in the user's browser. Field names are stable schema identifiers; all latency values are in milliseconds.",
|
||||
"Masked": "Note: IP addresses are partially masked for privacy (IPv4: last octet, IPv6: interface half, shown as \"x\").",
|
||||
"Instruction": "Based on the data above, analyze the health of this network. Point out anomalies (DNS leaks, WebRTC exposure, proxy/VPN traits, packet loss, high latency, routing issues) and give concrete troubleshooting suggestions.",
|
||||
"Sections": {
|
||||
"ipinfo": "Public IP addresses as seen by multiple independent sources (sources may disagree when a proxy or split routing is involved).",
|
||||
"connectivity": "Browser reachability probes against well-known websites; timeMs is the displayed round-trip, minTimeMs the best of repeated rounds.",
|
||||
"webrtc": "IPs exposed through WebRTC STUN — if these differ from the main public IP, the real address may be leaking past a proxy/VPN.",
|
||||
"dnsleak": "Egress IPs of the DNS resolvers that actually answered the browser's lookups — resolvers not belonging to the user's ISP/VPN provider indicate a DNS leak.",
|
||||
"speedtest": "Bandwidth, latency and loaded latency (bufferbloat) measured against Cloudflare; scores rate streaming/gaming/RTC experience, higher is better.",
|
||||
"pingtest": "ICMP ping statistics toward the user's IP from Globalping probes around the world.",
|
||||
"mtrtest": "Per-hop MTR traceroutes toward the user's IP from probes around the world; loss at intermediate hops can be mere ICMP rate-limiting, sustained loss from one hop onward indicates a real problem.",
|
||||
"ruletest": "Egress IPs observed when fetching 8 test endpoints — more than one unique IP means traffic is split across different routes (e.g. proxy rules).",
|
||||
"browserinfo": "Browser, OS and display environment of the tested device.",
|
||||
"invisibility": "Proxy/VPN detection scores (0-100, higher = more likely detected) with the individual detection signals that flagged.",
|
||||
"enhanceddnsleak": "Resolver-level DNS capture: every resolver IP that queried the test domain, with transport, ECS exposure and DNSSEC flags (do/cd)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipHistory": {
|
||||
"Title": "IP History",
|
||||
"Notes": "IPs detected while you use this site are recorded here. The data is stored only in this browser — it never syncs to your account or leaves your device. You can adjust the record retention time in the \"Preferences\".",
|
||||
|
||||
@@ -535,9 +535,51 @@
|
||||
"ViewOnMap": "Voir l'emplacement sur la carte",
|
||||
"InfoMask": "Masquer les informations IP",
|
||||
"IPHistory": "Voir l'historique des IP",
|
||||
"ShareReport": "Créer un rapport de diagnostic",
|
||||
"QueryIP": "Interroger l'adresse IP",
|
||||
"GithubLink": "Voir le code source sur GitHub"
|
||||
},
|
||||
"report": {
|
||||
"Title": "Rapport de diagnostic",
|
||||
"Note": "Regroupez les résultats de vos tests terminés dans un rapport à partager avec un ami ou un assistant IA pour diagnostiquer votre réseau. Rien ne quitte cet appareil avant que vous ne copiiez, téléchargiez ou créiez un lien.",
|
||||
"SectionsLabel": "Tests à inclure",
|
||||
"NotTested": "non testé",
|
||||
"MaskTail": "Masquer la fin des IP",
|
||||
"TtlLabel": "Suppression auto",
|
||||
"Ttl1": "1 jour",
|
||||
"Ttl3": "3 jours",
|
||||
"Ttl7": "7 jours",
|
||||
"Preview": "Aperçu du contenu partagé",
|
||||
"CreateLink": "Créer un lien de partage",
|
||||
"CopyAI": "Copier pour diagnostic IA",
|
||||
"DownloadJson": "Télécharger le JSON",
|
||||
"CopyLink": "Copier le lien",
|
||||
"NoSelection": "Sélectionnez d'abord au moins un test.",
|
||||
"CreateFailed": "La création du lien de partage a échoué, veuillez réessayer plus tard.",
|
||||
"LinkOnce": "Ce lien ne s'affiche qu'une seule fois — copiez-le maintenant. Toute personne disposant du lien peut consulter le rapport jusqu'à son expiration.",
|
||||
"ExpiresAt": "Le rapport sera automatiquement supprimé le {time}.",
|
||||
"CopiedTitle": "Copié",
|
||||
"CopiedMessage": "Rapport copié — collez-le dans n'importe quel assistant IA.",
|
||||
"ai": {
|
||||
"Heading": "Rapport de diagnostic réseau MyIP",
|
||||
"Intro": "Ce rapport a été généré par MyIP ({origin}) le {time} à partir de tests réseau exécutés dans le navigateur de l'utilisateur. Les noms de champs sont des identifiants stables du schéma ; toutes les latences sont en millisecondes.",
|
||||
"Masked": "Remarque : les adresses IP sont partiellement masquées pour la confidentialité (IPv4 : dernier octet, IPv6 : moitié d'interface, indiqués par « x »).",
|
||||
"Instruction": "À partir des données ci-dessus, analysez l'état de ce réseau. Signalez les anomalies (fuites DNS, exposition WebRTC, traits de proxy/VPN, pertes de paquets, latence élevée, problèmes de routage) et proposez des pistes de dépannage concrètes.",
|
||||
"Sections": {
|
||||
"ipinfo": "Adresses IP publiques vues par plusieurs sources indépendantes (elles peuvent diverger en présence d'un proxy ou d'un routage réparti).",
|
||||
"connectivity": "Sondes d'accessibilité du navigateur vers des sites connus ; timeMs est l'aller-retour affiché, minTimeMs le meilleur des tours répétés.",
|
||||
"webrtc": "IP exposées via WebRTC STUN — si elles diffèrent de l'IP publique principale, l'adresse réelle peut fuir malgré un proxy/VPN.",
|
||||
"dnsleak": "IP de sortie des résolveurs DNS ayant réellement répondu aux requêtes du navigateur — des résolveurs n'appartenant pas au FAI/fournisseur VPN de l'utilisateur indiquent une fuite DNS.",
|
||||
"speedtest": "Bande passante, latence et latence en charge (bufferbloat) mesurées contre Cloudflare ; les scores évaluent le streaming, le jeu et la RTC, plus haut est meilleur.",
|
||||
"pingtest": "Statistiques de ping ICMP vers l'IP de l'utilisateur depuis des sondes Globalping du monde entier.",
|
||||
"mtrtest": "Traceroutes MTR saut par saut vers l'IP de l'utilisateur depuis des sondes mondiales ; la perte sur des sauts intermédiaires peut n'être que du rate-limiting ICMP, une perte continue à partir d'un saut indique un vrai problème.",
|
||||
"ruletest": "IP de sortie observées en accédant à 8 points de test — plusieurs IP distinctes signifient que le trafic est réparti sur différentes routes (règles de proxy, par ex.).",
|
||||
"browserinfo": "Navigateur, système et environnement d'affichage de l'appareil testé.",
|
||||
"invisibility": "Scores de détection proxy/VPN (0-100, plus haut = plus probablement détecté) avec les signaux de détection déclenchés.",
|
||||
"enhanceddnsleak": "Capture DNS au niveau résolveur : chaque IP de résolveur ayant interrogé le domaine de test, avec transport, exposition ECS et indicateurs DNSSEC (do/cd)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipHistory": {
|
||||
"Title": "Historique des IP",
|
||||
"Notes": "Les IP détectées lors de votre utilisation du site sont enregistrées ici. Les données sont stockées uniquement dans ce navigateur — elles ne sont jamais synchronisées avec votre compte et ne quittent pas votre appareil. Vous pouvez ajuster le délai de conservation des enregistrements dans les \"Préférences\".",
|
||||
|
||||
@@ -535,9 +535,51 @@
|
||||
"ViewOnMap": "Konumu haritada görüntüle",
|
||||
"InfoMask": "IP Bilgilerini Gizle",
|
||||
"IPHistory": "IP Geçmişini Görüntüle",
|
||||
"ShareReport": "Tanı Raporu Oluştur",
|
||||
"QueryIP": "IP Adresini Sorgula",
|
||||
"GithubLink": "Kaynak Kodunu GitHub'da Görüntüle"
|
||||
},
|
||||
"report": {
|
||||
"Title": "Tanı Raporu",
|
||||
"Note": "Tamamlanan test sonuçlarını tek bir raporda toplayın ve ağ teşhisi için bir arkadaşınızla veya bir yapay zekâ asistanıyla paylaşın. Siz kopyalayana, indirene veya bağlantı oluşturana kadar hiçbir veri bu cihazdan çıkmaz.",
|
||||
"SectionsLabel": "Dahil edilecek testler",
|
||||
"NotTested": "test edilmedi",
|
||||
"MaskTail": "IP sonlarını maskele",
|
||||
"TtlLabel": "Otomatik silme",
|
||||
"Ttl1": "1 gün",
|
||||
"Ttl3": "3 gün",
|
||||
"Ttl7": "7 gün",
|
||||
"Preview": "Paylaşılacak içeriği önizle",
|
||||
"CreateLink": "Paylaşım Bağlantısı Oluştur",
|
||||
"CopyAI": "Yapay Zekâya Kopyala",
|
||||
"DownloadJson": "JSON İndir",
|
||||
"CopyLink": "Bağlantıyı Kopyala",
|
||||
"NoSelection": "Önce en az bir test seçin.",
|
||||
"CreateFailed": "Paylaşım bağlantısı oluşturulamadı, lütfen daha sonra tekrar deneyin.",
|
||||
"LinkOnce": "Bu bağlantı yalnızca bir kez gösterilir — şimdi kopyalayın. Bağlantıya sahip olan herkes, süresi dolana kadar raporu görüntüleyebilir.",
|
||||
"ExpiresAt": "Rapor {time} tarihinde otomatik olarak silinecek.",
|
||||
"CopiedTitle": "Kopyalandı",
|
||||
"CopiedMessage": "Rapor kopyalandı — herhangi bir yapay zekâ asistanına yapıştırabilirsiniz.",
|
||||
"ai": {
|
||||
"Heading": "MyIP Ağ Tanı Raporu",
|
||||
"Intro": "Bu rapor, kullanıcının tarayıcısında çalıştırılan ağ testlerinden MyIP ({origin}) tarafından {time} tarihinde oluşturuldu. Alan adları kararlı şema tanımlayıcılarıdır; tüm gecikme değerleri milisaniye cinsindendir.",
|
||||
"Masked": "Not: Gizlilik için IP adreslerinin sonları maskelenmiştir (IPv4: son bölüm, IPv6: arayüz yarısı, \"x\" ile gösterilir).",
|
||||
"Instruction": "Yukarıdaki verilere dayanarak bu ağın sağlığını analiz edin. Anormallikleri belirtin (DNS sızıntıları, WebRTC ifşası, proxy/VPN belirtileri, paket kaybı, yüksek gecikme, yönlendirme sorunları) ve somut sorun giderme önerileri verin.",
|
||||
"Sections": {
|
||||
"ipinfo": "Birden çok bağımsız kaynağın gördüğü genel IP adresleri (proxy veya bölünmüş yönlendirme varsa kaynaklar farklı sonuç verebilir).",
|
||||
"connectivity": "Tarayıcının bilinen sitelere erişilebilirlik denemeleri; timeMs gösterilen gidiş-dönüş süresi, minTimeMs tekrarlanan turların en iyisidir.",
|
||||
"webrtc": "WebRTC STUN üzerinden açığa çıkan IP'ler — ana genel IP'den farklıysa gerçek adres proxy/VPN'i aşarak sızıyor olabilir.",
|
||||
"dnsleak": "Tarayıcının sorgularını gerçekten yanıtlayan DNS çözümleyicilerin çıkış IP'leri — kullanıcının ISS/VPN sağlayıcısına ait olmayan çözümleyiciler DNS sızıntısına işaret eder.",
|
||||
"speedtest": "Cloudflare'a karşı ölçülen bant genişliği, gecikme ve yük altındaki gecikme (bufferbloat); puanlar akış/oyun/RTC deneyimini değerlendirir, yüksek olan iyidir.",
|
||||
"pingtest": "Dünya genelindeki Globalping sondalarından kullanıcının IP'sine ICMP ping istatistikleri.",
|
||||
"mtrtest": "Dünya genelindeki sondalardan kullanıcının IP'sine atlama atlama MTR izleme; ara atlamalardaki kayıp ICMP hız sınırlaması olabilir, bir atlamadan itibaren süren kayıp gerçek bir soruna işaret eder.",
|
||||
"ruletest": "8 test noktasına erişirken gözlemlenen çıkış IP'leri — birden fazla farklı IP, trafiğin farklı rotalara bölündüğünü gösterir (ör. proxy kuralları).",
|
||||
"browserinfo": "Test edilen cihazın tarayıcısı, işletim sistemi ve ekran ortamı.",
|
||||
"invisibility": "Proxy/VPN tespit puanları (0-100, yüksek = tespit olasılığı fazla) ve tetiklenen tespit sinyalleri.",
|
||||
"enhanceddnsleak": "Çözümleyici düzeyinde DNS yakalama: test alan adını sorgulayan her çözümleyici IP'si; taşıma, ECS ifşası ve DNSSEC bayraklarıyla (do/cd)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipHistory": {
|
||||
"Title": "IP Geçmişi",
|
||||
"Notes": "Siteyi kullanırken tespit edilen IP'ler burada kaydedilir. Veriler yalnızca bu tarayıcıda saklanır — hesabınızla asla eşitlenmez ve cihazınızdan ayrılmaz. Kayıtların saklanma süresini \"Tercihler\" bölümünde ayarlayabilirsiniz.",
|
||||
|
||||
@@ -535,9 +535,51 @@
|
||||
"ViewOnMap": "在地图上查看位置",
|
||||
"InfoMask": "隐藏信息",
|
||||
"IPHistory": "查看 IP 历史记录",
|
||||
"ShareReport": "生成诊断报告",
|
||||
"QueryIP": "查询 IP 信息",
|
||||
"GithubLink": "查看源代码"
|
||||
},
|
||||
"report": {
|
||||
"Title": "诊断报告",
|
||||
"Note": "把已完成的测试结果打包成一份报告,分享给朋友或 AI 协助诊断网络问题。在你复制、下载或生成链接之前,数据不会离开本机。",
|
||||
"SectionsLabel": "选择要包含的测试",
|
||||
"NotTested": "未测试",
|
||||
"MaskTail": "遮盖 IP 尾段",
|
||||
"TtlLabel": "自动删除",
|
||||
"Ttl1": "1 天",
|
||||
"Ttl3": "3 天",
|
||||
"Ttl7": "7 天",
|
||||
"Preview": "预览分享内容",
|
||||
"CreateLink": "生成分享链接",
|
||||
"CopyAI": "复制给 AI 诊断",
|
||||
"DownloadJson": "下载 JSON",
|
||||
"CopyLink": "复制链接",
|
||||
"NoSelection": "请先选择至少一项测试。",
|
||||
"CreateFailed": "生成分享链接失败,请稍后再试。",
|
||||
"LinkOnce": "链接仅显示这一次,请立即复制。任何拿到链接的人都能在过期前查看这份报告。",
|
||||
"ExpiresAt": "报告将于 {time} 自动删除。",
|
||||
"CopiedTitle": "已复制",
|
||||
"CopiedMessage": "报告已复制,可直接粘贴给任意 AI 助手。",
|
||||
"ai": {
|
||||
"Heading": "MyIP 网络诊断报告",
|
||||
"Intro": "本报告由 MyIP({origin})于 {time} 生成,数据来自在用户浏览器中运行的网络测试。字段名为稳定的 schema 标识符;所有延迟数值单位为毫秒。",
|
||||
"Masked": "注意:出于隐私考虑,报告中的 IP 地址已遮盖尾段(IPv4 末段、IPv6 接口位,以 \"x\" 表示)。",
|
||||
"Instruction": "请基于以上数据分析该网络的健康状况,指出异常项(DNS 泄漏、WebRTC 暴露、代理/VPN 特征、丢包、高延迟、路由问题等),并给出具体的排查建议。",
|
||||
"Sections": {
|
||||
"ipinfo": "多个独立来源看到的公网 IP(存在代理或分流时不同来源可能不一致)。",
|
||||
"connectivity": "浏览器对知名网站的可达性探测;timeMs 为展示的往返耗时,minTimeMs 为多轮中的最佳值。",
|
||||
"webrtc": "通过 WebRTC STUN 暴露的 IP——若与主公网 IP 不同,真实地址可能绕过代理/VPN 泄漏。",
|
||||
"dnsleak": "实际应答浏览器查询的 DNS 解析器出口 IP——解析器不属于用户的 ISP/VPN 供应商即为 DNS 泄漏迹象。",
|
||||
"speedtest": "对 Cloudflare 测得的带宽、延迟与负载延迟(bufferbloat);分数衡量流媒体/游戏/实时通话体验,越高越好。",
|
||||
"pingtest": "全球 Globalping 探针对用户 IP 的 ICMP ping 统计。",
|
||||
"mtrtest": "全球探针到用户 IP 的逐跳 MTR 路由追踪;中间跳的丢包可能只是 ICMP 限速,从某一跳起持续丢包才代表真实问题。",
|
||||
"ruletest": "访问 8 个测试端点时观测到的出口 IP——出现多个不同 IP 说明流量被分流到不同线路(如代理规则)。",
|
||||
"browserinfo": "被测设备的浏览器、操作系统与显示环境。",
|
||||
"invisibility": "代理/VPN 检测得分(0-100,越高越可能被识别)及各项命中的检测信号。",
|
||||
"enhanceddnsleak": "解析器级 DNS 捕获:向测试域名发起查询的每个解析器 IP,含传输协议、ECS 暴露与 DNSSEC 标志(do/cd)。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipHistory": {
|
||||
"Title": "IP 历史记录",
|
||||
"Notes": "这里记录你使用本站时被侦测到的 IP。数据仅保存在当前浏览器本地,不会同步到账号,也不会离开你的设备。你可以在「偏好设置」中调整记录保留时间。",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Turns collected section snapshots into the share outputs: the versioned
|
||||
// report envelope (POSTed for the /r/:id link and offered as JSON download)
|
||||
// and the self-describing Markdown handed to AI assistants. Pure functions —
|
||||
// the share dialog owns all interaction state. Markdown prose is localized
|
||||
// via the caller's t(); data tables keep the schema's English field names on
|
||||
// purpose (stable identifiers for AI consumption).
|
||||
|
||||
import { REPORT_VERSION, REPORT_SECTION_IDS, maskReportIps } from './report-schema.js';
|
||||
|
||||
// Section id → the site's existing i18n title key (dialog checklist and
|
||||
// Markdown headings reuse the same titles the homepage sections show).
|
||||
export const SECTION_TITLE_KEYS = {
|
||||
ipinfo: 'ipInfos.Title',
|
||||
connectivity: 'connectivity.Title',
|
||||
webrtc: 'webrtc.Title',
|
||||
dnsleak: 'dnsleaktest.Title',
|
||||
speedtest: 'speedtest.Title',
|
||||
pingtest: 'pingtest.Title',
|
||||
mtrtest: 'mtrtest.Title',
|
||||
ruletest: 'ruletest.Title',
|
||||
browserinfo: 'browserinfo.Title',
|
||||
invisibility: 'invisibilitytest.Title',
|
||||
enhanceddnsleak: 'enhanceddnsleaktest.Title',
|
||||
};
|
||||
|
||||
// Assemble the envelope from collected snapshots. Sections keep homepage
|
||||
// order regardless of selection order; snapshots are deep-copied (they're
|
||||
// JSON-safe by construction) so later test re-runs can't mutate an already
|
||||
// exported report; masking applies at this boundary so all outputs share
|
||||
// the exact same data.
|
||||
export const buildShareReport = ({ sections, selectedIds, maskTail, locale, origin }) => {
|
||||
const picked = {};
|
||||
for (const id of REPORT_SECTION_IDS) {
|
||||
if (selectedIds.includes(id) && sections[id]) {
|
||||
picked[id] = JSON.parse(JSON.stringify(sections[id]));
|
||||
}
|
||||
}
|
||||
const report = {
|
||||
v: REPORT_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
origin,
|
||||
locale,
|
||||
sections: picked,
|
||||
};
|
||||
return maskTail ? maskReportIps(report) : report;
|
||||
};
|
||||
|
||||
// --- Markdown rendering -------------------------------------------------------
|
||||
|
||||
const cell = (value) => {
|
||||
if (value === undefined || value === null || value === '') return '—';
|
||||
if (value === true) return 'yes';
|
||||
if (value === false) return 'no';
|
||||
return String(value).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
};
|
||||
|
||||
const mdTable = (headers, rows) => [
|
||||
`| ${headers.join(' | ')} |`,
|
||||
`| ${headers.map(() => '---').join(' | ')} |`,
|
||||
...rows.map((row) => `| ${row.map(cell).join(' | ')} |`),
|
||||
].join('\n');
|
||||
|
||||
const kvLines = (pairs) => pairs
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => `- ${key}: ${cell(value)}`)
|
||||
.join('\n');
|
||||
|
||||
// MTR hop columns vary by mtr build — render the union of stats the probe
|
||||
// actually carries, in a stable order.
|
||||
const MTR_HOP_STATS = ['lossPct', 'sntCount', 'drop', 'rcv', 'lastMs', 'avgMs', 'bestMs', 'worstMs', 'stdevMs', 'javgMs'];
|
||||
|
||||
const SECTION_RENDERERS = {
|
||||
ipinfo: (section) => mdTable(
|
||||
['source', 'ip', 'countryCode', 'region', 'city', 'asn', 'isp'],
|
||||
section.cards.map((c) => [c.source, c.ip, c.countryCode, c.region, c.city, c.asn, c.isp]),
|
||||
),
|
||||
connectivity: (section) => mdTable(
|
||||
['id', 'name', 'status', 'timeMs', 'minTimeMs', 'custom'],
|
||||
section.targets.map((t) => [t.id, t.name, t.status, t.timeMs, t.minTimeMs, t.custom]),
|
||||
),
|
||||
webrtc: (section) => mdTable(
|
||||
['id', 'url', 'ip', 'natType', 'countryCode', 'org'],
|
||||
section.servers.map((s) => [s.id, s.url, s.ip, s.natType, s.countryCode, s.org]),
|
||||
),
|
||||
dnsleak: (section) => mdTable(
|
||||
['id', 'ip', 'countryCode', 'org'],
|
||||
section.providers.map((p) => [p.id, p.ip, p.countryCode, p.org]),
|
||||
),
|
||||
speedtest: (section) => {
|
||||
const parts = [kvLines([
|
||||
['downloadMbps', section.downloadMbps],
|
||||
['uploadMbps', section.uploadMbps],
|
||||
['latencyMs', section.latencyMs],
|
||||
['jitterMs', section.jitterMs],
|
||||
['loadedLatencyDownMs', section.loadedLatencyDownMs],
|
||||
['loadedLatencyUpMs', section.loadedLatencyUpMs],
|
||||
])];
|
||||
if (section.scores) {
|
||||
parts.push(kvLines(Object.entries(section.scores).map(
|
||||
([key, score]) => [`score.${key}`, `${score} (${section.qualities?.[key] ?? '—'})`],
|
||||
)));
|
||||
}
|
||||
if (section.connection) {
|
||||
parts.push(kvLines(Object.entries(section.connection).map(
|
||||
([key, value]) => [`connection.${key}`, value],
|
||||
)));
|
||||
}
|
||||
return parts.filter(Boolean).join('\n');
|
||||
},
|
||||
pingtest: (section) => [
|
||||
`target: ${cell(section.target)}`,
|
||||
mdTable(
|
||||
['countryCode', 'min', 'avg', 'max', 'loss%', 'total', 'rcv', 'drop'],
|
||||
section.probes.map((p) => [p.countryCode, p.stats.min, p.stats.avg, p.stats.max, p.stats.loss, p.stats.total, p.stats.rcv, p.stats.drop]),
|
||||
),
|
||||
].join('\n'),
|
||||
mtrtest: (section) => {
|
||||
const blocks = [`target: ${cell(section.target)}`];
|
||||
for (const probe of section.probes) {
|
||||
const stats = MTR_HOP_STATS.filter((key) => probe.hops.some((hop) => hop[key] !== undefined));
|
||||
// "DE Falkenstein — Hetzner (AS24940)", absent parts omitted.
|
||||
const place = [probe.countryCode, probe.city].filter(Boolean).join(' ');
|
||||
const network = [probe.network, probe.asn !== undefined ? `(AS${probe.asn})` : undefined]
|
||||
.filter(Boolean).join(' ');
|
||||
blocks.push(`### probe: ${[place, network].filter(Boolean).join(' — ')}`);
|
||||
blocks.push(mdTable(
|
||||
['n', 'host', 'ip', 'asn', ...stats],
|
||||
probe.hops.map((hop) => [hop.n, hop.host, hop.ip, hop.asn, ...stats.map((key) => hop[key])]),
|
||||
));
|
||||
}
|
||||
return blocks.join('\n');
|
||||
},
|
||||
ruletest: (section) => [
|
||||
`uniqueIPCount: ${section.uniqueIPCount}`,
|
||||
mdTable(
|
||||
['id', 'ip', 'countryCode', 'org'],
|
||||
section.workers.map((w) => [w.id, w.ip, w.countryCode, w.org]),
|
||||
),
|
||||
].join('\n'),
|
||||
browserinfo: (section) => kvLines([
|
||||
['browser', section.browser && `${section.browser.name ?? ''} ${section.browser.version ?? ''}`.trim()],
|
||||
['os', section.os && `${section.os.name ?? ''} ${section.os.version ?? ''}`.trim()],
|
||||
['engine', section.engine && `${section.engine.name ?? ''} ${section.engine.version ?? ''}`.trim()],
|
||||
['timezone', section.timezone],
|
||||
['languages', section.languages?.join(', ')],
|
||||
['display', section.display && `${section.display.width}x${section.display.height} @${section.display.pixelRatio}x`],
|
||||
['connection', section.connection && `${section.connection.effectiveType ?? ''} downlink=${section.connection.downlink ?? '—'}Mbps rtt=${section.connection.rtt ?? '—'}ms`.trim()],
|
||||
]),
|
||||
invisibility: (section) => [
|
||||
kvLines([
|
||||
['ip', section.ip],
|
||||
['score.proxy', section.scores.proxy],
|
||||
['score.vpn', section.scores.vpn],
|
||||
]),
|
||||
mdTable(['signal', 'flagged'], section.flags.map((f) => [f.key, f.flagged])),
|
||||
].join('\n'),
|
||||
enhanceddnsleak: (section) => [
|
||||
kvLines([
|
||||
['rawCount', section.rawCount],
|
||||
['resolverCount', section.resolverCount],
|
||||
['dnssec', section.dnssec],
|
||||
]),
|
||||
mdTable(
|
||||
['ip', 'countryCode', 'asn', 'org', 'transport', 'ecs', 'do', 'cd'],
|
||||
section.queries.map((q) => [q.ip, q.countryCode, q.asn, q.org, q.transport, q.ecs, q.do, q.cd]),
|
||||
),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
// Render the whole report as a self-describing Markdown prompt: localized
|
||||
// prose explains each test and how to read it, the closing line asks the AI
|
||||
// for a diagnosis. `masked` adds the privacy note about masked IP tails.
|
||||
export const reportToMarkdown = (report, t, { masked = false } = {}) => {
|
||||
const lines = [
|
||||
`# ${t('report.ai.Heading')}`,
|
||||
'',
|
||||
t('report.ai.Intro', { origin: report.origin, time: report.generatedAt }),
|
||||
];
|
||||
if (masked) lines.push('', t('report.ai.Masked'));
|
||||
for (const id of REPORT_SECTION_IDS) {
|
||||
const section = report.sections[id];
|
||||
if (!section) continue;
|
||||
lines.push(
|
||||
'',
|
||||
`## ${t(SECTION_TITLE_KEYS[id])}`,
|
||||
'',
|
||||
t(`report.ai.Sections.${id}`),
|
||||
`testedAt: ${section.testedAt}`,
|
||||
'',
|
||||
SECTION_RENDERERS[id](section),
|
||||
);
|
||||
}
|
||||
lines.push('', '---', '', t('report.ai.Instruction'), '');
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
// --- JSON download --------------------------------------------------------------
|
||||
|
||||
export const reportFileName = (generatedAt) =>
|
||||
`myip-report-${String(generatedAt).slice(0, 10)}.json`;
|
||||
|
||||
export const downloadReportJson = (report) => {
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = reportFileName(report.generatedAt);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js';
|
||||
import serviceStatusHandler, {
|
||||
detailHandler as serviceStatusDetailHandler,
|
||||
} from '../api/service-status.js';
|
||||
import createReportHandler, { getReport as getReportHandler } from '../api/share-report.js';
|
||||
import createReportHandler, { getReport as getReportHandler, normalizeTtlDays } from '../api/share-report.js';
|
||||
import { REPORT_VERSION } from '../common/report-schema.js';
|
||||
|
||||
// -- shared test utilities ------------------------------------------------
|
||||
@@ -413,13 +413,12 @@ describe('share-report handlers', () => {
|
||||
assert.equal(res.statusCode, 503);
|
||||
});
|
||||
|
||||
it('POST rejects a ttlDays outside the whitelist', async () => {
|
||||
kvEnv();
|
||||
for (const ttlDays of [0, 2, 365, '7', null]) {
|
||||
const res = createResponse();
|
||||
await createReportHandler(createReq({ report: validReport(), ttlDays }), res);
|
||||
assert.equal(res.statusCode, 400, `ttlDays=${ttlDays} should 400`);
|
||||
assert.equal(res.body.error, 'Invalid ttlDays');
|
||||
it('forces a tampered ttlDays down to 1 day, keeps whitelisted values', () => {
|
||||
for (const ttlDays of [0, 2, 30, 365, -1, '7', null, undefined]) {
|
||||
assert.equal(normalizeTtlDays(ttlDays), 1, `ttlDays=${ttlDays} should coerce to 1`);
|
||||
}
|
||||
for (const ttlDays of [1, 3, 7]) {
|
||||
assert.equal(normalizeTtlDays(ttlDays), ttlDays);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Exercises the report export layer: envelope assembly (section selection,
|
||||
// ordering, deep-copy isolation, masking), the AI-facing Markdown generator
|
||||
// and the download filename. i18n is stubbed — the generator only needs a
|
||||
// t() shape, prose content is irrelevant here.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
SECTION_TITLE_KEYS,
|
||||
buildShareReport,
|
||||
reportToMarkdown,
|
||||
reportFileName,
|
||||
} from '../frontend/utils/report-export.js';
|
||||
import { REPORT_SECTION_IDS, REPORT_VERSION, validateReport } from '../common/report-schema.js';
|
||||
|
||||
// t() stub: returns the key, with params appended so interpolation is visible.
|
||||
const t = (key, params) => (params ? `${key}[${Object.values(params).join(',')}]` : key);
|
||||
|
||||
const makeSections = () => ({
|
||||
ipinfo: {
|
||||
testedAt: '2026-07-14T08:00:00.000Z',
|
||||
cards: [{ source: 'IPCheck.ing IPv4', ip: '1.2.3.4', countryCode: 'US', city: 'LA', asn: 'AS15169', isp: 'Google' }],
|
||||
},
|
||||
speedtest: {
|
||||
testedAt: '2026-07-14T08:05:00.000Z',
|
||||
downloadMbps: 500.2,
|
||||
uploadMbps: 42.1,
|
||||
latencyMs: 12,
|
||||
scores: { streaming: 90, gaming: 70, rtc: 80 },
|
||||
qualities: { streaming: 'great', gaming: 'good', rtc: 'good' },
|
||||
connection: { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK' },
|
||||
},
|
||||
mtrtest: {
|
||||
testedAt: '2026-07-14T08:10:00.000Z',
|
||||
target: '1.2.3.4',
|
||||
probes: [{
|
||||
countryCode: 'DE',
|
||||
network: 'Hetzner',
|
||||
asn: 24940,
|
||||
hops: [
|
||||
{ n: 1, host: '_gateway', ip: '172.17.0.1', lossPct: 0, avgMs: 0.3 },
|
||||
{ n: 2, lossPct: 100 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
describe('buildShareReport', () => {
|
||||
it('assembles a schema-valid envelope from selected sections', () => {
|
||||
const report = buildShareReport({
|
||||
sections: makeSections(),
|
||||
selectedIds: ['speedtest', 'ipinfo', 'mtrtest'],
|
||||
maskTail: false,
|
||||
locale: 'zh',
|
||||
origin: 'ipcheck.ing',
|
||||
});
|
||||
assert.equal(report.v, REPORT_VERSION);
|
||||
assert.equal(report.origin, 'ipcheck.ing');
|
||||
assert.equal(report.locale, 'zh');
|
||||
assert.ok(!Number.isNaN(Date.parse(report.generatedAt)));
|
||||
// Homepage order, not selection order.
|
||||
assert.deepEqual(Object.keys(report.sections), ['ipinfo', 'speedtest', 'mtrtest']);
|
||||
assert.deepEqual(validateReport(report), { ok: true, errors: [] });
|
||||
});
|
||||
|
||||
it('includes only selected sections and skips ids without data', () => {
|
||||
const report = buildShareReport({
|
||||
sections: makeSections(),
|
||||
selectedIds: ['speedtest', 'webrtc'], // webrtc has no snapshot
|
||||
maskTail: false,
|
||||
locale: 'en',
|
||||
origin: 'localhost',
|
||||
});
|
||||
assert.deepEqual(Object.keys(report.sections), ['speedtest']);
|
||||
});
|
||||
|
||||
it('applies tail masking across sections when asked', () => {
|
||||
const report = buildShareReport({
|
||||
sections: makeSections(),
|
||||
selectedIds: ['ipinfo', 'speedtest', 'mtrtest'],
|
||||
maskTail: true,
|
||||
locale: 'en',
|
||||
origin: 'localhost',
|
||||
});
|
||||
assert.equal(report.sections.ipinfo.cards[0].ip, '1.2.3.x');
|
||||
assert.equal(report.sections.speedtest.connection.ip, '104.16.0.x');
|
||||
assert.equal(report.sections.mtrtest.target, '1.2.3.x');
|
||||
assert.equal(report.sections.mtrtest.probes[0].hops[0].ip, '172.17.0.x');
|
||||
assert.equal(validateReport(report).ok, true);
|
||||
});
|
||||
|
||||
it('deep-copies snapshots so later mutations cannot alter an exported report', () => {
|
||||
const sections = makeSections();
|
||||
const report = buildShareReport({
|
||||
sections, selectedIds: ['ipinfo'], maskTail: false, locale: 'en', origin: 'localhost',
|
||||
});
|
||||
sections.ipinfo.cards[0].ip = '9.9.9.9';
|
||||
assert.equal(report.sections.ipinfo.cards[0].ip, '1.2.3.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTION_TITLE_KEYS', () => {
|
||||
it('covers every schema section id', () => {
|
||||
assert.deepEqual(Object.keys(SECTION_TITLE_KEYS).sort(), [...REPORT_SECTION_IDS].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportToMarkdown', () => {
|
||||
const makeReport = (maskTail = false) => buildShareReport({
|
||||
sections: makeSections(),
|
||||
selectedIds: ['ipinfo', 'speedtest', 'mtrtest'],
|
||||
maskTail,
|
||||
locale: 'en',
|
||||
origin: 'ipcheck.ing',
|
||||
});
|
||||
|
||||
it('renders heading, intro, per-section blocks and the closing instruction', () => {
|
||||
const md = reportToMarkdown(makeReport(), t);
|
||||
assert.ok(md.startsWith('# report.ai.Heading'));
|
||||
assert.ok(md.includes('report.ai.Intro[ipcheck.ing'));
|
||||
for (const id of ['ipinfo', 'speedtest', 'mtrtest']) {
|
||||
assert.ok(md.includes(`## ${SECTION_TITLE_KEYS[id]}`), `missing title for ${id}`);
|
||||
assert.ok(md.includes(`report.ai.Sections.${id}`), `missing explanation for ${id}`);
|
||||
}
|
||||
assert.ok(md.trimEnd().endsWith('report.ai.Instruction'));
|
||||
// Data made it into the tables.
|
||||
assert.ok(md.includes('| IPCheck.ing IPv4 | 1.2.3.4 |'));
|
||||
assert.ok(md.includes('- downloadMbps: 500.2'));
|
||||
assert.ok(md.includes('### probe: DE — Hetzner (AS24940)'));
|
||||
});
|
||||
|
||||
it('omits sections absent from the report and skips the masked note by default', () => {
|
||||
const md = reportToMarkdown(makeReport(), t);
|
||||
assert.ok(!md.includes(SECTION_TITLE_KEYS.webrtc));
|
||||
assert.ok(!md.includes('report.ai.Masked'));
|
||||
});
|
||||
|
||||
it('adds the masked note when masked, and hop tables only carry present stats', () => {
|
||||
const md = reportToMarkdown(makeReport(true), t, { masked: true });
|
||||
assert.ok(md.includes('report.ai.Masked'));
|
||||
assert.ok(md.includes('1.2.3.x'));
|
||||
// The mtr fixture has lossPct/avgMs only — absent stats stay out of the header.
|
||||
assert.ok(md.includes('| n | host | ip | asn | lossPct | avgMs |'));
|
||||
assert.ok(!md.includes('worstMs'));
|
||||
// The no-reply hop renders em-dash placeholders, keeping row width.
|
||||
assert.ok(md.includes('| 2 | — | — | — | 100 | — |'));
|
||||
});
|
||||
|
||||
it('escapes pipe characters inside values', () => {
|
||||
const report = makeReport();
|
||||
report.sections.ipinfo.cards[0].isp = 'Evil|Corp';
|
||||
const md = reportToMarkdown(report, t);
|
||||
assert.ok(md.includes('Evil\\|Corp'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportFileName', () => {
|
||||
it('derives a dated json filename from generatedAt', () => {
|
||||
assert.equal(reportFileName('2026-07-14T08:00:00.000Z'), 'myip-report-2026-07-14.json');
|
||||
});
|
||||
});
|
||||
@@ -241,7 +241,7 @@ describe('validateReport', () => {
|
||||
|
||||
describe('constants', () => {
|
||||
it('exposes ttl choices and section ids', () => {
|
||||
assert.deepEqual(REPORT_TTL_DAYS, [1, 7, 30]);
|
||||
assert.deepEqual(REPORT_TTL_DAYS, [1, 3, 7]);
|
||||
assert.ok(REPORT_SECTION_IDS.includes('ipinfo'));
|
||||
assert.ok(REPORT_SECTION_IDS.includes('enhanceddnsleak'));
|
||||
assert.equal(REPORT_SECTION_IDS.length, 11);
|
||||
|
||||
Reference in new issue
Block a user