From 6f4c96e64b22fe6abb58086cb5d52e805ee47c88 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:18:58 +0800 Subject: [PATCH 01/36] Refactor(rdap): share IP arithmetic through common/ip-math.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rdap.js carried its own BigInt IP parser and CIDR containment; the new common/ip-math.js (strict parsers, RFC 5952 formatting, mask / count math, split / aggregate / range → CIDR) becomes the single implementation, bridged to the front-end for the upcoming IP Calculator. Bootstrap matching is unchanged; tests/rdap-ip.test.js still passes. Co-Authored-By: Claude Fable 5.1 --- common/ip-math.js | 374 +++++++++++++++++++++++++++++++++++++ common/rdap.js | 40 +--- frontend/utils/ip-math.js | 6 + tests/ip-math.test.js | 375 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 761 insertions(+), 34 deletions(-) create mode 100644 common/ip-math.js create mode 100644 frontend/utils/ip-math.js create mode 100644 tests/ip-math.test.js diff --git a/common/ip-math.js b/common/ip-math.js new file mode 100644 index 000000000..da25272dd --- /dev/null +++ b/common/ip-math.js @@ -0,0 +1,374 @@ +// IP address arithmetic shared by the IP Calculator (front-end) and rdap.js's +// CIDR containment (back-end). Both families are BigInt: 32-bit `<<` goes +// negative on 240.0.0.0, and IPv6 counts exceed Number anyway. +// +// Parsed = { family: 4 | 6, value: bigint } +// Cidr = { family, prefix, address, network, aligned } +// +// Every export is pure, never throws, and returns null (false for predicates) +// on unusable input. Parsers are strict — `01.2.3.4` is rejected because +// inet_aton would read the octet as octal; ip-calc.js labels such forms +// instead. No imports, so valid-ip.js could adopt this file without a cycle. + +const BITS = { 4: 32, 6: 128 }; +const MAX = { 4: (1n << 32n) - 1n, 6: (1n << 128n) - 1n }; + +const V4_OCTET = /^(0|[1-9]\d{0,2})$/; +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; +const V6_CHARS = /^[0-9a-fA-F:.]+$/; + +const isFamily = (family) => family === 4 || family === 6; +const isBig = (v) => typeof v === 'bigint'; +const inFamily = (value, family) => isBig(value) && value >= 0n && value <= MAX[family]; +const isPrefix = (prefix, family) => + Number.isInteger(prefix) && prefix >= 0 && prefix <= BITS[family]; + +/* ------------------------------------------------------------------ */ +/* Parsing */ +/* ------------------------------------------------------------------ */ + +export const parseIPv4 = (str) => { + if (typeof str !== 'string') return null; + const parts = str.split('.'); + if (parts.length !== 4) return null; + let value = 0n; + for (const part of parts) { + if (!V4_OCTET.test(part)) return null; + const n = Number(part); + if (n > 255) return null; + value = (value << 8n) | BigInt(n); + } + return { family: 4, value }; +}; + +// One `::` at most, 1–4 hex per group, and a dotted-quad tail only as the +// final token (`::ffff:192.0.2.1`), which then stands in for two groups. +export const parseIPv6 = (str) => { + if (typeof str !== 'string' || str === '' || !V6_CHARS.test(str)) return null; + + let text = str; + let embeddedV4 = false; + if (str.includes('.')) { + const lastColon = str.lastIndexOf(':'); + const tail = str.slice(lastColon + 1); + if (lastColon < 0 || !tail.includes('.')) return null; + const quad = parseIPv4(tail); + if (!quad) return null; + const hi = (quad.value >> 16n).toString(16); + const lo = (quad.value & 0xffffn).toString(16); + text = `${str.slice(0, lastColon + 1)}${hi}:${lo}`; + embeddedV4 = true; + } + + const halves = text.split('::'); + if (halves.length > 2) return null; + const shorthand = halves.length === 2; + const left = halves[0] === '' ? [] : halves[0].split(':'); + const right = shorthand && halves[1] !== '' ? halves[1].split(':') : []; + const explicit = left.length + right.length; + if (shorthand ? explicit > 7 : explicit !== 8) return null; + + const groups = [...left, ...Array(shorthand ? 8 - explicit : 0).fill('0'), ...right]; + let value = 0n; + for (const group of groups) { + if (!HEX_GROUP.test(group)) return null; + value = (value << 16n) | BigInt(parseInt(group, 16)); + } + return { family: 6, value, embeddedV4 }; +}; + +export const parseIp = (str) => parseIPv4(str) || parseIPv6(str); + +export const ipToBigInt = (str) => parseIp(str)?.value ?? null; + +// `a.b.c.d/n`, `x::/n`, or (IPv4 only) `a.b.c.d/m.m.m.m` with a contiguous +// dotted mask. Host bits are kept on `address`; `network` has them cleared. +export const parseCidr = (str) => { + if (typeof str !== 'string') return null; + const slash = str.indexOf('/'); + if (slash <= 0 || slash !== str.lastIndexOf('/')) return null; + const parsed = parseIp(str.slice(0, slash)); + if (!parsed) return null; + const { family, value } = parsed; + + const rhs = str.slice(slash + 1); + let prefix = null; + if (/^\d{1,3}$/.test(rhs)) { + prefix = Number(rhs); + } else if (family === 4) { + const mask = parseIPv4(rhs); + prefix = mask ? maskToPrefix(mask.value, 4) : null; + } + if (prefix === null || !isPrefix(prefix, family)) return null; + + const network = value & prefixToMask(prefix, family); + return { family, prefix, address: value, network, aligned: network === value }; +}; + +/* ------------------------------------------------------------------ */ +/* Formatting */ +/* ------------------------------------------------------------------ */ + +export const toOctets = (value) => { + if (!inFamily(value, 4)) return null; + return [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 0xffn)); +}; + +export const toHextets = (value) => { + if (!inFamily(value, 6)) return null; + return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt((7 - i) * 16)) & 0xffffn)); +}; + +export const formatIPv4 = (value) => toOctets(value)?.join('.') ?? null; + +// RFC 5952: lowercase, no leading zeros, the longest run of two or more zero +// groups becomes `::` (first run wins a tie), a lone zero group stays. +export const formatIPv6 = (value, { expanded = false } = {}) => { + const hextets = toHextets(value); + if (!hextets) return null; + if (expanded) return hextets.map((h) => h.toString(16).padStart(4, '0')).join(':'); + + let bestStart = -1; + let bestLen = 0; + for (let i = 0; i < 8; i += 1) { + if (hextets[i] !== 0) continue; + let j = i; + while (j < 8 && hextets[j] === 0) j += 1; + if (j - i > bestLen) { + bestStart = i; + bestLen = j - i; + } + i = j; + } + const hex = hextets.map((h) => h.toString(16)); + if (bestLen < 2) return hex.join(':'); + const head = hex.slice(0, bestStart).join(':'); + const tail = hex.slice(bestStart + bestLen).join(':'); + return `${head}::${tail}`; +}; + +export const formatIp = (parsed) => { + if (!parsed || !isFamily(parsed.family)) return null; + return parsed.family === 4 ? formatIPv4(parsed.value) : formatIPv6(parsed.value); +}; + +export const formatCidr = (cidr, { network = true } = {}) => { + if (!cidr || !isFamily(cidr.family)) return null; + const ip = formatIp({ family: cidr.family, value: network ? cidr.network : cidr.address }); + return ip === null ? null : `${ip}/${cidr.prefix}`; +}; + +/* ------------------------------------------------------------------ */ +/* Masks & counts */ +/* ------------------------------------------------------------------ */ + +export const prefixToMask = (prefix, family) => { + if (!isFamily(family) || !isPrefix(prefix, family)) return null; + const bits = BITS[family]; + return ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix); +}; + +// Null unless the mask is ones followed by zeros. +export const maskToPrefix = (mask, family) => { + if (!isFamily(family) || !inFamily(mask, family)) return null; + for (let prefix = 0; prefix <= BITS[family]; prefix += 1) { + if (prefixToMask(prefix, family) === mask) return prefix; + } + return null; +}; + +export const wildcardMask = (prefix, family) => { + const mask = prefixToMask(prefix, family); + return mask === null ? null : MAX[family] ^ mask; +}; + +export const addressCount = (prefix, family) => { + if (!isFamily(family) || !isPrefix(prefix, family)) return null; + return 1n << BigInt(BITS[family] - prefix); +}; + +// IPv4 loses network + broadcast except on /31 (RFC 3021 point-to-point) +// and /32; IPv6 has no broadcast, every address is assignable. +export const usableCount = (prefix, family) => { + const count = addressCount(prefix, family); + if (count === null) return null; + if (family === 6 || prefix >= 31) return count; + return count - 2n; +}; + +// Smallest block that holds `count` addresses (number or bigint). +export const smallestPrefixFor = (count, family) => { + if (!isFamily(family)) return null; + let need; + try { + need = BigInt(count); + } catch { + return null; + } + if (need < 1n) return null; + for (let prefix = BITS[family]; prefix >= 0; prefix -= 1) { + if (addressCount(prefix, family) >= need) return prefix; + } + return null; +}; + +/* ------------------------------------------------------------------ */ +/* Block info */ +/* ------------------------------------------------------------------ */ + +export const cidrInfo = (str) => { + const cidr = parseCidr(str); + if (!cidr) return null; + const { family, prefix, address, network, aligned } = cidr; + const mask = prefixToMask(prefix, family); + const wildcard = wildcardMask(prefix, family); + const lastAddress = network | wildcard; + const hostRange = family === 4 && prefix < 31; + return { + family, + prefix, + cidr: formatCidr(cidr), + address, + network, + broadcast: family === 4 ? lastAddress : null, + first: hostRange ? network + 1n : network, + last: hostRange ? lastAddress - 1n : lastAddress, + lastAddress, + mask, + wildcard, + count: addressCount(prefix, family), + usable: usableCount(prefix, family), + aligned, + }; +}; + +/* ------------------------------------------------------------------ */ +/* Containment & ordering */ +/* ------------------------------------------------------------------ */ + +export const prefixContains = (network, prefix, family, value) => { + if (!isFamily(family) || !isPrefix(prefix, family) || !isBig(network) || !isBig(value)) return false; + const shift = BigInt(BITS[family] - prefix); + return (value >> shift) === (network >> shift); +}; + +export const cidrContains = (cidrStr, ipStr) => { + const cidr = parseCidr(cidrStr); + const ip = parseIp(ipStr); + if (!cidr || !ip || cidr.family !== ip.family) return null; + return prefixContains(cidr.network, cidr.prefix, cidr.family, ip.value); +}; + +export const cidrOverlaps = (aStr, bStr) => { + const a = parseCidr(aStr); + const b = parseCidr(bStr); + if (!a || !b || a.family !== b.family) return null; + return prefixContains(a.network, a.prefix, a.family, b.network) + || prefixContains(b.network, b.prefix, b.family, a.network); +}; + +// IPv4 sorts before IPv6; within a family, numerically. +export const compareIps = (a, b) => { + if (a.family !== b.family) return a.family < b.family ? -1 : 1; + if (a.value === b.value) return 0; + return a.value < b.value ? -1 : 1; +}; + +/* ------------------------------------------------------------------ */ +/* Set operations */ +/* ------------------------------------------------------------------ */ + +// Children of `cidrStr` at `newPrefix`. `total` is exact; `subnets` stops at +// `limit` so a /8 → /32 request never materialises 16M strings. +export const splitCidr = (cidrStr, newPrefix, { limit = 1024 } = {}) => { + const cidr = parseCidr(cidrStr); + if (!cidr || !isPrefix(newPrefix, cidr.family) || newPrefix < cidr.prefix) return null; + const { family, network } = cidr; + const total = 1n << BigInt(newPrefix - cidr.prefix); + const size = addressCount(newPrefix, family); + const emit = total < BigInt(limit) ? Number(total) : limit; + const subnets = []; + for (let i = 0n; i < BigInt(emit); i += 1n) { + subnets.push(`${formatIp({ family, value: network + i * size })}/${newPrefix}`); + } + return { family, prefix: newPrefix, total, subnets, truncated: BigInt(emit) < total }; +}; + +const isAligned = (network, prefix, family) => + (network & (addressCount(prefix, family) - 1n)) === 0n; + +// Minimal covering set per family: bare IPs become host routes, contained +// blocks are absorbed, aligned siblings merge upward (4 × /26 → one /24). +export const aggregateCidrs = (list) => { + const out = { v4: [], v6: [], invalid: [] }; + if (!Array.isArray(list)) return out; + const blocks = { 4: [], 6: [] }; + for (const token of list) { + const text = typeof token === 'string' ? token.trim() : ''; + const cidr = parseCidr(text); + if (cidr) { + blocks[cidr.family].push({ network: cidr.network, prefix: cidr.prefix }); + continue; + } + const ip = parseIp(text); + if (ip) { + blocks[ip.family].push({ network: ip.value, prefix: BITS[ip.family] }); + continue; + } + out.invalid.push(token); + } + + for (const family of [4, 6]) { + const sorted = blocks[family].sort((a, b) => { + if (a.network !== b.network) return a.network < b.network ? -1 : 1; + return a.prefix - b.prefix; + }); + const stack = []; + for (const block of sorted) { + const top = stack[stack.length - 1]; + if (top && top.prefix <= block.prefix + && prefixContains(top.network, top.prefix, family, block.network)) continue; + stack.push(block); + while (stack.length >= 2) { + const a = stack[stack.length - 2]; + const b = stack[stack.length - 1]; + const size = addressCount(a.prefix, family); + if (a.prefix === 0 || a.prefix !== b.prefix || b.network !== a.network + size + || !isAligned(a.network, a.prefix - 1, family)) break; + stack.splice(-2, 2, { network: a.network, prefix: a.prefix - 1 }); + } + } + out[family === 4 ? 'v4' : 'v6'] = stack.map( + (b) => `${formatIp({ family, value: b.network })}/${b.prefix}`, + ); + } + return out; +}; + +// Greedy cover of an inclusive range with the fewest CIDRs: at each step +// take the largest block aligned at `cur` that still fits before `end`. +export const rangeToCidrs = (startStr, endStr) => { + const start = parseIp(startStr); + const end = parseIp(endStr); + if (!start || !end || start.family !== end.family || start.value > end.value) return null; + const { family } = start; + const bits = BITS[family]; + const cidrs = []; + let cur = start.value; + while (cur <= end.value) { + let prefix = 0; + for (; prefix <= bits; prefix += 1) { + const size = addressCount(prefix, family); + if (isAligned(cur, prefix, family) && cur + size - 1n <= end.value) break; + } + cidrs.push(`${formatIp({ family, value: cur })}/${prefix}`); + cur += addressCount(prefix, family); + } + return { family, cidrs }; +}; + +export const cidrToRange = (cidrStr) => { + const info = cidrInfo(cidrStr); + if (!info) return null; + return { family: info.family, start: info.network, end: info.lastAddress }; +}; diff --git a/common/rdap.js b/common/rdap.js index d90da54b0..88162e899 100644 --- a/common/rdap.js +++ b/common/rdap.js @@ -20,7 +20,7 @@ import { fetchUpstream } from './fetch-with-timeout.js'; import { isIPv6 } from './valid-ip.js'; -import { expandIPv6 } from './bgp-prefix.js'; +import { ipToBigInt, parseCidr, prefixContains } from './ip-math.js'; import logger from './logger.js'; const BOOTSTRAP_BASE = 'https://data.iana.org/rdap/'; @@ -77,35 +77,6 @@ export async function rdapDomain(domain, { timeoutMs = 5000 } = {}) { // -- IP lookup ------------------------------------------------------------- -// Numeric value of an IP for prefix math. Returns null on junk — callers -// validate first, but bootstrap CIDR bases also pass through here. -const ipToBigInt = (ip) => { - if (typeof ip !== 'string') return null; - if (!ip.includes(':')) { - const parts = ip.split('.'); - if (parts.length !== 4) return null; - let n = 0n; - for (const p of parts) { - if (!/^\d{1,3}$/.test(p)) return null; - n = (n << 8n) | BigInt(p); - } - return n; - } - const hextets = expandIPv6(ip); - if (!hextets) return null; - let n = 0n; - for (const h of hextets) n = (n << 16n) | BigInt(parseInt(h, 16)); - return n; -}; - -const cidrContains = (cidr, ipBig, v6) => { - const [base, lenStr] = cidr.split('/'); - const baseBig = ipToBigInt(base); - if (baseBig === null) return false; - const shift = (v6 ? 128n : 32n) - BigInt(lenStr); - return (ipBig >> shift) === (baseBig >> shift); -}; - // Longest-prefix match of `ip` against an IANA ipv4/ipv6 bootstrap // `services` array (entries: [[cidr, …], [url, …]]). Exported for tests. export const findIpEndpoint = (services, ip) => { @@ -117,10 +88,11 @@ export const findIpEndpoint = (services, ip) => { let bestLen = -1; for (const [cidrs, urls] of services) { for (const cidr of cidrs) { - if (cidr.includes(':') !== v6) continue; - const len = Number(cidr.split('/')[1]); - if (len <= bestLen || !cidrContains(cidr, ipBig, v6)) continue; - bestLen = len; + const block = parseCidr(cidr); + if (!block || (block.family === 6) !== v6) continue; + if (block.prefix <= bestLen + || !prefixContains(block.network, block.prefix, block.family, ipBig)) continue; + bestLen = block.prefix; best = urls.find((u) => u.startsWith('https://')) || urls[0]; } } diff --git a/frontend/utils/ip-math.js b/frontend/utils/ip-math.js new file mode 100644 index 000000000..0b3f5620e --- /dev/null +++ b/frontend/utils/ip-math.js @@ -0,0 +1,6 @@ +// Thin re-export — implementation lives in common/ip-math.js so the +// front-end IP Calculator and the back-end's CIDR containment (rdap.js) +// share one source of truth (same pattern as valid-ip.js / bgp-prefix.js). +// +// import { parseCidr, cidrInfo } from '@/utils/ip-math.js'; +export * from '../../common/ip-math.js'; diff --git a/tests/ip-math.test.js b/tests/ip-math.test.js new file mode 100644 index 000000000..06cfda90e --- /dev/null +++ b/tests/ip-math.test.js @@ -0,0 +1,375 @@ +// Guards common/ip-math.js — the BigInt address arithmetic behind the IP +// Calculator and rdap's CIDR containment: strict parsers, RFC 5952 +// formatting, mask / count math, containment, and the set operations +// (split, aggregate, range → CIDR). Also pins the frontend bridge to the +// same functions. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import * as common from '../common/ip-math.js'; +import * as bridge from '../frontend/utils/ip-math.js'; + +const { + parseIPv4, parseIPv6, parseIp, ipToBigInt, parseCidr, + formatIPv4, formatIPv6, formatIp, formatCidr, toOctets, toHextets, + prefixToMask, maskToPrefix, wildcardMask, addressCount, usableCount, smallestPrefixFor, + cidrInfo, prefixContains, cidrContains, cidrOverlaps, compareIps, + splitCidr, aggregateCidrs, rangeToCidrs, cidrToRange, +} = common; + +const v4 = (str) => parseIPv4(str).value; +const v6 = (str) => parseIPv6(str).value; + +describe('frontend bridge', () => { + it('re-exports every common export unchanged', () => { + for (const [name, fn] of Object.entries(common)) { + assert.equal(bridge[name], fn, `bridge is missing or diverges on ${name}`); + } + }); +}); + +describe('parseIPv4', () => { + const accept = [ + ['192.0.2.1', 0xc0000201n], + ['0.0.0.0', 0n], + ['255.255.255.255', 4294967295n], + ['10.0.0.1', 0x0a000001n], + ]; + for (const [input, value] of accept) { + it(`parses ${input}`, () => assert.deepEqual(parseIPv4(input), { family: 4, value })); + } + const reject = ['256.0.0.1', '01.2.3.4', '1.2.3', '1.2.3.4.5', '1.2.3.4 ', ' 1.2.3.4', + '127.1', '0x7f.0.0.1', '', '1.2.3.-4', '1.2.3.4/24', 'a.b.c.d', 42, null, undefined]; + for (const input of reject) { + it(`rejects ${JSON.stringify(input)}`, () => assert.equal(parseIPv4(input), null)); + } +}); + +describe('parseIPv6', () => { + const accept = [ + ['::1', 1n, false], + ['::', 0n, false], + ['2001:db8::1', 0x20010db8000000000000000000000001n, false], + ['2001:DB8::1', 0x20010db8000000000000000000000001n, false], + ['2001:0db8:0000:0000:0000:0000:0000:0001', 0x20010db8000000000000000000000001n, false], + ['::ffff:192.0.2.1', 0xffffc0000201n, true], + ['::192.0.2.1', 0xc0000201n, true], + ['64:ff9b::192.0.2.1', 0x0064ff9b0000000000000000c0000201n, true], + ['0:0:0:0:0:ffff:1.2.3.4', 0xffff01020304n, true], + ['1::', 1n << 112n, false], + ['fe80::1', 0xfe800000000000000000000000000001n, false], + ]; + for (const [input, value, embeddedV4] of accept) { + it(`parses ${input}`, () => assert.deepEqual(parseIPv6(input), { family: 6, value, embeddedV4 })); + } + const reject = ['1:2:3:4:5:6:7:1.2.3.4', '::1.2.3', '::01.2.3.4', '1.2.3.4::', '1.2.3.4', ':::', + '1::2::3', ':1', '1:', '12345::', 'fe80::1%eth0', '[::1]', '1:2:3:4:5:6:7', '1:2:3:4:5:6:7:8:9', + '::1:', 'g::1', '', '::/64', 7, null]; + for (const input of reject) { + it(`rejects ${JSON.stringify(input)}`, () => assert.equal(parseIPv6(input), null)); + } +}); + +describe('parseIp / ipToBigInt', () => { + it('dispatches by family', () => { + assert.equal(parseIp('10.0.0.1').family, 4); + assert.equal(parseIp('::1').family, 6); + assert.equal(parseIp('nope'), null); + assert.equal(ipToBigInt('10.0.0.1'), 0x0a000001n); + assert.equal(ipToBigInt('junk'), null); + }); +}); + +describe('formatIPv6', () => { + const cases = [ + ['2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1'], + ['0:0:0:0:0:0:0:0', '::'], + ['0:0:0:0:0:0:0:1', '::1'], + ['1:0:0:0:0:0:0:0', '1::'], + ['2001:db8:0:0:1:0:0:1', '2001:db8::1:0:0:1'], + ['1:0:0:1:0:0:0:1', '1:0:0:1::1'], + ['2001:db8:0:1:1:1:1:1', '2001:db8:0:1:1:1:1:1'], + ['::ffff:192.0.2.1', '::ffff:c000:201'], + ['ABCD:EF01::', 'abcd:ef01::'], + ]; + for (const [input, expected] of cases) { + it(`${input} → ${expected}`, () => assert.equal(formatIPv6(v6(input)), expected)); + } + it('expands to eight zero-padded groups', () => { + assert.equal(formatIPv6(1n, { expanded: true }), '0000:0000:0000:0000:0000:0000:0000:0001'); + assert.equal(formatIPv6(v6('2001:db8::1'), { expanded: true }), '2001:0db8:0000:0000:0000:0000:0000:0001'); + }); + it('rejects out-of-range values', () => { + assert.equal(formatIPv6(2n ** 128n), null); + assert.equal(formatIPv6(-1n), null); + assert.equal(formatIPv6(5), null); + }); +}); + +describe('formatIPv4 / formatIp / formatCidr / octets', () => { + it('round-trips', () => { + assert.equal(formatIPv4(0xc0000201n), '192.0.2.1'); + assert.equal(formatIPv4(0n), '0.0.0.0'); + assert.equal(formatIPv4(4294967295n), '255.255.255.255'); + assert.equal(formatIPv4(-1n), null); + assert.equal(formatIPv4(2n ** 32n), null); + assert.equal(formatIp(parseIp('::1')), '::1'); + assert.equal(formatIp(parseIp('1.2.3.4')), '1.2.3.4'); + assert.equal(formatIp(null), null); + }); + it('formatCidr renders network by default, address on request', () => { + const cidr = parseCidr('10.0.0.1/8'); + assert.equal(formatCidr(cidr), '10.0.0.0/8'); + assert.equal(formatCidr(cidr, { network: false }), '10.0.0.1/8'); + assert.equal(formatCidr(null), null); + }); + it('splits into octets / hextets', () => { + assert.deepEqual(toOctets(v4('192.168.1.130')), [192, 168, 1, 130]); + assert.deepEqual(toHextets(v6('2001:db8::1')), [0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]); + assert.equal(toOctets(2n ** 32n), null); + }); +}); + +describe('parseCidr', () => { + it('accepts prefix lengths and dotted masks', () => { + assert.deepEqual(parseCidr('10.0.0.0/8'), { family: 4, prefix: 8, address: v4('10.0.0.0'), network: v4('10.0.0.0'), aligned: true }); + const host = parseCidr('10.0.0.1/8'); + assert.equal(host.aligned, false); + assert.equal(formatIPv4(host.network), '10.0.0.0'); + assert.equal(parseCidr('10.0.0.0/255.0.0.0').prefix, 8); + assert.equal(parseCidr('10.0.0.0/0.0.0.0').prefix, 0); + assert.equal(parseCidr('10.0.0.0/024').prefix, 24); + assert.equal(parseCidr('2001:db8::/32').prefix, 32); + assert.equal(parseCidr('::ffff:1.2.3.4/120').prefix, 120); + assert.equal(parseCidr('2001:db8::/128').prefix, 128); + }); + const reject = ['10.0.0.0/255.0.255.0', '10.0.0.0/33', '10.0.0.0/-1', '10.0.0.0/ 24', '10.0.0.0/24/25', + '10.0.0.0/', '/8', '127.1/8', '2001:db8::/129', '2001:db8::/255.255.0.0', '10.0.0.0', '', null]; + for (const input of reject) { + it(`rejects ${JSON.stringify(input)}`, () => assert.equal(parseCidr(input), null)); + } +}); + +describe('masks', () => { + it('prefixToMask', () => { + assert.equal(prefixToMask(24, 4), 0xffffff00n); + assert.equal(prefixToMask(0, 4), 0n); + assert.equal(prefixToMask(32, 4), 0xffffffffn); + assert.equal(prefixToMask(64, 6), 0xffffffffffffffff0000000000000000n); + assert.equal(prefixToMask(33, 4), null); + assert.equal(prefixToMask(24, 5), null); + assert.equal(prefixToMask(1.5, 4), null); + }); + it('maskToPrefix', () => { + assert.equal(maskToPrefix(0xffffff00n, 4), 24); + assert.equal(maskToPrefix(0xff00ff00n, 4), null); + assert.equal(maskToPrefix(0n, 4), 0); + assert.equal(maskToPrefix(0xffffffffn, 4), 32); + assert.equal(maskToPrefix(2n ** 32n, 4), null); + }); + it('wildcardMask', () => { + assert.equal(wildcardMask(26, 4), 63n); + assert.equal(wildcardMask(0, 4), 0xffffffffn); + assert.equal(wildcardMask(129, 6), null); + }); +}); + +describe('counts', () => { + it('addressCount / usableCount', () => { + assert.equal(addressCount(24, 4), 256n); + assert.equal(addressCount(0, 6), 2n ** 128n); + assert.equal(usableCount(24, 4), 254n); + assert.equal(usableCount(31, 4), 2n); + assert.equal(usableCount(32, 4), 1n); + assert.equal(usableCount(0, 4), 2n ** 32n - 2n); + assert.equal(usableCount(64, 6), 2n ** 64n); + assert.equal(usableCount(128, 6), 1n); + assert.equal(usableCount(33, 4), null); + }); + it('smallestPrefixFor', () => { + assert.equal(smallestPrefixFor(1, 4), 32); + assert.equal(smallestPrefixFor(2, 4), 31); + assert.equal(smallestPrefixFor(300, 4), 23); + assert.equal(smallestPrefixFor(2n ** 32n, 4), 0); + assert.equal(smallestPrefixFor(2n ** 32n + 1n, 4), null); + assert.equal(smallestPrefixFor(0, 4), null); + assert.equal(smallestPrefixFor('nope', 4), null); + assert.equal(smallestPrefixFor(2n ** 64n, 6), 64); + }); +}); + +describe('cidrInfo', () => { + it('192.168.1.130/26', () => { + const info = cidrInfo('192.168.1.130/26'); + assert.equal(info.cidr, '192.168.1.128/26'); + assert.equal(formatIPv4(info.network), '192.168.1.128'); + assert.equal(formatIPv4(info.broadcast), '192.168.1.191'); + assert.equal(formatIPv4(info.first), '192.168.1.129'); + assert.equal(formatIPv4(info.last), '192.168.1.190'); + assert.equal(formatIPv4(info.mask), '255.255.255.192'); + assert.equal(formatIPv4(info.wildcard), '0.0.0.63'); + assert.equal(info.count, 64n); + assert.equal(info.usable, 62n); + assert.equal(info.aligned, false); + assert.equal(formatIPv4(info.address), '192.168.1.130'); + }); + it('/31 and /32 keep the whole block as host range', () => { + const p2p = cidrInfo('10.0.0.1/31'); + assert.equal(formatIPv4(p2p.first), '10.0.0.0'); + assert.equal(formatIPv4(p2p.last), '10.0.0.1'); + assert.equal(p2p.usable, 2n); + const host = cidrInfo('10.0.0.1/32'); + assert.equal(host.first, host.last); + assert.equal(host.first, host.network); + assert.equal(host.usable, 1n); + }); + it('0.0.0.0/0 spans everything', () => { + const all = cidrInfo('0.0.0.0/0'); + assert.equal(formatIPv4(all.broadcast), '255.255.255.255'); + assert.equal(all.count, 2n ** 32n); + }); + it('IPv6 has no broadcast and no reserved endpoints', () => { + const info = cidrInfo('2001:db8::1/64'); + assert.equal(formatIPv6(info.network), '2001:db8::'); + assert.equal(formatIPv6(info.lastAddress), '2001:db8::ffff:ffff:ffff:ffff'); + assert.equal(info.broadcast, null); + assert.equal(info.first, info.network); + assert.equal(info.last, info.lastAddress); + assert.equal(info.count, 2n ** 64n); + assert.equal(info.usable, 2n ** 64n); + assert.equal(cidrInfo('::/0').count, 2n ** 128n); + }); + it('returns null on junk', () => { + assert.equal(cidrInfo('nope/8'), null); + assert.equal(cidrInfo('10.0.0.0'), null); + }); +}); + +describe('containment & ordering', () => { + it('cidrContains', () => { + assert.equal(cidrContains('10.0.0.0/8', '10.255.255.255'), true); + assert.equal(cidrContains('10.0.0.0/8', '11.0.0.0'), false); + assert.equal(cidrContains('::ffff:0:0/96', '::ffff:1.2.3.4'), true); + assert.equal(cidrContains('0.0.0.0/0', '203.0.113.9'), true); + assert.equal(cidrContains('10.0.0.0/8', '::1'), null); + assert.equal(cidrContains('junk', '1.1.1.1'), null); + }); + it('prefixContains guards its inputs', () => { + assert.equal(prefixContains(v4('10.0.0.0'), 8, 4, v4('10.1.2.3')), true); + assert.equal(prefixContains(v4('10.0.0.0'), 8, 4, 5), false); + assert.equal(prefixContains(v4('10.0.0.0'), 33, 4, v4('10.1.2.3')), false); + }); + it('cidrOverlaps', () => { + assert.equal(cidrOverlaps('10.0.0.0/8', '10.1.0.0/16'), true); + assert.equal(cidrOverlaps('10.1.0.0/16', '10.0.0.0/8'), true); + assert.equal(cidrOverlaps('10.0.0.0/9', '10.128.0.0/9'), false); + assert.equal(cidrOverlaps('10.0.0.0/8', '2001:db8::/32'), null); + }); + it('compareIps orders v4 before v6, then numerically', () => { + assert.equal(compareIps(parseIp('1.1.1.1'), parseIp('1.1.1.2')), -1); + assert.equal(compareIps(parseIp('1.1.1.2'), parseIp('1.1.1.1')), 1); + assert.equal(compareIps(parseIp('1.1.1.1'), parseIp('1.1.1.1')), 0); + assert.equal(compareIps(parseIp('255.255.255.255'), parseIp('::')), -1); + assert.equal(compareIps(parseIp('::'), parseIp('255.255.255.255')), 1); + }); +}); + +describe('splitCidr', () => { + it('splits a /24 into /26s', () => { + const r = splitCidr('10.0.0.0/24', 26); + assert.deepEqual(r.subnets, ['10.0.0.0/26', '10.0.0.64/26', '10.0.0.128/26', '10.0.0.192/26']); + assert.equal(r.total, 4n); + assert.equal(r.truncated, false); + assert.equal(r.prefix, 26); + }); + it('same prefix yields the block itself; shorter or out-of-range prefixes are null', () => { + assert.deepEqual(splitCidr('10.0.0.5/24', 24).subnets, ['10.0.0.0/24']); + assert.equal(splitCidr('10.0.0.0/24', 23), null); + assert.equal(splitCidr('10.0.0.0/24', 33), null); + assert.equal(splitCidr('10.0.0.0/24', 25.5), null); + assert.equal(splitCidr('junk', 25), null); + }); + it('caps emitted subnets but reports the exact total', () => { + const r = splitCidr('10.0.0.0/8', 32, { limit: 10 }); + assert.equal(r.subnets.length, 10); + assert.equal(r.total, 16777216n); + assert.equal(r.truncated, true); + assert.equal(r.subnets[9], '10.0.0.9/32'); + }); + it('IPv6 /32 → /48 uses the default cap', () => { + const r = splitCidr('2001:db8::/32', 48); + assert.equal(r.total, 65536n); + assert.equal(r.subnets.length, 1024); + assert.equal(r.truncated, true); + assert.equal(r.subnets[0], '2001:db8::/48'); + assert.equal(r.subnets[1023], '2001:db8:3ff::/48'); + }); +}); + +describe('aggregateCidrs', () => { + const cases = [ + [['192.168.0.0/24', '192.168.1.0/24'], ['192.168.0.0/23']], + [['192.168.1.0/24', '192.168.2.0/24'], ['192.168.1.0/24', '192.168.2.0/24']], + [['10.0.0.0/8', '10.1.0.0/16'], ['10.0.0.0/8']], + [['10.1.0.0/16', '10.0.0.0/8'], ['10.0.0.0/8']], + [['10.0.0.192/26', '10.0.0.0/26', '10.0.0.128/26', '10.0.0.64/26'], ['10.0.0.0/24']], + [['0.0.0.0/1', '128.0.0.0/1'], ['0.0.0.0/0']], + [['10.0.0.1/24'], ['10.0.0.0/24']], + [['1.2.3.4'], ['1.2.3.4/32']], + [['1.2.3.4/32', '1.2.3.5/32'], ['1.2.3.4/31']], + [['1.2.3.5/32', '1.2.3.6/32'], ['1.2.3.5/32', '1.2.3.6/32']], + [['10.0.0.0/24', '10.0.0.0/24', ' 10.0.0.0/24 '], ['10.0.0.0/24']], + [['10.0.0.0/24', '10.0.1.0/24', '10.0.2.0/24'], ['10.0.0.0/23', '10.0.2.0/24']], + ]; + for (const [input, expected] of cases) { + it(`${input.join(' ')} → ${expected.join(' ')}`, () => { + const r = aggregateCidrs(input); + assert.deepEqual(r.v4, expected); + assert.deepEqual(r.v6, []); + assert.deepEqual(r.invalid, []); + }); + } + it('handles IPv6 and mixed families', () => { + assert.deepEqual(aggregateCidrs(['2001:db8::/33', '2001:db8:8000::/33']).v6, ['2001:db8::/32']); + const mixed = aggregateCidrs(['10.0.0.0/8', '2001:db8::1', 'foo', '1.2.3.4/33']); + assert.deepEqual(mixed.v4, ['10.0.0.0/8']); + assert.deepEqual(mixed.v6, ['2001:db8::1/128']); + assert.deepEqual(mixed.invalid, ['foo', '1.2.3.4/33']); + }); + it('tolerates empty and non-array input', () => { + assert.deepEqual(aggregateCidrs([]), { v4: [], v6: [], invalid: [] }); + assert.deepEqual(aggregateCidrs('nope'), { v4: [], v6: [], invalid: [] }); + assert.deepEqual(aggregateCidrs([null, 3]).invalid, [null, 3]); + }); +}); + +describe('rangeToCidrs / cidrToRange', () => { + it('covers aligned ranges with one block', () => { + assert.deepEqual(rangeToCidrs('10.0.0.0', '10.0.0.255').cidrs, ['10.0.0.0/24']); + assert.deepEqual(rangeToCidrs('0.0.0.0', '255.255.255.255').cidrs, ['0.0.0.0/0']); + assert.deepEqual(rangeToCidrs('1.1.1.1', '1.1.1.1').cidrs, ['1.1.1.1/32']); + }); + it('builds the classic ladder for .1–.254', () => { + const r = rangeToCidrs('10.0.0.1', '10.0.0.254'); + assert.equal(r.family, 4); + assert.equal(r.cidrs.length, 14); + assert.deepEqual(r.cidrs.slice(0, 3), ['10.0.0.1/32', '10.0.0.2/31', '10.0.0.4/30']); + assert.deepEqual(r.cidrs.slice(-2), ['10.0.0.252/31', '10.0.0.254/32']); + }); + it('IPv6 ranges', () => { + assert.deepEqual(rangeToCidrs('::', '::ffff').cidrs, ['::/112']); + assert.deepEqual(rangeToCidrs('2001:db8::1', '2001:db8::2').cidrs, ['2001:db8::1/128', '2001:db8::2/128']); + }); + it('rejects reversed, cross-family and junk ranges', () => { + assert.equal(rangeToCidrs('10.0.0.5', '10.0.0.1'), null); + assert.equal(rangeToCidrs('1.1.1.1', '::1'), null); + assert.equal(rangeToCidrs('x', '1.1.1.1'), null); + }); + it('cidrToRange', () => { + const r = cidrToRange('10.0.0.0/30'); + assert.equal(formatIPv4(r.start), '10.0.0.0'); + assert.equal(formatIPv4(r.end), '10.0.0.3'); + assert.equal(cidrToRange('nope'), null); + }); +}); From e6670b16ec51610c76ad2b8bbc960ff32146b114 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:18:58 +0800 Subject: [PATCH 02/36] Feat(ipcalculator): add the pure classification and analysis layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyInput() recognises IPv4 / IPv6, prefixes (length or dotted mask), integers and hex, ranges, prefix lists and the inet_aton spellings browsers accept; calculate() derives display-ready analysis: IANA special-purpose blocks, subnet math, PTR names, obfuscated and embedded-IPv6 forms, Teredo / ULA / multicast decoding and EUI-64 → MAC recovery. Nothing throws and no BigInt reaches a template. MAC input is deliberately left to MAC Lookup. Co-Authored-By: Claude Fable 5.1 --- frontend/utils/ip-calc.js | 621 ++++++++++++++++++++++++++++++++++++++ tests/ip-calc.test.js | 460 ++++++++++++++++++++++++++++ 2 files changed, 1081 insertions(+) create mode 100644 frontend/utils/ip-calc.js create mode 100644 tests/ip-calc.test.js diff --git a/frontend/utils/ip-calc.js b/frontend/utils/ip-calc.js new file mode 100644 index 000000000..511b06f31 --- /dev/null +++ b/frontend/utils/ip-calc.js @@ -0,0 +1,621 @@ +// Pure layer of the IP Calculator: classifies whatever was pasted (IPv4 / +// IPv6, prefix, number, range, prefix list) and derives what the result cards +// render. `calculate(raw)` is the component's single entry point; the parts +// are exported for tests and for the prefix slider (`analyzeCidr`). +// +// Nothing throws — junk yields `{ kind: 'invalid', reason }`, helpers return +// null. Analyzer output is display-ready (strings, counts via `formatCount`) +// so templates never meet a BigInt; only the classifier's `value` stays a +// BigInt for the bitmap. The lenient inet_aton grammar (`127.1`, `0177.0.0.1`) +// is accepted here and labelled obfuscated — the point is to show how a +// browser reads it. Block labels are IANA registry names and stay English; +// only `scope` is localised. MAC input belongs to the MAC Lookup tool; just +// the EUI-64 → MAC recovery inside an IPv6 interface id lives here. + +import { + parseIPv4, parseIPv6, parseIp, parseCidr, formatIPv4, formatIPv6, formatIp, formatCidr, + toOctets, toHextets, cidrInfo, prefixContains, aggregateCidrs, rangeToCidrs, +} from './ip-math.js'; + +const MAX_V4 = (1n << 32n) - 1n; +const MAX_V6 = (1n << 128n) - 1n; + +/* ------------------------------------------------------------------ */ +/* Special-purpose address registries */ +/* ------------------------------------------------------------------ */ + +// `scope` is the localised word; `global` says whether the block is +// globally reachable (drives the success badge). Longest prefix wins. +const compileBlocks = (rows) => rows.map((row) => { + const cidr = parseCidr(row.cidr); + return { ...row, family: cidr.family, network: cidr.network, prefix: cidr.prefix }; +}); + +export const IPV4_SPECIAL_BLOCKS = compileBlocks([ + { cidr: '0.0.0.0/8', id: 'this-network', label: 'This network', rfc: [791, 1122], scope: 'unspecified', global: false }, + { cidr: '0.0.0.0/32', id: 'this-host', label: 'This host on this network', rfc: [1122], scope: 'unspecified', global: false }, + { cidr: '10.0.0.0/8', id: 'private-10', label: 'Private-Use', rfc: [1918], scope: 'private', global: false }, + { cidr: '100.64.0.0/10', id: 'shared-cgnat', label: 'Shared Address Space (CGNAT)', rfc: [6598], scope: 'shared', global: false }, + { cidr: '127.0.0.0/8', id: 'loopback', label: 'Loopback', rfc: [1122], scope: 'loopback', global: false }, + { cidr: '169.254.0.0/16', id: 'link-local', label: 'Link-Local', rfc: [3927], scope: 'link-local', global: false }, + { cidr: '172.16.0.0/12', id: 'private-172', label: 'Private-Use', rfc: [1918], scope: 'private', global: false }, + { cidr: '192.0.0.0/24', id: 'ietf-protocol', label: 'IETF Protocol Assignments', rfc: [6890], scope: 'reserved', global: false }, + { cidr: '192.0.0.0/29', id: 'ds-lite', label: 'IPv4 Service Continuity Prefix (DS-Lite)', rfc: [7335], scope: 'reserved', global: false }, + { cidr: '192.0.0.8/32', id: 'dummy', label: 'IPv4 dummy address', rfc: [7600], scope: 'reserved', global: false }, + { cidr: '192.0.0.9/32', id: 'pcp-anycast', label: 'Port Control Protocol anycast', rfc: [7723], scope: 'global', global: true }, + { cidr: '192.0.0.10/32', id: 'turn-anycast', label: 'TURN anycast', rfc: [8155], scope: 'global', global: true }, + { cidr: '192.0.0.170/31', id: 'nat64-discovery', label: 'NAT64/DNS64 discovery', rfc: [8880, 7050], scope: 'reserved', global: false }, + { cidr: '192.0.2.0/24', id: 'test-net-1', label: 'Documentation (TEST-NET-1)', rfc: [5737], scope: 'documentation', global: false }, + { cidr: '192.31.196.0/24', id: 'as112-v4', label: 'AS112-v4', rfc: [7535], scope: 'global', global: true }, + { cidr: '192.52.193.0/24', id: 'amt', label: 'AMT', rfc: [7450], scope: 'global', global: true }, + { cidr: '192.88.99.0/24', id: '6to4-relay', label: '6to4 Relay Anycast (deprecated)', rfc: [3068, 7526], scope: 'reserved', global: false }, + { cidr: '192.168.0.0/16', id: 'private-192', label: 'Private-Use', rfc: [1918], scope: 'private', global: false }, + { cidr: '192.175.48.0/24', id: 'as112-direct', label: 'Direct Delegation AS112 Service', rfc: [7534], scope: 'global', global: true }, + { cidr: '198.18.0.0/15', id: 'benchmarking', label: 'Benchmarking', rfc: [2544], scope: 'reserved', global: false }, + { cidr: '198.51.100.0/24', id: 'test-net-2', label: 'Documentation (TEST-NET-2)', rfc: [5737], scope: 'documentation', global: false }, + { cidr: '203.0.113.0/24', id: 'test-net-3', label: 'Documentation (TEST-NET-3)', rfc: [5737], scope: 'documentation', global: false }, + { cidr: '224.0.0.0/4', id: 'multicast', label: 'Multicast', rfc: [1112, 5771], scope: 'multicast', global: false }, + { cidr: '224.0.0.0/24', id: 'mcast-local-control', label: 'Local Network Control Block', rfc: [5771], scope: 'multicast', global: false }, + { cidr: '224.0.1.0/24', id: 'mcast-internetwork', label: 'Internetwork Control Block', rfc: [5771], scope: 'multicast', global: false }, + { cidr: '232.0.0.0/8', id: 'mcast-ssm', label: 'Source-Specific Multicast', rfc: [4607], scope: 'multicast', global: false }, + { cidr: '233.0.0.0/8', id: 'mcast-glop', label: 'GLOP Block', rfc: [3180], scope: 'multicast', global: false }, + { cidr: '239.0.0.0/8', id: 'mcast-admin', label: 'Administratively Scoped', rfc: [2365], scope: 'multicast', global: false }, + { cidr: '240.0.0.0/4', id: 'reserved-240', label: 'Reserved (former Class E)', rfc: [1112], scope: 'reserved', global: false }, + { cidr: '255.255.255.255/32', id: 'broadcast', label: 'Limited Broadcast', rfc: [919, 8190], scope: 'broadcast', global: false }, +]); + +export const IPV6_SPECIAL_BLOCKS = compileBlocks([ + { cidr: '::/128', id: 'unspecified', label: 'Unspecified', rfc: [4291], scope: 'unspecified', global: false }, + { cidr: '::1/128', id: 'loopback', label: 'Loopback', rfc: [4291], scope: 'loopback', global: false }, + { cidr: '::ffff:0:0/96', id: 'ipv4-mapped', label: 'IPv4-mapped', rfc: [4291], scope: 'reserved', global: false }, + { cidr: '::/96', id: 'ipv4-compatible', label: 'IPv4-compatible (deprecated)', rfc: [4291], scope: 'reserved', global: false }, + { cidr: '64:ff9b::/96', id: 'nat64-wkp', label: 'NAT64 well-known prefix', rfc: [6052], scope: 'reserved', global: false }, + { cidr: '64:ff9b:1::/48', id: 'nat64-local', label: 'Local-use NAT64', rfc: [8215], scope: 'private', global: false }, + { cidr: '100::/64', id: 'discard', label: 'Discard-only', rfc: [6666], scope: 'reserved', global: false }, + { cidr: '2001::/23', id: 'ietf-protocol-v6', label: 'IETF Protocol Assignments', rfc: [2928], scope: 'reserved', global: false }, + { cidr: '2001::/32', id: 'teredo', label: 'Teredo', rfc: [4380], scope: 'global', global: true }, + { cidr: '2001:1::1/128', id: 'pcp-anycast-v6', label: 'Port Control Protocol anycast', rfc: [7723], scope: 'global', global: true }, + { cidr: '2001:1::2/128', id: 'turn-anycast-v6', label: 'TURN anycast', rfc: [8155], scope: 'global', global: true }, + { cidr: '2001:1::3/128', id: 'dnssd-srp-anycast', label: 'DNS-SD Service Registration Protocol anycast', rfc: [9665], scope: 'global', global: true }, + { cidr: '2001:2::/48', id: 'benchmarking-v6', label: 'Benchmarking', rfc: [5180], scope: 'reserved', global: false }, + { cidr: '2001:3::/32', id: 'amt-v6', label: 'AMT', rfc: [7450], scope: 'global', global: true }, + { cidr: '2001:4:112::/48', id: 'as112-v6', label: 'AS112-v6', rfc: [7535], scope: 'global', global: true }, + { cidr: '2001:10::/28', id: 'orchid', label: 'ORCHID (deprecated)', rfc: [4843], scope: 'reserved', global: false }, + { cidr: '2001:20::/28', id: 'orchid-v2', label: 'ORCHIDv2', rfc: [7343], scope: 'reserved', global: false }, + { cidr: '2001:30::/28', id: 'drone-rid', label: 'Drone Remote ID Protocol Entity Tags', rfc: [9374], scope: 'reserved', global: false }, + { cidr: '2001:db8::/32', id: 'documentation-v6', label: 'Documentation', rfc: [3849], scope: 'documentation', global: false }, + { cidr: '2002::/16', id: '6to4', label: '6to4', rfc: [3056], scope: 'global', global: true }, + { cidr: '2620:4f:8000::/48', id: 'as112-direct-v6', label: 'Direct Delegation AS112 Service', rfc: [7534], scope: 'global', global: true }, + { cidr: '3fff::/20', id: 'documentation-3fff', label: 'Documentation', rfc: [9637], scope: 'documentation', global: false }, + { cidr: '5f00::/16', id: 'srv6', label: 'Segment Routing (SRv6) SIDs', rfc: [9602], scope: 'reserved', global: false }, + { cidr: '2000::/3', id: 'global-unicast', label: 'Global Unicast', rfc: [4291], scope: 'global', global: true }, + { cidr: 'fc00::/7', id: 'ula', label: 'Unique Local', rfc: [4193], scope: 'private', global: false }, + { cidr: 'fe80::/10', id: 'link-local-v6', label: 'Link-Local', rfc: [4291], scope: 'link-local', global: false }, + { cidr: 'fec0::/10', id: 'site-local', label: 'Site-Local (deprecated)', rfc: [3879], scope: 'reserved', global: false }, + { cidr: 'ff00::/8', id: 'multicast-v6', label: 'Multicast', rfc: [4291], scope: 'multicast', global: false }, + { cidr: 'ff02::1:ff00:0/104', id: 'solicited-node', label: 'Solicited-Node Multicast', rfc: [4291], scope: 'multicast', global: false }, +]); + +// IPv6 space outside every registered block is unassigned by the IETF. +const RESERVED_IETF_V6 = { id: 'reserved-ietf', label: 'Reserved by IETF', rfc: [4291], scope: 'reserved', global: false }; + +export const IPV6_MULTICAST_SCOPES = { + 0: 'reserved', + 1: 'interface-local', + 2: 'link-local', + 3: 'realm-local', + 4: 'admin-local', + 5: 'site-local', + 8: 'organization-local', + 14: 'global', + 15: 'reserved', +}; + +// Every block containing `value`, most specific first. +export const lookupBlocks = (family, value) => { + const table = family === 4 ? IPV4_SPECIAL_BLOCKS : IPV6_SPECIAL_BLOCKS; + return table + .filter((b) => prefixContains(b.network, b.prefix, family, value)) + .sort((a, b) => b.prefix - a.prefix) + .map(({ id, label, rfc, scope, global, cidr }) => ({ id, label, rfc, scope, global, cidr })); +}; + +// IPv4 outside every block is plain global unicast (no block); IPv6 outside +// every block is IETF-reserved, which is a classification of its own. +const classify = (family, value) => { + let blocks = lookupBlocks(family, value); + if (family === 6 && blocks.length === 0) blocks = [{ ...RESERVED_IETF_V6, cidr: null }]; + const block = blocks[0] || null; + return { + block, + blocks, + scope: block ? block.scope : 'global', + isGlobal: block ? block.global : true, + }; +}; + +/* ------------------------------------------------------------------ */ +/* Small formatters */ +/* ------------------------------------------------------------------ */ + +const hex = (value, width) => value.toString(16).padStart(width, '0'); +const byteHex = (n) => n.toString(16).padStart(2, '0'); + +// `pow2` when the count is a power of two; `approx` once the digits stop +// being readable. +export const formatCount = (value) => { + if (typeof value !== 'bigint' || value < 0n) return null; + const exact = value.toString(); + const grouped = exact.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const pow2 = value > 0n && (value & (value - 1n)) === 0n ? value.toString(2).length - 1 : null; + let approx = null; + if (exact.length > 15) { + const mantissa = (Number(exact.slice(0, 4)) / 1000).toFixed(2); + approx = `${mantissa}×10^${exact.length - 1}`; + } + return { exact, grouped, pow2, approx }; +}; + +// One-line rendering of a formatCount result. +export const countLabel = (count) => { + if (!count) return ''; + return count.pow2 !== null && count.pow2 >= 10 ? `${count.grouped} (2^${count.pow2})` : count.grouped; +}; + +export const ptrName = (parsed) => { + if (!parsed) return null; + if (parsed.family === 4) { + const octets = toOctets(parsed.value); + return octets ? `${octets.reverse().join('.')}.in-addr.arpa` : null; + } + if (parsed.family !== 6 || typeof parsed.value !== 'bigint' || parsed.value < 0n || parsed.value > MAX_V6) return null; + return `${hex(parsed.value, 32).split('').reverse().join('.')}.ip6.arpa`; +}; + +// Reverse zone of a block; only octet-aligned (v4) / nibble-aligned (v6) +// prefixes have one — RFC 2317 classless delegation is out of scope. +export const ptrZone = (cidr) => { + const c = typeof cidr === 'string' ? parseCidr(cidr) : cidr; + if (!c) return null; + if (c.family === 4) { + if (c.prefix % 8 !== 0) return null; + const labels = toOctets(c.network).slice(0, c.prefix / 8).reverse(); + return [...labels, 'in-addr.arpa'].join('.'); + } + if (c.prefix % 4 !== 0) return null; + const nibbles = hex(c.network, 32).slice(0, c.prefix / 4).split('').reverse(); + return [...nibbles, 'ip6.arpa'].join('.'); +}; + +/* ------------------------------------------------------------------ */ +/* IPv4 derivations */ +/* ------------------------------------------------------------------ */ + +// Spellings inet_aton-style parsers also accept — how an IP sneaks past a +// naive string filter. +export const obfuscatedForms = (value) => { + const octets = toOctets(value); + if (!octets) return null; + const [a, b, c, d] = octets; + return { + decimal: value.toString(), + hex: `0x${hex(value, 8)}`, + octal: `0${value.toString(8)}`, + dottedHex: octets.map((o) => `0x${o.toString(16)}`).join('.'), + dottedOctal: octets.map((o) => `0${o.toString(8)}`).join('.'), + short: [ + `${a}.${b}.${(c << 8) | d}`, + `${a}.${(b << 16) | (c << 8) | d}`, + ], + }; +}; + +export const ipv4ToEmbeddedForms = (value) => { + const dotted = formatIPv4(value); + if (dotted === null) return null; + return { + mapped: `::ffff:${dotted}`, + mappedHex: formatIPv6((0xffffn << 32n) | value), + compat: `::${dotted}`, + nat64: `64:ff9b::${dotted}`, + sixToFour: `${formatIPv6((0x2002n << 112n) | (value << 80n))}/48`, + }; +}; + +const ipv4Class = (firstOctet) => { + if (firstOctet < 128) return 'A'; + if (firstOctet < 192) return 'B'; + if (firstOctet < 224) return 'C'; + if (firstOctet < 240) return 'D'; + return 'E'; +}; + +export const analyzeIPv4 = (value) => { + const octets = toOctets(value); + if (!octets) return null; + return { + family: 4, + canonical: formatIPv4(value), + octets, + class: ipv4Class(octets[0]), + ...classify(4, value), + integer: value.toString(), + hex: `0x${hex(value, 8)}`, + octal: `0${value.toString(8)}`, + binary: octets.map((o) => o.toString(2).padStart(8, '0')).join('.'), + ptr: ptrName({ family: 4, value }), + obfuscated: obfuscatedForms(value), + embedded: ipv4ToEmbeddedForms(value), + }; +}; + +/* ------------------------------------------------------------------ */ +/* IPv6 derivations */ +/* ------------------------------------------------------------------ */ + +const EMBEDDED_LOW32 = new Set(['ipv4-mapped', 'ipv4-compatible', 'nat64-wkp', 'nat64-local']); + +// The IPv4 an address embeds, by transition mechanism (Teredo's client is +// XOR-obscured — see decodeTeredo). +export const extractEmbeddedIPv4 = (value) => { + if (typeof value !== 'bigint' || value < 0n || value > MAX_V6) return null; + const block = lookupBlocks(6, value)[0]; + if (!block) return null; + if (EMBEDDED_LOW32.has(block.id)) return formatIPv4(value & 0xffffffffn); + if (block.id === '6to4') return formatIPv4((value >> 80n) & 0xffffffffn); + return null; +}; + +// RFC 4380 layout: prefix(32) server(32) flags(16) ~port(16) ~client(32). +export const decodeTeredo = (value) => { + if (typeof value !== 'bigint' || !prefixContains(0x2001n << 112n, 32, 6, value)) return null; + const flags = Number((value >> 48n) & 0xffffn); + return { + server: formatIPv4((value >> 64n) & 0xffffffffn), + client: formatIPv4((value & 0xffffffffn) ^ 0xffffffffn), + port: Number((value >> 32n) & 0xffffn) ^ 0xffff, + cone: (flags & 0x8000) !== 0, + }; +}; + +export const solicitedNode = (value) => { + if (typeof value !== 'bigint' || value < 0n || value > MAX_V6) return null; + return formatIPv6((0xff02n << 112n) | (0x1ffn << 24n) | (value & 0xffffffn)); +}; + +const iidBytes = (iid) => Array.from({ length: 8 }, (_, i) => Number((iid >> BigInt((7 - i) * 8)) & 0xffn)); + +// Modified EUI-64 → MAC: `ff:fe` marks the expansion, U/L bit was flipped. +export const iidToMac = (iid) => { + if (typeof iid !== 'bigint' || iid < 0n || iid > (1n << 64n) - 1n) return null; + const b = iidBytes(iid); + if (b[3] !== 0xff || b[4] !== 0xfe) return null; + return { + mac: [b[0] ^ 0x02, b[1], b[2], b[5], b[6], b[7]].map(byteHex).join(':'), + universal: (b[0] & 0x02) !== 0, + }; +}; + +const IID_SCOPES = new Set(['global', 'private', 'link-local', 'documentation']); + +export const analyzeIPv6 = (value, { embeddedV4 = false } = {}) => { + const hextets = toHextets(value); + if (!hextets) return null; + const classification = classify(6, value); + const blockId = classification.block?.id ?? RESERVED_IETF_V6.id; + + let multicast = null; + if (classification.scope === 'multicast') { + const flags = (hextets[0] >> 4) & 0xf; + const scopeId = hextets[0] & 0xf; + multicast = { + flags: { T: (flags & 1) !== 0, P: (flags & 2) !== 0, R: (flags & 4) !== 0 }, + scope: { id: scopeId, name: IPV6_MULTICAST_SCOPES[scopeId] ?? 'unassigned' }, + solicitedNodeSuffix: blockId === 'solicited-node' + ? iidBytes(value & 0xffffffn).slice(5).map(byteHex).join(':') + : null, + }; + } + + let ula = null; + if (blockId === 'ula') { + const globalId = hex((value >> 80n) & 0xffffffffffn, 10); + ula = { + locallyAssigned: ((value >> 120n) & 1n) === 1n, + globalId: `${globalId.slice(0, 2)}:${globalId.slice(2, 6)}:${globalId.slice(6)}`, + subnetId: hex((value >> 64n) & 0xffffn, 4), + }; + } + + let iid = null; + if (IID_SCOPES.has(classification.scope) && blockId !== 'teredo') { + const iidValue = value & ((1n << 64n) - 1n); + const eui = iidToMac(iidValue); + iid = { + hex: hex(iidValue, 16).match(/.{4}/g).join(':'), + isEui64: eui !== null, + mac: eui?.mac ?? null, + universal: eui?.universal ?? null, + isSubnetRouterAnycast: iidValue === 0n, + solicitedNode: solicitedNode(value), + }; + } + + return { + family: 6, + compressed: formatIPv6(value), + expanded: formatIPv6(value, { expanded: true }), + hextets, + ...classification, + integer: value.toString(), + hex: `0x${hex(value, 32)}`, + ptr: ptrName({ family: 6, value }), + embeddedV4: extractEmbeddedIPv4(value), + embeddedV4Notation: embeddedV4, + teredo: blockId === 'teredo' ? decodeTeredo(value) : null, + multicast, + ula, + iid, + }; +}; + +/* ------------------------------------------------------------------ */ +/* Prefix / range / list derivations */ +/* ------------------------------------------------------------------ */ + +const splitPresetsFor = (prefix, family) => { + const bits = family === 4 ? 32 : 128; + const presets = []; + if (family === 4) { + for (let p = prefix + 1; p <= Math.min(prefix + 8, bits); p += 1) presets.push(p); + } else { + // Nibble boundaries keep ip6.arpa delegation clean, so offer those. + for (let p = prefix + 1; p <= Math.min(prefix + 16, bits); p += 1) { + if (p % 4 === 0) presets.push(p); + } + } + return presets; +}; + +export const analyzeCidr = (cidrStr) => { + const info = cidrInfo(cidrStr); + if (!info) return null; + const { family, prefix } = info; + const ip = (value) => formatIp({ family, value }); + return { + family, + prefix, + cidr: info.cidr, + address: ip(info.address), + network: ip(info.network), + broadcast: info.broadcast === null ? null : ip(info.broadcast), + first: ip(info.first), + last: ip(info.last), + lastAddress: ip(info.lastAddress), + mask: ip(info.mask), + maskHex: `0x${hex(info.mask, family === 4 ? 8 : 32)}`, + wildcard: ip(info.wildcard), + count: formatCount(info.count), + usable: formatCount(info.usable), + aligned: info.aligned, + ptrZone: ptrZone({ family, prefix, network: info.network }), + ...classify(family, info.network), + splitPresets: splitPresetsFor(prefix, family), + slash64s: family === 6 && prefix <= 64 ? formatCount(1n << BigInt(64 - prefix)) : null, + }; +}; + +export const analyzeRange = (startStr, endStr) => { + const range = rangeToCidrs(startStr, endStr); + if (!range) return null; + const start = parseIp(startStr); + const end = parseIp(endStr); + return { + family: range.family, + start: formatIp(start), + end: formatIp(end), + count: formatCount(end.value - start.value + 1n), + cidrs: range.cidrs, + aggregated: range.cidrs.length === 1 ? range.cidrs[0] : null, + }; +}; + +const familySummary = (tokens, aggregated, family) => { + const input = []; + for (const token of tokens) { + const cidr = parseCidr(token); + if (cidr) { + if (cidr.family === family) input.push(formatCidr(cidr)); + continue; + } + const ip = parseIp(token); + if (ip && ip.family === family) input.push(`${formatIp(ip)}/${family === 4 ? 32 : 128}`); + } + const total = aggregated.reduce((sum, cidr) => sum + cidrInfo(cidr).count, 0n); + return { input, aggregated, count: formatCount(total) }; +}; + +export const analyzeList = (tokens) => { + if (!Array.isArray(tokens)) return null; + const { v4, v6, invalid } = aggregateCidrs(tokens); + return { + v4: familySummary(tokens, v4, 4), + v6: familySummary(tokens, v6, 6), + invalid, + }; +}; + +export const analyzeInteger = (value, family) => { + if (typeof value !== 'bigint' || value < 0n) return null; + if (family === 4 ? value > MAX_V4 : value > MAX_V6) return null; + const bits = family === 4 ? 32 : 128; + return { + family, + asIPv4: family === 4 ? formatIPv4(value) : null, + asIPv6: formatIPv6(value), + decimal: value.toString(), + hex: `0x${hex(value, bits / 4)}`, + binary: value.toString(2).padStart(bits, '0').match(/.{8}/g).join(' '), + bits: value === 0n ? 0 : value.toString(2).length, + }; +}; + +/* ------------------------------------------------------------------ */ +/* Input classifier */ +/* ------------------------------------------------------------------ */ + +const invalid = (raw, input, reason) => ({ kind: 'invalid', raw, input, reason }); + +// inet_aton: 1–4 dot-separated parts, each decimal / leading-0 octal / 0x +// hex; the last part absorbs the remaining octets. +const INET_ATON_PART = /^(0x[0-9a-f]+|0[0-7]*|[1-9]\d*)$/i; + +const parseInetAton = (input) => { + const parts = input.split('.'); + if (parts.length < 2 || parts.length > 4 || !parts.every((p) => INET_ATON_PART.test(p))) return null; + const notations = new Set(); + const numbers = parts.map((p) => { + if (/^0x/i.test(p)) { notations.add('hex'); return BigInt(p); } + if (p.length > 1 && p.startsWith('0')) { notations.add('octal'); return BigInt(parseInt(p, 8)); } + return BigInt(p); + }); + const last = numbers.pop(); + if (numbers.some((n) => n > 255n)) return null; + const lastBits = BigInt(8 * (5 - parts.length)); + if (last >= 1n << lastBits) return null; + let value = last; + numbers.forEach((n, i) => { value |= n << BigInt(8 * (3 - i)); }); + let notation = 'shorthand'; + if (notations.size === 2) notation = 'mixed'; + else if (notations.size === 1) notation = [...notations][0]; + return { value, notation }; +}; + +const normalize = (raw) => { + let input = typeof raw === 'string' ? raw.trim() : ''; + if (/^\[.*\]$/.test(input)) input = input.slice(1, -1).trim(); + let zone = null; + if (input.includes(':') && input.includes('%') && !/\s/.test(input)) { + const at = input.indexOf('%'); + zone = input.slice(at + 1); + input = input.slice(0, at); + } + return { input: input.replace(/\s+/g, ' '), zone }; +}; + +const rangeOf = (input) => { + const m = /^(\S+) ?- ?(\S+)$/.exec(input); + if (!m) return null; + const start = parseIp(m[1]); + if (!start) return null; + let end = parseIp(m[2]); + if (!end && start.family === 4 && /^\d{1,3}$/.test(m[2]) && Number(m[2]) <= 255) { + end = { family: 4, value: (start.value & ~0xffn & MAX_V4) | BigInt(m[2]) }; + } + if (!end || end.family !== start.family) return { error: true }; + const reversed = start.value > end.value; + return reversed ? { start: end, end: start, reversed } : { start, end, reversed }; +}; + +export const classifyInput = (raw) => { + let { input } = normalize(raw); + const { zone } = normalize(raw); + const base = { raw, input, zone }; + if (input === '') return invalid(raw, input, 'empty'); + + const range = rangeOf(input); + if (range) { + if (range.error) return invalid(raw, input, 'range'); + return { ...base, kind: 'range', family: range.start.family, start: range.start, end: range.end, reversed: range.reversed }; + } + + const tokens = input.split(/[\s,;]+/).filter(Boolean); + if (tokens.length >= 2) { + const bad = tokens.filter((t) => !parseCidr(t) && !parseIp(t)); + if (bad.length === tokens.length) return invalid(raw, input, 'list-no-valid'); + return { ...base, kind: 'cidr-list', tokens, invalid: bad }; + } + // A lone token with a trailing separator (`1.2.3.4,`) is still one item. + input = tokens[0] ?? input; + base.input = input; + + if (input.includes('/')) { + const cidr = parseCidr(input); + if (cidr) return { ...base, kind: cidr.family === 4 ? 'ipv4-cidr' : 'ipv6-cidr', cidr }; + const left = input.slice(0, input.indexOf('/')); + return invalid(raw, input, parseIp(left) ? 'cidr-prefix' : 'cidr-address'); + } + + if (input.includes(':')) { + const v6 = parseIPv6(input); + if (!v6) return invalid(raw, input, 'ipv6-syntax'); + return { ...base, kind: 'ipv6', value: v6.value, embeddedV4: v6.embeddedV4 }; + } + + const v4 = parseIPv4(input); + if (v4) return { ...base, kind: 'ipv4', value: v4.value, notation: 'dotted', obfuscated: false }; + + if (/^0x[0-9a-f]+$/i.test(input)) { + const digits = input.length - 2; + if (digits > 32) return invalid(raw, input, 'hex-too-large'); + return { ...base, kind: 'hex', family: digits <= 8 ? 4 : 6, value: BigInt(input), prefixed: true }; + } + + // A leading zero reads as octal, as inet_aton would. + if (/^0[0-7]+$/.test(input)) { + const value = BigInt(parseInt(input, 8)); + if (value > MAX_V4) return invalid(raw, input, 'integer-too-large'); + return { ...base, kind: 'ipv4', value, notation: 'octal', obfuscated: true }; + } + + if (/^\d+$/.test(input)) { + if (/^0\d/.test(input)) return invalid(raw, input, 'bad-octal'); + const value = BigInt(input); + if (value > MAX_V6) return invalid(raw, input, 'integer-too-large'); + return { ...base, kind: 'integer', family: value <= MAX_V4 ? 4 : 6, value }; + } + + if (/^[0-9a-f]{32}$/i.test(input)) { + return { ...base, kind: 'hex', family: 6, value: BigInt(`0x${input}`), prefixed: false }; + } + + const loose = parseInetAton(input); + if (loose) return { ...base, kind: 'ipv4', value: loose.value, notation: loose.notation, obfuscated: true }; + + return invalid(raw, input, 'unrecognized'); +}; + +/* ------------------------------------------------------------------ */ +/* Entry point */ +/* ------------------------------------------------------------------ */ + +const hostAnalysis = (family, value, { embeddedV4 = false, prefix = null } = {}) => { + const address = family === 4 ? analyzeIPv4(value) : analyzeIPv6(value, { embeddedV4 }); + const defaultPrefix = family === 4 ? 32 : 64; + return { + address, + cidr: analyzeCidr(`${formatIp({ family, value })}/${prefix ?? defaultPrefix}`), + }; +}; + +export const calculate = (raw) => { + const c = classifyInput(raw); + switch (c.kind) { + case 'ipv4': + return { ...c, analysis: hostAnalysis(4, c.value) }; + case 'ipv6': + return { ...c, analysis: hostAnalysis(6, c.value, { embeddedV4: c.embeddedV4 }) }; + case 'ipv4-cidr': + case 'ipv6-cidr': + return { ...c, analysis: hostAnalysis(c.cidr.family, c.cidr.address, { prefix: c.cidr.prefix }) }; + case 'integer': + case 'hex': + return { ...c, analysis: { ...hostAnalysis(c.family, c.value), number: analyzeInteger(c.value, c.family) } }; + case 'range': + return { ...c, analysis: analyzeRange(formatIp(c.start), formatIp(c.end)) }; + case 'cidr-list': + return { ...c, analysis: analyzeList(c.tokens) }; + default: + return c; + } +}; diff --git a/tests/ip-calc.test.js b/tests/ip-calc.test.js new file mode 100644 index 000000000..8e64c255a --- /dev/null +++ b/tests/ip-calc.test.js @@ -0,0 +1,460 @@ +// Guards frontend/utils/ip-calc.js — the classifier and its rule order, the +// IANA block tables, the IPv6 decoders, PTR names, obfuscated / embedded +// forms, count formatting, and the never-throws contract of `calculate()`. +// MAC input is asserted absent (MAC Lookup's job). + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + IPV4_SPECIAL_BLOCKS, IPV6_SPECIAL_BLOCKS, IPV6_MULTICAST_SCOPES, lookupBlocks, + classifyInput, calculate, + analyzeIPv4, analyzeIPv6, analyzeCidr, analyzeRange, analyzeList, analyzeInteger, + extractEmbeddedIPv4, decodeTeredo, iidToMac, solicitedNode, + ptrName, ptrZone, obfuscatedForms, ipv4ToEmbeddedForms, formatCount, countLabel, +} from '../frontend/utils/ip-calc.js'; +import { parseIp, parseCidr, formatIPv4 } from '../common/ip-math.js'; + +const v = (ip) => parseIp(ip).value; + +describe('special-purpose tables', () => { + for (const [name, table] of [['IPv4', IPV4_SPECIAL_BLOCKS], ['IPv6', IPV6_SPECIAL_BLOCKS]]) { + it(`${name} rows parse, are aligned, and have unique ids`, () => { + const ids = new Set(); + for (const row of table) { + const cidr = parseCidr(row.cidr); + assert.ok(cidr, `${row.cidr} must parse`); + assert.equal(cidr.aligned, true, `${row.cidr} must be a network address`); + assert.ok(row.rfc.length > 0, `${row.id} cites an RFC`); + assert.equal(typeof row.global, 'boolean'); + assert.ok(!ids.has(row.id), `duplicate id ${row.id}`); + ids.add(row.id); + } + }); + } +}); + +describe('IPv4 blocks', () => { + const cases = [ + ['10.1.2.3', 'private-10'], ['172.15.255.255', null], ['172.16.0.0', 'private-172'], + ['172.31.255.255', 'private-172'], ['172.32.0.0', null], ['100.63.255.255', null], + ['100.64.0.0', 'shared-cgnat'], ['100.127.255.255', 'shared-cgnat'], ['100.128.0.0', null], + ['127.0.0.1', 'loopback'], ['127.255.255.255', 'loopback'], ['169.254.1.1', 'link-local'], + ['192.0.0.1', 'ds-lite'], ['192.0.0.8', 'dummy'], ['192.0.0.9', 'pcp-anycast'], ['192.0.0.10', 'turn-anycast'], + ['192.0.0.170', 'nat64-discovery'], ['192.0.0.171', 'nat64-discovery'], ['192.0.0.200', 'ietf-protocol'], + ['192.0.2.1', 'test-net-1'], ['192.88.99.1', '6to4-relay'], ['198.18.0.1', 'benchmarking'], + ['198.19.255.255', 'benchmarking'], ['198.20.0.0', null], ['198.51.100.1', 'test-net-2'], + ['203.0.113.1', 'test-net-3'], ['224.0.0.1', 'mcast-local-control'], ['232.1.1.1', 'mcast-ssm'], + ['239.1.1.1', 'mcast-admin'], ['225.0.0.1', 'multicast'], ['240.0.0.1', 'reserved-240'], + ['255.255.255.255', 'broadcast'], ['0.0.0.0', 'this-host'], ['0.1.2.3', 'this-network'], ['8.8.8.8', null], + ]; + for (const [ip, id] of cases) { + it(`${ip} → ${id ?? 'global'}`, () => { + const r = analyzeIPv4(v(ip)); + assert.equal(r.block?.id ?? null, id); + if (id === null) { + assert.equal(r.scope, 'global'); + assert.equal(r.isGlobal, true); + assert.deepEqual(r.blocks, []); + } + }); + } + it('lists every containing block, most specific first', () => { + assert.deepEqual(analyzeIPv4(v('192.0.0.1')).blocks.map((b) => b.id), ['ds-lite', 'ietf-protocol']); + assert.deepEqual(analyzeIPv4(v('255.255.255.255')).blocks.map((b) => b.id), ['broadcast', 'reserved-240']); + assert.deepEqual(analyzeIPv4(v('224.0.0.1')).blocks.map((b) => b.id), ['mcast-local-control', 'multicast']); + }); + it('classes A–E by first octet', () => { + const cls = (ip) => analyzeIPv4(v(ip)).class; + assert.equal(cls('1.0.0.0'), 'A'); assert.equal(cls('127.255.255.255'), 'A'); + assert.equal(cls('128.0.0.0'), 'B'); assert.equal(cls('191.255.255.255'), 'B'); + assert.equal(cls('192.0.0.0'), 'C'); assert.equal(cls('223.255.255.255'), 'C'); + assert.equal(cls('224.0.0.0'), 'D'); assert.equal(cls('240.0.0.0'), 'E'); + }); + it('renders representations and PTR', () => { + const r = analyzeIPv4(v('127.0.0.1')); + assert.equal(r.canonical, '127.0.0.1'); + assert.equal(r.integer, '2130706433'); + assert.equal(r.hex, '0x7f000001'); + assert.equal(r.octal, '017700000001'); + assert.equal(r.binary, '01111111.00000000.00000000.00000001'); + assert.equal(r.ptr, '1.0.0.127.in-addr.arpa'); + assert.equal(analyzeIPv4(2n ** 32n), null); + }); +}); + +describe('IPv6 blocks', () => { + const cases = [ + ['::', 'unspecified'], ['::1', 'loopback'], ['::2', 'ipv4-compatible'], ['::ffff:192.0.2.1', 'ipv4-mapped'], + ['::ffff:c000:201', 'ipv4-mapped'], ['64:ff9b::c000:201', 'nat64-wkp'], ['64:ff9b:1::c000:201', 'nat64-local'], + ['100::1', 'discard'], ['2001::1', 'teredo'], ['2001:1::1', 'pcp-anycast-v6'], ['2001:1::2', 'turn-anycast-v6'], + ['2001:1::3', 'dnssd-srp-anycast'], ['2001:1::4', 'ietf-protocol-v6'], ['2001:2::1', 'benchmarking-v6'], + ['2001:3::1', 'amt-v6'], ['2001:4:112::1', 'as112-v6'], ['2001:10::1', 'orchid'], ['2001:20::1', 'orchid-v2'], + ['2001:2f:ffff::1', 'orchid-v2'], ['2001:30::1', 'drone-rid'], ['2001:db8::1', 'documentation-v6'], + ['2001:db9::1', 'global-unicast'], ['2002:c000:201::1', '6to4'], ['2620:4f:8000::1', 'as112-direct-v6'], + ['3fff::1', 'documentation-3fff'], ['3fff:fff::1', 'documentation-3fff'], ['3fff:1000::1', 'global-unicast'], + ['5f00::1', 'srv6'], ['2600::1', 'global-unicast'], ['4000::1', 'reserved-ietf'], ['fbff::1', 'reserved-ietf'], + ['fc00::1', 'ula'], ['fd12:3456:789a:1::1', 'ula'], ['fe80::1', 'link-local-v6'], ['febf::1', 'link-local-v6'], + ['fec0::1', 'site-local'], ['ff02::1', 'multicast-v6'], ['ff02::1:ff00:1', 'solicited-node'], + ]; + for (const [ip, id] of cases) { + it(`${ip} → ${id}`, () => assert.equal(analyzeIPv6(v(ip)).block.id, id)); + } + it('global flag follows the block', () => { + assert.equal(analyzeIPv6(v('2600::1')).isGlobal, true); + assert.equal(analyzeIPv6(v('2002:c000:201::1')).isGlobal, true); + assert.equal(analyzeIPv6(v('fd00::1')).isGlobal, false); + assert.equal(analyzeIPv6(v('4000::1')).scope, 'reserved'); + assert.equal(analyzeIPv6(v('4000::1')).blocks.length, 1); + }); + it('renders compressed / expanded / hex / PTR', () => { + const r = analyzeIPv6(v('2001:db8::1')); + assert.equal(r.compressed, '2001:db8::1'); + assert.equal(r.expanded, '2001:0db8:0000:0000:0000:0000:0000:0001'); + assert.equal(r.hex, '0x20010db8000000000000000000000001'); + assert.equal(r.ptr, '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa'); + assert.equal(analyzeIPv6(2n ** 128n), null); + }); +}); + +describe('IPv6 decoders', () => { + it('extracts embedded IPv4 per mechanism', () => { + assert.equal(extractEmbeddedIPv4(v('::ffff:192.0.2.1')), '192.0.2.1'); + assert.equal(extractEmbeddedIPv4(v('::2')), '0.0.0.2'); + assert.equal(extractEmbeddedIPv4(v('64:ff9b::c000:201')), '192.0.2.1'); + assert.equal(extractEmbeddedIPv4(v('64:ff9b:1::c000:201')), '192.0.2.1'); + assert.equal(extractEmbeddedIPv4(v('2002:c000:201::1')), '192.0.2.1'); + assert.equal(extractEmbeddedIPv4(v('::1')), null); + assert.equal(extractEmbeddedIPv4(v('2001:db8::1')), null); + assert.equal(extractEmbeddedIPv4('nope'), null); + assert.equal(analyzeIPv6(v('::ffff:192.0.2.1'), { embeddedV4: true }).embeddedV4Notation, true); + }); + it('decodes Teredo', () => { + const t = decodeTeredo(v('2001:0:4136:e378:8000:63bf:3fff:fdd2')); + assert.deepEqual(t, { server: '65.54.227.120', client: '192.0.2.45', port: 40000, cone: true }); + assert.equal(decodeTeredo(v('2001:db8::1')), null); + assert.equal(analyzeIPv6(v('2001:db8::1')).teredo, null); + assert.equal(analyzeIPv6(v('2001:0:4136:e378:8000:63bf:3fff:fdd2')).teredo.port, 40000); + }); + it('recovers a MAC from a modified EUI-64 interface id', () => { + const universal = analyzeIPv6(v('fe80::211:22ff:fe33:4455')).iid; + assert.equal(universal.isEui64, true); + assert.equal(universal.mac, '00:11:22:33:44:55'); + assert.equal(universal.universal, true); + assert.equal(universal.hex, '0211:22ff:fe33:4455'); + assert.equal(universal.solicitedNode, 'ff02::1:ff33:4455'); + const local = analyzeIPv6(v('fe80::11:22ff:fe33:4455')).iid; + assert.equal(local.mac, '02:11:22:33:44:55'); + assert.equal(local.universal, false); + const plain = analyzeIPv6(v('2001:db8::1')).iid; + assert.equal(plain.isEui64, false); + assert.equal(plain.mac, null); + assert.equal(plain.isSubnetRouterAnycast, false); + assert.equal(analyzeIPv6(v('2001:db8::')).iid.isSubnetRouterAnycast, true); + assert.equal(analyzeIPv6(v('::ffff:1.2.3.4')).iid, null); + assert.equal(analyzeIPv6(v('2001:0:4136:e378:8000:63bf:3fff:fdd2')).iid, null); + assert.equal(analyzeIPv6(v('2002:c000:201::211:22ff:fe33:4455')).iid.mac, '00:11:22:33:44:55'); + assert.equal(iidToMac(-1n), null); + assert.equal(iidToMac(0n), null); + }); + it('iidToMac on a bare interface id', () => { + assert.deepEqual(iidToMac(0x021122fffe334455n), { mac: '00:11:22:33:44:55', universal: true }); + assert.deepEqual(iidToMac(0x001122fffe334455n), { mac: '02:11:22:33:44:55', universal: false }); + assert.equal(iidToMac(0x0011223344556677n), null); + }); + it('solicited-node multicast', () => { + assert.equal(solicitedNode(v('fe80::211:22ff:fe33:4455')), 'ff02::1:ff33:4455'); + assert.equal(solicitedNode(v('2001:db8::1')), 'ff02::1:ff00:1'); + assert.equal(solicitedNode(5), null); + }); + it('multicast flags and scopes', () => { + const link = analyzeIPv6(v('ff02::1')).multicast; + assert.deepEqual(link.flags, { T: false, P: false, R: false }); + assert.deepEqual(link.scope, { id: 2, name: 'link-local' }); + assert.equal(link.solicitedNodeSuffix, null); + assert.equal(analyzeIPv6(v('ff02::1:ff00:1')).multicast.solicitedNodeSuffix, '00:00:01'); + assert.equal(analyzeIPv6(v('ff05::2')).multicast.scope.name, 'site-local'); + const transient = analyzeIPv6(v('ff1e::1')).multicast; + assert.equal(transient.flags.T, true); + assert.equal(transient.scope.name, 'global'); + const prefixBased = analyzeIPv6(v('ff3e:40:2001:db8::1')).multicast.flags; + assert.deepEqual(prefixBased, { T: true, P: true, R: false }); + assert.equal(analyzeIPv6(v('ff0f::1')).multicast.scope.name, 'reserved'); + assert.equal(analyzeIPv6(v('ff06::1')).multicast.scope.name, 'unassigned'); + assert.equal(analyzeIPv6(v('2001:db8::1')).multicast, null); + assert.equal(IPV6_MULTICAST_SCOPES[14], 'global'); + }); + it('ULA fields', () => { + const random = analyzeIPv6(v('fd12:3456:789a:1::1')).ula; + assert.deepEqual(random, { locallyAssigned: true, globalId: '12:3456:789a', subnetId: '0001' }); + assert.equal(analyzeIPv6(v('fc00::1')).ula.locallyAssigned, false); + assert.equal(analyzeIPv6(v('2001:db8::1')).ula, null); + }); +}); + +describe('PTR', () => { + it('names', () => { + assert.equal(ptrName(parseIp('192.0.2.1')), '1.2.0.192.in-addr.arpa'); + assert.equal(ptrName(parseIp('::ffff:192.0.2.1')), '1.0.2.0.0.0.0.c.f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa'); + assert.equal(ptrName(null), null); + assert.equal(ptrName({ family: 6, value: -1n }), null); + }); + it('zones', () => { + assert.equal(ptrZone('192.0.2.0/24'), '2.0.192.in-addr.arpa'); + assert.equal(ptrZone('10.0.0.0/8'), '10.in-addr.arpa'); + assert.equal(ptrZone('0.0.0.0/0'), 'in-addr.arpa'); + assert.equal(ptrZone('192.0.2.0/23'), null); + assert.equal(ptrZone('2001:db8::/32'), '8.b.d.0.1.0.0.2.ip6.arpa'); + assert.equal(ptrZone('2001:db8::/48'), '0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa'); + assert.equal(ptrZone('::/0'), 'ip6.arpa'); + assert.equal(ptrZone('2001:db8::/33'), null); + assert.equal(ptrZone('junk'), null); + }); +}); + +describe('IPv4 forms', () => { + it('obfuscated forms', () => { + const f = obfuscatedForms(v('127.0.0.1')); + assert.equal(f.decimal, '2130706433'); + assert.equal(f.hex, '0x7f000001'); + assert.equal(f.octal, '017700000001'); + assert.equal(f.dottedHex, '0x7f.0x0.0x0.0x1'); + assert.equal(f.dottedOctal, '0177.00.00.01'); + assert.deepEqual(f.short, ['127.0.1', '127.1']); + assert.deepEqual(obfuscatedForms(v('192.168.1.1')).short, ['192.168.257', '192.11010305']); + assert.deepEqual(obfuscatedForms(v('0.0.0.0')).short, ['0.0.0', '0.0']); + assert.equal(obfuscatedForms(-1n), null); + }); + it('every obfuscated form re-parses to the same address', () => { + for (const ip of ['127.0.0.1', '192.168.1.1', '10.0.0.255', '255.255.255.255', '0.0.0.0', '8.8.8.8']) { + const f = obfuscatedForms(v(ip)); + for (const form of [f.decimal, f.hex, f.octal, f.dottedHex, f.dottedOctal, ...f.short]) { + const r = calculate(form); + assert.equal(r.analysis?.address?.canonical, ip, `${form} should read as ${ip}`); + } + } + }); + it('embedded forms', () => { + assert.deepEqual(ipv4ToEmbeddedForms(v('192.0.2.1')), { + mapped: '::ffff:192.0.2.1', + mappedHex: '::ffff:c000:201', + compat: '::192.0.2.1', + nat64: '64:ff9b::192.0.2.1', + sixToFour: '2002:c000:201::/48', + }); + assert.equal(ipv4ToEmbeddedForms(2n ** 32n), null); + }); +}); + +describe('formatCount', () => { + it('exact / grouped / pow2 / approx', () => { + assert.deepEqual(formatCount(1n), { exact: '1', grouped: '1', pow2: 0, approx: null }); + assert.deepEqual(formatCount(254n), { exact: '254', grouped: '254', pow2: null, approx: null }); + assert.equal(formatCount(256n).pow2, 8); + assert.deepEqual(formatCount(2n ** 32n), { exact: '4294967296', grouped: '4,294,967,296', pow2: 32, approx: null }); + const big = formatCount(2n ** 64n); + assert.equal(big.exact, '18446744073709551616'); + assert.equal(big.pow2, 64); + assert.equal(big.approx, '1.84×10^19'); + const huge = formatCount(2n ** 128n); + assert.equal(huge.exact, '340282366920938463463374607431768211456'); + assert.equal(huge.pow2, 128); + assert.equal(huge.approx, '3.40×10^38'); + assert.deepEqual(formatCount(0n), { exact: '0', grouped: '0', pow2: null, approx: null }); + assert.equal(formatCount(5), null); + assert.equal(formatCount(-1n), null); + }); + it('countLabel', () => { + assert.equal(countLabel(formatCount(254n)), '254'); + assert.equal(countLabel(formatCount(256n)), '256'); + assert.equal(countLabel(formatCount(1024n)), '1,024 (2^10)'); + assert.equal(countLabel(formatCount(2n ** 64n)), '18,446,744,073,709,551,616 (2^64)'); + assert.equal(countLabel(null), ''); + }); +}); + +describe('analyzeCidr', () => { + it('returns strings only, with zone and presets', () => { + const r = analyzeCidr('192.168.1.130/26'); + assert.equal(r.cidr, '192.168.1.128/26'); + assert.equal(r.address, '192.168.1.130'); + assert.equal(r.network, '192.168.1.128'); + assert.equal(r.broadcast, '192.168.1.191'); + assert.equal(r.first, '192.168.1.129'); + assert.equal(r.last, '192.168.1.190'); + assert.equal(r.mask, '255.255.255.192'); + assert.equal(r.maskHex, '0xffffffc0'); + assert.equal(r.wildcard, '0.0.0.63'); + assert.equal(r.count.exact, '64'); + assert.equal(r.usable.exact, '62'); + assert.equal(r.aligned, false); + assert.equal(r.ptrZone, null); + assert.equal(r.block.id, 'private-192'); + assert.deepEqual(r.splitPresets, [27, 28, 29, 30, 31, 32]); + assert.equal(r.slash64s, null); + for (const value of Object.values(r)) assert.notEqual(typeof value, 'bigint'); + }); + it('IPv6 prefixes count /64s and offer nibble presets', () => { + const r = analyzeCidr('2001:db8::/48'); + assert.equal(r.network, '2001:db8::'); + assert.equal(r.broadcast, null); + assert.equal(r.mask, 'ffff:ffff:ffff::'); + assert.equal(r.maskHex, '0xffffffffffff00000000000000000000'); + assert.equal(r.count.pow2, 80); + assert.equal(r.slash64s.exact, '65536'); + assert.equal(r.ptrZone, '0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa'); + assert.deepEqual(r.splitPresets, [52, 56, 60, 64]); + assert.deepEqual(analyzeCidr('2001:db8::/64').splitPresets, [68, 72, 76, 80]); + assert.deepEqual(analyzeCidr('2001:db8::/126').splitPresets, [128]); + assert.equal(analyzeCidr('2001:db8::/65').slash64s, null); + assert.equal(analyzeCidr('junk'), null); + }); +}); + +describe('classifyInput', () => { + const table = [ + ['', 'invalid', 'empty'], [' ', 'invalid', 'empty'], + ['192.0.2.1', 'ipv4'], [' 192.0.2.1 ', 'ipv4'], ['1.2.3.4,', 'ipv4'], + ['192.0.2.0/24', 'ipv4-cidr'], ['192.0.2.0/255.255.255.0', 'ipv4-cidr'], + ['192.0.2.0/33', 'invalid', 'cidr-prefix'], ['foo/24', 'invalid', 'cidr-address'], ['1.2.3.4/24/25', 'invalid', 'cidr-prefix'], + ['2001:db8::1', 'ipv6'], ['[2001:db8::1]', 'ipv6'], ['2001:db8::/32', 'ipv6-cidr'], ['::ffff:192.0.2.1/120', 'ipv6-cidr'], + ['2001:db8:::1', 'invalid', 'ipv6-syntax'], ['00:11:22:33:44:55:66:77', 'ipv6'], + ['2130706433', 'integer'], ['0', 'integer'], ['340282366920938463463374607431768211456', 'invalid', 'integer-too-large'], + ['0177', 'ipv4'], ['089', 'invalid', 'bad-octal'], ['123456789012', 'integer'], + ['0x7f000001', 'hex'], ['0x1', 'hex'], ['0x100000000', 'hex'], ['0x' + 'f'.repeat(33), 'invalid', 'hex-too-large'], + ['0x', 'invalid', 'unrecognized'], ['20010db8000000000000000000000001', 'hex'], + ['127.1', 'ipv4'], ['0177.0.0.1', 'ipv4'], ['0x7f.0.0.1', 'ipv4'], ['1.2.3.256', 'invalid', 'unrecognized'], + ['300.1', 'invalid', 'unrecognized'], ['1.2.3.4.5', 'invalid', 'unrecognized'], ['08.1.1.1', 'invalid', 'unrecognized'], + ['192.0.2.1 - 192.0.2.100', 'range'], ['192.0.2.1-100', 'range'], ['2001:db8::1-2001:db8::ff', 'range'], + ['192.0.2.1-2001:db8::1', 'invalid', 'range'], ['192.0.2.1-300', 'invalid', 'range'], + ['10.0.0.0/8 10.1.0.0/16', 'cidr-list'], ['10.0.0.0/8,10.1.0.0/16', 'cidr-list'], ['10.0.0.0/8\n10.1.0.0/16', 'cidr-list'], + ['10.0.0.0/8;1.2.3.4', 'cidr-list'], ['10.0.0.0/8, foo', 'cidr-list'], ['foo bar', 'invalid', 'list-no-valid'], + // MACs are not an input here — the MAC Lookup tool owns them. + ['00:11:22:33:44:55', 'invalid', 'ipv6-syntax'], ['00-11-22-33-44-55', 'invalid', 'unrecognized'], + ['0011.2233.4455', 'invalid', 'unrecognized'], ['00112233aabb', 'invalid', 'unrecognized'], + ['001122334455', 'ipv4'], ['00:11:22:33:44', 'invalid', 'ipv6-syntax'], ['hello', 'invalid', 'unrecognized'], + ]; + for (const [input, kind, reason] of table) { + it(`${JSON.stringify(input)} → ${kind}${reason ? `/${reason}` : ''}`, () => { + const r = classifyInput(input); + assert.equal(r.kind, kind); + if (reason) assert.equal(r.reason, reason); + }); + } + + it('carries kind-specific fields', () => { + assert.equal(classifyInput('fe80::1%eth0').zone, 'eth0'); + assert.equal(classifyInput('fe80::1%eth0').kind, 'ipv6'); + assert.equal(classifyInput('1.2.3.4,').input, '1.2.3.4'); + assert.equal(classifyInput('192.0.2.5/24').cidr.aligned, false); + assert.equal(classifyInput('192.0.2.0/255.255.255.0').cidr.prefix, 24); + assert.equal(classifyInput('::ffff:192.0.2.1').embeddedV4, true); + assert.equal(classifyInput('192.0.2.1').notation, 'dotted'); + assert.equal(classifyInput('127.1').notation, 'shorthand'); + assert.equal(classifyInput('0177.0.0.1').notation, 'octal'); + assert.equal(classifyInput('0x7f.0x0.0x0.0x1').notation, 'hex'); + assert.equal(classifyInput('0177.0x0.0.1').notation, 'mixed'); + assert.equal(classifyInput('0177').notation, 'octal'); + assert.equal(formatIPv4(classifyInput('0177').value), '0.0.0.127'); + assert.equal(formatIPv4(classifyInput('127.1.1').value), '127.1.0.1'); + assert.equal(formatIPv4(classifyInput('1.2.515').value), '1.2.2.3'); + assert.equal(formatIPv4(classifyInput('1.256').value), '1.0.1.0'); + assert.equal(classifyInput('0177.0.0.1').obfuscated, true); + assert.equal(classifyInput('192.0.2.1').obfuscated, false); + }); + + it('sizes integers and hex by family', () => { + assert.equal(classifyInput('2130706433').family, 4); + assert.equal(classifyInput('4294967295').family, 4); + assert.equal(classifyInput('4294967296').family, 6); + assert.equal(classifyInput('0x7f000001').family, 4); + assert.equal(classifyInput('0x100000000').family, 6); + assert.equal(classifyInput('0x20010db8000000000000000000000001').family, 6); + assert.equal(classifyInput('20010db8000000000000000000000001').prefixed, false); + assert.equal(classifyInput('0x1').prefixed, true); + }); + + it('twelve bare digits with a leading zero read as octal', () => { + assert.equal(classifyInput('001122334455').notation, 'octal'); + assert.equal(classifyInput('123456789012').kind, 'integer'); + }); + + it('normalises ranges', () => { + const r = classifyInput('192.0.2.100-192.0.2.1'); + assert.equal(r.reversed, true); + assert.equal(formatIPv4(r.start.value), '192.0.2.1'); + assert.equal(formatIPv4(classifyInput('192.0.2.1-100').end.value), '192.0.2.100'); + assert.equal(classifyInput('192.0.2.1 - 192.0.2.100').reversed, false); + }); + + it('lists keep their invalid tokens', () => { + const r = classifyInput('10.0.0.0/8, foo, 1.2.3.4'); + assert.deepEqual(r.tokens, ['10.0.0.0/8', 'foo', '1.2.3.4']); + assert.deepEqual(r.invalid, ['foo']); + }); +}); + +describe('calculate', () => { + it('ipv4 host defaults to /32, cidr keeps its prefix', () => { + const host = calculate('192.0.2.1'); + assert.equal(host.analysis.address.canonical, '192.0.2.1'); + assert.equal(host.analysis.cidr.prefix, 32); + const cidr = calculate('192.0.2.5/24'); + assert.equal(cidr.analysis.cidr.network, '192.0.2.0'); + assert.equal(cidr.analysis.address.canonical, '192.0.2.5'); + }); + it('ipv6 host defaults to /64', () => { + const r = calculate('2001:db8::1'); + assert.equal(r.analysis.cidr.prefix, 64); + assert.equal(r.analysis.cidr.network, '2001:db8::'); + }); + it('integer / hex add a number view', () => { + const r = calculate('0x7f000001'); + assert.equal(r.analysis.address.canonical, '127.0.0.1'); + assert.deepEqual(r.analysis.number, analyzeInteger(0x7f000001n, 4)); + assert.equal(r.analysis.number.asIPv4, '127.0.0.1'); + assert.equal(r.analysis.number.asIPv6, '::7f00:1'); + assert.equal(r.analysis.number.bits, 31); + const big = calculate('4294967296'); + assert.equal(big.analysis.number.asIPv4, null); + assert.equal(big.analysis.number.asIPv6, '::1:0:0'); + assert.equal(analyzeInteger(0n, 4).bits, 0); + assert.equal(analyzeInteger(2n ** 32n, 4), null); + }); + it('range', () => { + const r = calculate('10.0.0.1-10.0.0.254'); + assert.equal(r.analysis.cidrs.length, 14); + assert.equal(r.analysis.count.exact, '254'); + assert.equal(r.analysis.aggregated, null); + assert.equal(calculate('10.0.0.0-10.0.0.255').analysis.aggregated, '10.0.0.0/24'); + assert.equal(analyzeRange('x', 'y'), null); + }); + it('cidr list', () => { + const r = calculate('192.168.0.0/24 192.168.1.0/24 2001:db8::1 foo'); + assert.deepEqual(r.analysis.v4.input, ['192.168.0.0/24', '192.168.1.0/24']); + assert.deepEqual(r.analysis.v4.aggregated, ['192.168.0.0/23']); + assert.equal(r.analysis.v4.count.exact, '512'); + assert.deepEqual(r.analysis.v6.input, ['2001:db8::1/128']); + assert.deepEqual(r.analysis.v6.aggregated, ['2001:db8::1/128']); + assert.deepEqual(r.analysis.invalid, ['foo']); + assert.equal(analyzeList('nope'), null); + }); + it('never throws on junk', () => { + const junk = ['/', '::/', '0x', '-', ' - ', '1.2.3.4-', '%', '[]', '[', '', '..', '...', ':', '::::', + '1.2.3.4/', '/32', '1-2-3', '10.0.0.0/8 -', 'a'.repeat(10000), '9'.repeat(200), '1.2.3.4%eth0', + 'fe80::1%', '%eth0', '0x0x1', '0177.0177.0177.0177.0177', '256', '1..2', '.1.2.3', '1.2.3.', + ',', ';;', '00:00:00:00:00:00:00', '::ffff:256.1.1.1', null, undefined, 42, {}, [], true]; + for (const input of junk) { + let r; + assert.doesNotThrow(() => { r = calculate(input); }, `threw on ${JSON.stringify(input)}`); + assert.ok(r && typeof r.kind === 'string', `no kind for ${JSON.stringify(input)}`); + } + assert.equal(calculate('256').kind, 'integer'); + assert.equal(calculate('9'.repeat(200)).kind, 'invalid'); + }); + it('lookupBlocks is exported and sorted', () => { + assert.deepEqual(lookupBlocks(4, v('192.0.0.9')).map((b) => b.id), ['pcp-anycast', 'ietf-protocol']); + assert.deepEqual(lookupBlocks(4, v('192.0.0.1')).map((b) => b.id), ['ds-lite', 'ietf-protocol']); + assert.deepEqual(lookupBlocks(6, v('ff02::1:ff00:1')).map((b) => b.id), ['solicited-node', 'multicast-v6']); + }); +}); From e30fcb17e55dca77bd91c7186e3f6c442c980fe0 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:18:58 +0800 Subject: [PATCH 03/36] Feat(ipcalculator): add the IP Calculator advanced tool One smart input with example pills and the visitor's own IPs, result cards per kind (IPv4, IPv6, range, prefix list) built on a prefix slider + bitmap, value rows with copy buttons, and a subnet splitter. The query mirrors to ?q= so results are shareable. The splitter only follows the slider on release and value rows have a fixed min-height, so nothing below the thumb resizes mid-drag. Registered in tools.js with shortcut `a`; six locales. Co-Authored-By: Claude Fable 5.1 --- .../advanced-tools/IpCalculator.vue | 220 ++++++++++++++++++ .../ip-calculator/CalcSection.vue | 22 ++ .../ip-calculator/Ipv4Result.vue | 98 ++++++++ .../ip-calculator/Ipv6Result.vue | 130 +++++++++++ .../ip-calculator/PrefixBitmap.vue | 88 +++++++ .../ip-calculator/RangeResult.vue | 78 +++++++ .../ip-calculator/SubnetSplitter.vue | 59 +++++ .../advanced-tools/ip-calculator/ValueRow.vue | 40 ++++ frontend/composables/use-shortcuts.js | 1 + frontend/data/tools.js | 1 + frontend/locales/en.json | 131 +++++++++++ frontend/locales/fr.json | 131 +++++++++++ frontend/locales/pt-BR.json | 131 +++++++++++ frontend/locales/ru.json | 131 +++++++++++ frontend/locales/zh-TW.json | 131 +++++++++++ frontend/locales/zh.json | 131 +++++++++++ 16 files changed, 1523 insertions(+) create mode 100644 frontend/components/advanced-tools/IpCalculator.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/CalcSection.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/Ipv4Result.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/Ipv6Result.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/PrefixBitmap.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/RangeResult.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/SubnetSplitter.vue create mode 100644 frontend/components/advanced-tools/ip-calculator/ValueRow.vue diff --git a/frontend/components/advanced-tools/IpCalculator.vue b/frontend/components/advanced-tools/IpCalculator.vue new file mode 100644 index 000000000..499b6751e --- /dev/null +++ b/frontend/components/advanced-tools/IpCalculator.vue @@ -0,0 +1,220 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/CalcSection.vue b/frontend/components/advanced-tools/ip-calculator/CalcSection.vue new file mode 100644 index 000000000..afb5a987f --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/CalcSection.vue @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/Ipv4Result.vue b/frontend/components/advanced-tools/ip-calculator/Ipv4Result.vue new file mode 100644 index 000000000..43229ddd2 --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/Ipv4Result.vue @@ -0,0 +1,98 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/Ipv6Result.vue b/frontend/components/advanced-tools/ip-calculator/Ipv6Result.vue new file mode 100644 index 000000000..1acdeb680 --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/Ipv6Result.vue @@ -0,0 +1,130 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/PrefixBitmap.vue b/frontend/components/advanced-tools/ip-calculator/PrefixBitmap.vue new file mode 100644 index 000000000..051da0b18 --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/PrefixBitmap.vue @@ -0,0 +1,88 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/RangeResult.vue b/frontend/components/advanced-tools/ip-calculator/RangeResult.vue new file mode 100644 index 000000000..eb785a25e --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/RangeResult.vue @@ -0,0 +1,78 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/SubnetSplitter.vue b/frontend/components/advanced-tools/ip-calculator/SubnetSplitter.vue new file mode 100644 index 000000000..13860da5d --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/SubnetSplitter.vue @@ -0,0 +1,59 @@ + + + + diff --git a/frontend/components/advanced-tools/ip-calculator/ValueRow.vue b/frontend/components/advanced-tools/ip-calculator/ValueRow.vue new file mode 100644 index 000000000..1810cd921 --- /dev/null +++ b/frontend/components/advanced-tools/ip-calculator/ValueRow.vue @@ -0,0 +1,40 @@ + + + + diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index 1be3dd32f..2b692227a 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -164,6 +164,7 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => { }, { keys: 'l', action: () => goToAdvancedTool('pingtest', 'PingTest'), description: t('shortcutKeys.PingTest') }, { keys: 'M', action: () => goToAdvancedTool('macchecker', 'MacChecker'), description: t('shortcutKeys.MacChecker') }, + { keys: 'a', action: () => goToAdvancedTool('ipcalculator', 'IpCalculator'), description: t('shortcutKeys.IpCalculator') }, { keys: 't', action: () => goToAdvancedTool('mtrtest', 'MTRTest'), description: t('shortcutKeys.MTRTest') }, { keys: 'S', action: () => goToAdvancedTool('securitychecklist', 'SecurityChecklist'), description: t('shortcutKeys.SecurityChecklist') }, { keys: 'r', action: () => goToAdvancedTool('ruletest', 'RuleTest'), description: t('shortcutKeys.RuleTest') }, diff --git a/frontend/data/tools.js b/frontend/data/tools.js index 6b4bf928e..b100a2bb6 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -27,6 +27,7 @@ export const ADVANCED_TOOLS = [ { slug: 'censorshipcheck', emoji: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', component: () => import('@/components/advanced-tools/CensorshipCheck.vue') }, { slug: 'whois', emoji: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', component: () => import('@/components/advanced-tools/Whois.vue') }, { slug: 'macchecker', emoji: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', component: () => import('@/components/advanced-tools/MacChecker.vue') }, + { slug: 'ipcalculator', emoji: '🧮', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', component: () => import('@/components/advanced-tools/IpCalculator.vue') }, { slug: 'browserinfo', emoji: '🖥️', titleKey: 'browserinfo.Title', noteKey: 'advancedtools.BrowserInfo', component: () => import('@/components/advanced-tools/BrowserInfo.vue') }, { slug: 'securitychecklist', emoji: '📋', titleKey: 'securitychecklist.Title', noteKey: 'advancedtools.SecurityChecklist', component: () => import('@/components/advanced-tools/SecurityChecklist.vue') }, { slug: 'servicestatus', emoji: '📡', titleKey: 'serviceStatus.Title', noteKey: 'advancedtools.ServiceStatus', component: () => import('@/components/advanced-tools/ServiceStatus.vue') }, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0f3437bfd..da0531d30 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -871,6 +871,7 @@ "Whois": "Search for domain/IP registration information", "InvisibilityTest": "Check if you are using a proxy or VPN", "MacChecker": "Query information of a physical address", + "IpCalculator": "Subnet math, notation conversion and IPv6 insight for any IP, prefix or range", "BrowserInfo": "Check browser information and fingerprint", "SecurityChecklist": "Guide to securing your digital life", "PersonaCheck": "Compare what sites see against the country you want to look local to" @@ -901,6 +902,135 @@ "value": "Value", "manufacturer": "Manufacturer details" }, + "ipcalculator": { + "Title": "IP Calculator", + "Note": "Paste an IPv4 or IPv6 address, a CIDR prefix, an address range, a list of prefixes, a plain number, or one of the odd spellings that browsers still accept — the calculator recognises the input on its own and shows only what applies. Reach for it when you are carving up a subnet (network, broadcast, usable hosts, netmask, wildcard, splitting into smaller blocks), when you want to understand an IPv6 address (its type, an embedded IPv4, whether the interface identifier gives away a MAC), when a firewall or ACL needs a range expressed as the fewest CIDR blocks or a long list of prefixes collapsed, or when a bare number in a log or a suspicious URL has to be turned back into a normal address. Everything is computed in your browser; nothing is sent anywhere.", + "Note2": "Enter an IP, prefix, range, list or number to calculate:", + "Placeholder": "2001:db8::1/64", + "Calculate": "Calculate", + "Examples": "Try:", + "exampleObfuscated": "Obfuscated", + "exampleEui64": "EUI-64", + "MyIPs": "Your IPs:", + "InterpretedAs": "Interpreted as", + "copy": "Copy", + "copyAll": "Copy all", + "yes": "Yes", + "no": "No", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "IPv4 prefix", + "ipv6": "IPv6", + "ipv6Cidr": "IPv6 prefix", + "integer": "Integer", + "hex": "Hexadecimal", + "range": "Range", + "cidrList": "Prefix list" + }, + "scope": { + "global": "Globally routable", + "private": "Private", + "shared": "Shared (CGNAT)", + "loopback": "Loopback", + "linkLocal": "Link-local", + "multicast": "Multicast", + "documentation": "Documentation", + "reserved": "Reserved", + "unspecified": "Unspecified", + "broadcast": "Broadcast" + }, + "invalid": { + "empty": "Enter something to calculate", + "listNoValid": "None of the items is a valid IP or prefix", + "cidrPrefix": "The prefix length after / is out of range for this family", + "cidrAddress": "The part before / is not a valid IP", + "ipv6Syntax": "Not a valid IPv6 address", + "hexTooLarge": "Hex value is longer than 128 bits", + "integerTooLarge": "Integer is larger than 128 bits", + "badOctal": "A number starting with 0 is read as octal, and 8 and 9 are not octal digits", + "range": "Both ends of a range must be valid IPs of the same family", + "unrecognized": "Unrecognized input" + }, + "section": { + "Network": "Network", + "Representations": "Representations", + "Obfuscated": "Obfuscated notations", + "AsIPv6": "As IPv6", + "Split": "Split into subnets", + "Address": "Address", + "Type": "Type details", + "InterfaceId": "Interface identifier", + "Prefix": "Prefix", + "Range": "Range", + "Cidrs": "CIDR blocks", + "IPv4Blocks": "IPv4 blocks", + "IPv6Blocks": "IPv6 blocks" + }, + "network": "Network", + "broadcast": "Broadcast", + "netmask": "Netmask", + "wildcard": "Wildcard mask", + "firstHost": "First host", + "lastHost": "Last host", + "firstAddress": "First", + "lastAddress": "Last", + "usable": "Usable hosts", + "total": "Total", + "ptr": "PTR record", + "ptrZone": "Reverse zone", + "class": "Class", + "rfc3021Note": "A /31 (RFC 3021) or /32 has no separate network or broadcast address; every address is usable.", + "networkBits": "Network bits", + "hostBits": "Host bits", + "prefixLength": "Prefix length", + "decimal": "Decimal", + "hex": "Hexadecimal", + "octal": "Octal", + "binary": "Binary", + "obfuscatedNote": "Browsers and most libraries also accept these spellings — the reason naive IP filters can be bypassed.", + "dottedHex": "Dotted hex", + "dottedOctal": "Dotted octal", + "shortForm": "Short form ({parts} parts)", + "mapped": "IPv4-mapped", + "mappedHex": "IPv4-mapped (hex)", + "compat": "IPv4-compatible", + "nat64": "NAT64", + "sixToFour": "6to4 prefix", + "splitTo": "Split to", + "showingFirst": "Showing the first {shown} of {total} subnets", + "subnetCount": "{count} subnets", + "compressed": "Compressed", + "expanded": "Expanded", + "zone": "Zone", + "embeddedIPv4": "Embedded IPv4", + "teredoServer": "Teredo server", + "teredoClient": "Teredo client", + "teredoPort": "Teredo port", + "teredoCone": "Cone NAT", + "locallyAssigned": "Locally assigned (fd00::/8)", + "globalId": "Global ID", + "subnetId": "Subnet ID", + "mcastScope": "Multicast scope", + "flags": "Flags", + "flagT": "T (transient)", + "flagP": "P (prefix-based)", + "flagR": "R (embedded RP)", + "noFlags": "None", + "solicitedNodeSuffix": "Solicited-node suffix", + "iid": "Interface ID", + "mac": "MAC from EUI-64", + "solicitedNode": "Solicited-node multicast", + "universalMac": "The interface ID is a modified EUI-64 derived from a globally unique MAC, so it identifies the hardware wherever the device goes.", + "localMac": "The interface ID is a modified EUI-64 built from a locally administered MAC.", + "privacyIid": "The interface ID is not EUI-64 shaped: a privacy extension, a randomised or a manually chosen identifier.", + "subnetRouterAnycast": "All-zero interface ID: the subnet-router anycast address of this prefix.", + "slash64s": "/64 subnets", + "start": "Start", + "end": "End", + "aggregated": "Single block", + "aggregationSummary": "{input} blocks in, {output} out, {addresses} addresses", + "invalidTokens": "Ignored: {list}" + }, "whois": { "Title": "Whois Search", "Note": "Whois search is a service used to retrieve domain registration information. By entering a domain name or IP address, you can retrieve information such as the domain's registration details, registrar, registration date, expiration date, and more. The Whois information for IP addresses and domain names may have different fields. Additionally, some less common top-level domains (TLDs) may not have available information for Whois queries.", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "Open Invisibility Test panel", "EnhancedDnsLeakTest": "Open In-depth DNS Leak Test panel", "MacChecker": "Open MAC lookup panel", + "IpCalculator": "Open IP Calculator panel", "BrowserInfo": "Open Browser Info panel", "SecurityChecklist": "Open Security Checklist panel", "PersonaCheck": "Open In-depth Persona Check panel" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 7a19d6062..30a702d6d 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -871,6 +871,7 @@ "Whois": "Recherche d'informations sur l'enregistrement de domaine/IP", "InvisibilityTest": "Vérifiez si vous utilisez un proxy ou un VPN", "MacChecker": "Requête d'informations d'une adresse physique", + "IpCalculator": "Calcul de sous-réseau, conversion de notation et analyse IPv6 pour toute IP, préfixe ou plage", "BrowserInfo": "Vérifier les informations du navigateur et l'empreinte digitale", "SecurityChecklist": "Guide pour sécuriser votre vie numérique", "PersonaCheck": "Comparez ce que voient les sites avec le pays dont vous voulez avoir l'air d'être un habitant" @@ -901,6 +902,135 @@ "value": "Valeur", "manufacturer": "Détails du fabricant" }, + "ipcalculator": { + "Title": "Calculateur IP", + "Note": "Collez une adresse IPv4 ou IPv6, un préfixe CIDR, une plage d'adresses, une liste de préfixes, un simple nombre ou l'une de ces écritures étranges que les navigateurs acceptent encore : le calculateur reconnaît l'entrée par lui-même et n'affiche que ce qui s'applique. Utilisez-le pour découper un sous-réseau (réseau, diffusion, hôtes utilisables, masque, masque inversé, division en blocs plus petits), pour comprendre une adresse IPv6 (son type, une IPv4 intégrée, si l'identifiant d'interface trahit une MAC), quand un pare-feu ou une ACL a besoin d'une plage exprimée avec le moins de blocs CIDR possible ou d'une longue liste de préfixes agrégée, ou quand un nombre brut trouvé dans un journal ou une URL suspecte doit redevenir une adresse normale. Tout est calculé dans votre navigateur ; rien n'est envoyé nulle part.", + "Note2": "Saisissez une IP, un préfixe, une plage, une liste ou un nombre à calculer :", + "Placeholder": "2001:db8::1/64", + "Calculate": "Calculer", + "Examples": "Essayez :", + "exampleObfuscated": "Obscurcie", + "exampleEui64": "EUI-64", + "MyIPs": "Vos IP :", + "InterpretedAs": "Interprété comme", + "copy": "Copier", + "copyAll": "Tout copier", + "yes": "Oui", + "no": "Non", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "Préfixe IPv4", + "ipv6": "IPv6", + "ipv6Cidr": "Préfixe IPv6", + "integer": "Entier", + "hex": "Hexadécimal", + "range": "Plage", + "cidrList": "Liste de préfixes" + }, + "scope": { + "global": "Routable globalement", + "private": "Privée", + "shared": "Partagée (CGNAT)", + "loopback": "Bouclage", + "linkLocal": "Lien local", + "multicast": "Multidiffusion", + "documentation": "Documentation", + "reserved": "Réservée", + "unspecified": "Non spécifiée", + "broadcast": "Diffusion" + }, + "invalid": { + "empty": "Saisissez quelque chose à calculer", + "listNoValid": "Aucun des éléments n'est une IP ou un préfixe valide", + "cidrPrefix": "La longueur de préfixe après / est hors limites pour cette famille", + "cidrAddress": "La partie avant / n'est pas une IP valide", + "ipv6Syntax": "Adresse IPv6 invalide", + "hexTooLarge": "La valeur hexadécimale dépasse 128 bits", + "integerTooLarge": "L'entier dépasse 128 bits", + "badOctal": "Un nombre commençant par 0 est lu en octal, et 8 et 9 ne sont pas des chiffres octaux", + "range": "Les deux extrémités d'une plage doivent être des IP valides de la même famille", + "unrecognized": "Entrée non reconnue" + }, + "section": { + "Network": "Réseau", + "Representations": "Représentations", + "Obfuscated": "Notations obscurcies", + "AsIPv6": "En IPv6", + "Split": "Découper en sous-réseaux", + "Address": "Adresse", + "Type": "Détails du type", + "InterfaceId": "Identifiant d'interface", + "Prefix": "Préfixe", + "Range": "Plage", + "Cidrs": "Blocs CIDR", + "IPv4Blocks": "Blocs IPv4", + "IPv6Blocks": "Blocs IPv6" + }, + "network": "Réseau", + "broadcast": "Diffusion", + "netmask": "Masque", + "wildcard": "Masque inversé", + "firstHost": "Premier hôte", + "lastHost": "Dernier hôte", + "firstAddress": "Première", + "lastAddress": "Dernière", + "usable": "Hôtes utilisables", + "total": "Total", + "ptr": "Enregistrement PTR", + "ptrZone": "Zone inverse", + "class": "Classe", + "rfc3021Note": "Un /31 (RFC 3021) ou un /32 n'a pas d'adresse réseau ni de diffusion distincte ; chaque adresse est utilisable.", + "networkBits": "Bits réseau", + "hostBits": "Bits hôte", + "prefixLength": "Longueur de préfixe", + "decimal": "Décimal", + "hex": "Hexadécimal", + "octal": "Octal", + "binary": "Binaire", + "obfuscatedNote": "Les navigateurs et la plupart des bibliothèques acceptent aussi ces écritures — c'est ce qui permet de contourner les filtres d'IP naïfs.", + "dottedHex": "Hexadécimal pointé", + "dottedOctal": "Octal pointé", + "shortForm": "Forme courte ({parts} parties)", + "mapped": "IPv4 mappée", + "mappedHex": "IPv4 mappée (hex)", + "compat": "IPv4 compatible", + "nat64": "NAT64", + "sixToFour": "Préfixe 6to4", + "splitTo": "Découper en", + "showingFirst": "Affichage des {shown} premiers sous-réseaux sur {total}", + "subnetCount": "{count} sous-réseaux", + "compressed": "Compressée", + "expanded": "Développée", + "zone": "Zone", + "embeddedIPv4": "IPv4 intégrée", + "teredoServer": "Serveur Teredo", + "teredoClient": "Client Teredo", + "teredoPort": "Port Teredo", + "teredoCone": "NAT cone", + "locallyAssigned": "Assigné localement (fd00::/8)", + "globalId": "ID global", + "subnetId": "ID de sous-réseau", + "mcastScope": "Portée multidiffusion", + "flags": "Indicateurs", + "flagT": "T (transitoire)", + "flagP": "P (basé sur préfixe)", + "flagR": "R (RP intégré)", + "noFlags": "Aucun", + "solicitedNodeSuffix": "Suffixe nœud sollicité", + "iid": "ID d'interface", + "mac": "MAC issue de l'EUI-64", + "solicitedNode": "Multidiffusion nœud sollicité", + "universalMac": "L'identifiant d'interface est un EUI-64 modifié dérivé d'une MAC unique au monde : il identifie le matériel où que l'appareil aille.", + "localMac": "L'identifiant d'interface est un EUI-64 modifié construit à partir d'une MAC administrée localement.", + "privacyIid": "L'identifiant d'interface n'a pas la forme EUI-64 : extension de confidentialité, identifiant aléatoire ou choisi manuellement.", + "subnetRouterAnycast": "Identifiant d'interface tout à zéro : adresse anycast du routeur de ce sous-réseau.", + "slash64s": "Sous-réseaux /64", + "start": "Début", + "end": "Fin", + "aggregated": "Bloc unique", + "aggregationSummary": "{input} blocs en entrée, {output} en sortie, {addresses} adresses", + "invalidTokens": "Ignoré : {list}" + }, "whois": { "Title": "Recherche Whois", "Note": "La recherche Whois est un service utilisé pour récupérer des informations sur l'enregistrement d'un domaine. En entrant un nom de domaine ou une adresse IP, vous pouvez obtenir des informations telles que les détails d'enregistrement du domaine, le registraire, la date d'enregistrement, la date d'expiration, et plus encore. Les informations Whois pour les adresses IP et les noms de domaine peuvent avoir des champs différents. De plus, certains domaines de premier niveau (TLD) moins courants peuvent ne pas avoir d'informations disponibles pour les requêtes Whois.", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "Ouvrir le Test d'invisibilité", "EnhancedDnsLeakTest": "Ouvrir le Test de fuite DNS approfondi", "MacChecker": "Ouvrir le Recherche de MAC", + "IpCalculator": "Ouvrir le Calculateur IP", "BrowserInfo": "Ouvrir l'Info du navigateur", "SecurityChecklist": "Ouvrir la Liste de sécurité", "PersonaCheck": "Ouvrir le panneau Vérification de persona approfondie" diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 94b8740e9..0a60adcc2 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -871,6 +871,7 @@ "Whois": "Pesquise informações de registro de domínio/IP", "InvisibilityTest": "Verifique se você está usando proxy ou VPN", "MacChecker": "Consultar informações de um endereço físico", + "IpCalculator": "Cálculo de sub-rede, conversão de notação e análise IPv6 para qualquer IP, prefixo ou intervalo", "BrowserInfo": "Verificar informações e impressão digital do navegador", "SecurityChecklist": "Guia para proteger sua vida digital", "PersonaCheck": "Compare o que os sites veem com o país no qual você quer parecer local" @@ -901,6 +902,135 @@ "value": "Valor", "manufacturer": "Detalhes do fabricante" }, + "ipcalculator": { + "Title": "Calculadora de IP", + "Note": "Cole um endereço IPv4 ou IPv6, um prefixo CIDR, um intervalo de endereços, uma lista de prefixos, um número simples ou uma daquelas grafias estranhas que os navegadores ainda aceitam: a calculadora reconhece a entrada sozinha e mostra só o que se aplica. Use-a quando estiver dividindo uma sub-rede (rede, broadcast, hosts utilizáveis, máscara, máscara curinga, divisão em blocos menores), quando quiser entender um endereço IPv6 (o tipo, um IPv4 embutido, se o identificador de interface entrega um MAC), quando um firewall ou ACL precisar de um intervalo expresso no menor número de blocos CIDR ou de uma lista longa de prefixos agregada, ou quando um número solto em um log ou em uma URL suspeita precisar voltar a ser um endereço normal. Tudo é calculado no seu navegador; nada é enviado a lugar nenhum.", + "Note2": "Digite um IP, prefixo, intervalo, lista ou número para calcular:", + "Placeholder": "2001:db8::1/64", + "Calculate": "Calcular", + "Examples": "Experimente:", + "exampleObfuscated": "Ofuscado", + "exampleEui64": "EUI-64", + "MyIPs": "Seus IPs:", + "InterpretedAs": "Interpretado como", + "copy": "Copiar", + "copyAll": "Copiar tudo", + "yes": "Sim", + "no": "Não", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "Prefixo IPv4", + "ipv6": "IPv6", + "ipv6Cidr": "Prefixo IPv6", + "integer": "Inteiro", + "hex": "Hexadecimal", + "range": "Intervalo", + "cidrList": "Lista de prefixos" + }, + "scope": { + "global": "Roteável globalmente", + "private": "Privado", + "shared": "Compartilhado (CGNAT)", + "loopback": "Loopback", + "linkLocal": "Link-local", + "multicast": "Multicast", + "documentation": "Documentação", + "reserved": "Reservado", + "unspecified": "Não especificado", + "broadcast": "Broadcast" + }, + "invalid": { + "empty": "Digite algo para calcular", + "listNoValid": "Nenhum dos itens é um IP ou prefixo válido", + "cidrPrefix": "O comprimento do prefixo após / está fora do intervalo desta família", + "cidrAddress": "A parte antes de / não é um IP válido", + "ipv6Syntax": "Endereço IPv6 inválido", + "hexTooLarge": "O valor hexadecimal tem mais de 128 bits", + "integerTooLarge": "O inteiro é maior que 128 bits", + "badOctal": "Um número que começa com 0 é lido como octal, e 8 e 9 não são dígitos octais", + "range": "As duas pontas de um intervalo devem ser IPs válidos da mesma família", + "unrecognized": "Entrada não reconhecida" + }, + "section": { + "Network": "Rede", + "Representations": "Representações", + "Obfuscated": "Notações ofuscadas", + "AsIPv6": "Como IPv6", + "Split": "Dividir em sub-redes", + "Address": "Endereço", + "Type": "Detalhes do tipo", + "InterfaceId": "Identificador de interface", + "Prefix": "Prefixo", + "Range": "Intervalo", + "Cidrs": "Blocos CIDR", + "IPv4Blocks": "Blocos IPv4", + "IPv6Blocks": "Blocos IPv6" + }, + "network": "Rede", + "broadcast": "Broadcast", + "netmask": "Máscara", + "wildcard": "Máscara curinga", + "firstHost": "Primeiro host", + "lastHost": "Último host", + "firstAddress": "Primeiro", + "lastAddress": "Último", + "usable": "Hosts utilizáveis", + "total": "Total", + "ptr": "Registro PTR", + "ptrZone": "Zona reversa", + "class": "Classe", + "rfc3021Note": "Um /31 (RFC 3021) ou /32 não tem endereço de rede nem de broadcast separado; todo endereço é utilizável.", + "networkBits": "Bits de rede", + "hostBits": "Bits de host", + "prefixLength": "Comprimento do prefixo", + "decimal": "Decimal", + "hex": "Hexadecimal", + "octal": "Octal", + "binary": "Binário", + "obfuscatedNote": "Navegadores e a maioria das bibliotecas também aceitam essas grafias — é por isso que filtros de IP ingênuos podem ser burlados.", + "dottedHex": "Hexadecimal pontuado", + "dottedOctal": "Octal pontuado", + "shortForm": "Forma curta ({parts} partes)", + "mapped": "IPv4-mapped", + "mappedHex": "IPv4-mapped (hex)", + "compat": "IPv4-compatible", + "nat64": "NAT64", + "sixToFour": "Prefixo 6to4", + "splitTo": "Dividir em", + "showingFirst": "Mostrando as primeiras {shown} de {total} sub-redes", + "subnetCount": "{count} sub-redes", + "compressed": "Comprimido", + "expanded": "Expandido", + "zone": "Zona", + "embeddedIPv4": "IPv4 embutido", + "teredoServer": "Servidor Teredo", + "teredoClient": "Cliente Teredo", + "teredoPort": "Porta Teredo", + "teredoCone": "Cone NAT", + "locallyAssigned": "Atribuído localmente (fd00::/8)", + "globalId": "ID global", + "subnetId": "ID da sub-rede", + "mcastScope": "Escopo multicast", + "flags": "Flags", + "flagT": "T (transitório)", + "flagP": "P (baseado em prefixo)", + "flagR": "R (RP embutido)", + "noFlags": "Nenhum", + "solicitedNodeSuffix": "Sufixo solicited-node", + "iid": "ID de interface", + "mac": "MAC do EUI-64", + "solicitedNode": "Multicast solicited-node", + "universalMac": "O identificador de interface é um EUI-64 modificado derivado de um MAC globalmente único, então identifica o hardware aonde quer que o dispositivo vá.", + "localMac": "O identificador de interface é um EUI-64 modificado construído a partir de um MAC administrado localmente.", + "privacyIid": "O identificador de interface não tem forma de EUI-64: extensão de privacidade, identificador aleatório ou escolhido manualmente.", + "subnetRouterAnycast": "Identificador de interface todo zero: o endereço anycast do roteador desta sub-rede.", + "slash64s": "Sub-redes /64", + "start": "Início", + "end": "Fim", + "aggregated": "Bloco único", + "aggregationSummary": "{input} blocos de entrada, {output} de saída, {addresses} endereços", + "invalidTokens": "Ignorado: {list}" + }, "whois": { "Title": "Pesquisa Whois", "Note": "A pesquisa Whois é um serviço usado para recuperar informações de registro de domínios. Ao inserir um nome de domínio ou IP, você pode recuperar informações como detalhes de registro do domínio, registrador, data de registro, data de expiração e mais. As informações Whois para IPs e nomes de domínio podem ter campos diferentes. Além disso, alguns domínios de topo (TLDs) menos comuns podem não ter informações disponíveis para consultas Whois.", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "Abrir painel de teste de invisibilidade", "EnhancedDnsLeakTest": "Abrir painel de teste aprofundado de vazamento de DNS", "MacChecker": "Abrir painel de consulta MAC", + "IpCalculator": "Abrir painel da Calculadora de IP", "BrowserInfo": "Abrir painel de informações do navegador", "SecurityChecklist": "Abrir painel de checklist de segurança", "PersonaCheck": "Abrir painel de verificação aprofundada de persona" diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 7b0d74fd2..80ee545df 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -871,6 +871,7 @@ "Whois": "Поиск регистрационных сведений о домене или IP", "InvisibilityTest": "Проверка использования прокси или VPN", "MacChecker": "Поиск сведений о физическом адресе", + "IpCalculator": "Расчёт подсетей, преобразование записей и анализ IPv6 для любого IP, префикса или диапазона", "BrowserInfo": "Проверка сведений и отпечатка браузера", "SecurityChecklist": "Руководство по защите цифровой жизни", "PersonaCheck": "Сравните то, что видят сайты, со страной, местным жителем которой вы хотите выглядеть" @@ -901,6 +902,135 @@ "value": "Значение", "manufacturer": "Сведения о производителе" }, + "ipcalculator": { + "Title": "IP-калькулятор", + "Note": "Вставьте адрес IPv4 или IPv6, префикс CIDR, диапазон адресов, список префиксов, обычное число или одну из тех странных записей, которые браузеры до сих пор принимают, — калькулятор сам распознает ввод и покажет только то, что применимо. Он пригодится, когда вы нарезаете подсеть (сеть, широковещательный адрес, доступные хосты, маска, обратная маска, разбиение на блоки поменьше), когда нужно понять адрес IPv6 (его тип, встроенный IPv4, выдаёт ли идентификатор интерфейса MAC), когда межсетевому экрану или ACL нужен диапазон в виде минимального числа блоков CIDR или длинный список префиксов, свёрнутый в короткий, или когда голое число из журнала либо подозрительной ссылки нужно превратить обратно в обычный адрес. Всё вычисляется в вашем браузере, никуда ничего не отправляется.", + "Note2": "Введите IP, префикс, диапазон, список или число для расчёта:", + "Placeholder": "2001:db8::1/64", + "Calculate": "Рассчитать", + "Examples": "Попробуйте:", + "exampleObfuscated": "Обфусцированный", + "exampleEui64": "EUI-64", + "MyIPs": "Ваши IP:", + "InterpretedAs": "Интерпретировано как", + "copy": "Копировать", + "copyAll": "Копировать всё", + "yes": "Да", + "no": "Нет", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "Префикс IPv4", + "ipv6": "IPv6", + "ipv6Cidr": "Префикс IPv6", + "integer": "Целое число", + "hex": "Шестнадцатеричное", + "range": "Диапазон", + "cidrList": "Список префиксов" + }, + "scope": { + "global": "Глобально маршрутизируемый", + "private": "Частный", + "shared": "Общий (CGNAT)", + "loopback": "Петлевой", + "linkLocal": "Локальный для канала", + "multicast": "Многоадресный", + "documentation": "Для документации", + "reserved": "Зарезервирован", + "unspecified": "Неопределённый", + "broadcast": "Широковещательный" + }, + "invalid": { + "empty": "Введите что-нибудь для расчёта", + "listNoValid": "Ни один из элементов не является корректным IP или префиксом", + "cidrPrefix": "Длина префикса после / выходит за пределы для этого семейства", + "cidrAddress": "Часть до / не является корректным IP", + "ipv6Syntax": "Некорректный адрес IPv6", + "hexTooLarge": "Шестнадцатеричное значение длиннее 128 бит", + "integerTooLarge": "Число больше 128 бит", + "badOctal": "Число, начинающееся с 0, читается как восьмеричное, а 8 и 9 — не восьмеричные цифры", + "range": "Оба конца диапазона должны быть корректными IP одного семейства", + "unrecognized": "Нераспознанный ввод" + }, + "section": { + "Network": "Сеть", + "Representations": "Представления", + "Obfuscated": "Обфусцированные записи", + "AsIPv6": "В виде IPv6", + "Split": "Разбить на подсети", + "Address": "Адрес", + "Type": "Сведения о типе", + "InterfaceId": "Идентификатор интерфейса", + "Prefix": "Префикс", + "Range": "Диапазон", + "Cidrs": "Блоки CIDR", + "IPv4Blocks": "Блоки IPv4", + "IPv6Blocks": "Блоки IPv6" + }, + "network": "Сеть", + "broadcast": "Широковещательный", + "netmask": "Маска сети", + "wildcard": "Обратная маска", + "firstHost": "Первый хост", + "lastHost": "Последний хост", + "firstAddress": "Первый", + "lastAddress": "Последний", + "usable": "Доступных хостов", + "total": "Всего", + "ptr": "Запись PTR", + "ptrZone": "Обратная зона", + "class": "Класс", + "rfc3021Note": "У /31 (RFC 3021) и /32 нет отдельных адресов сети и широковещания; каждый адрес можно использовать.", + "networkBits": "Биты сети", + "hostBits": "Биты хоста", + "prefixLength": "Длина префикса", + "decimal": "Десятичное", + "hex": "Шестнадцатеричное", + "octal": "Восьмеричное", + "binary": "Двоичное", + "obfuscatedNote": "Браузеры и большинство библиотек принимают и такие записи — поэтому простые фильтры IP можно обойти.", + "dottedHex": "Точечно-шестнадцатеричная", + "dottedOctal": "Точечно-восьмеричная", + "shortForm": "Краткая форма ({parts} части)", + "mapped": "IPv4-mapped", + "mappedHex": "IPv4-mapped (hex)", + "compat": "IPv4-compatible", + "nat64": "NAT64", + "sixToFour": "Префикс 6to4", + "splitTo": "Разбить на", + "showingFirst": "Показаны первые {shown} из {total} подсетей", + "subnetCount": "Подсетей: {count}", + "compressed": "Сжатый", + "expanded": "Развёрнутый", + "zone": "Зона", + "embeddedIPv4": "Встроенный IPv4", + "teredoServer": "Сервер Teredo", + "teredoClient": "Клиент Teredo", + "teredoPort": "Порт Teredo", + "teredoCone": "Cone NAT", + "locallyAssigned": "Назначен локально (fd00::/8)", + "globalId": "Глобальный ID", + "subnetId": "ID подсети", + "mcastScope": "Область многоадресной рассылки", + "flags": "Флаги", + "flagT": "T (временный)", + "flagP": "P (на основе префикса)", + "flagR": "R (встроенный RP)", + "noFlags": "Нет", + "solicitedNodeSuffix": "Суффикс solicited-node", + "iid": "ID интерфейса", + "mac": "MAC из EUI-64", + "solicitedNode": "Многоадресный solicited-node", + "universalMac": "Идентификатор интерфейса — модифицированный EUI-64 из глобально уникального MAC, поэтому он выдаёт оборудование, куда бы устройство ни переместилось.", + "localMac": "Идентификатор интерфейса — модифицированный EUI-64 из локально администрируемого MAC.", + "privacyIid": "Идентификатор интерфейса не имеет формы EUI-64: расширение приватности, случайный или заданный вручную идентификатор.", + "subnetRouterAnycast": "Идентификатор интерфейса из нулей: anycast-адрес маршрутизатора этой подсети.", + "slash64s": "Подсетей /64", + "start": "Начало", + "end": "Конец", + "aggregated": "Единый блок", + "aggregationSummary": "На входе блоков: {input}, на выходе: {output}, адресов: {addresses}", + "invalidTokens": "Пропущено: {list}" + }, "whois": { "Title": "Поиск Whois", "Note": "Поиск Whois позволяет получить регистрационные сведения о домене. Введите доменное имя или IP-адрес, чтобы узнать регистрационные данные домена, регистратора, даты регистрации и окончания срока действия и другие сведения. Набор полей Whois для IP-адресов и доменных имён может различаться. Кроме того, для некоторых редких доменов верхнего уровня (TLD) данные Whois могут отсутствовать.", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "Открыть панель теста незаметности", "EnhancedDnsLeakTest": "Открыть панель углублённого теста утечки DNS", "MacChecker": "Открыть панель поиска MAC-адреса", + "IpCalculator": "Открыть панель IP-калькулятора", "BrowserInfo": "Открыть панель сведений о браузере", "SecurityChecklist": "Открыть панель контрольного списка безопасности", "PersonaCheck": "Открыть панель углублённой проверки портрета" diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 0c5dadd4b..f101e36ee 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -871,6 +871,7 @@ "Whois": "對網域名稱或 IP 進行 Whois 查詢", "InvisibilityTest": "猜猜看我是否知道你掛了代理", "MacChecker": "查詢實體位址的歸屬資訊", + "IpCalculator": "任意 IP、前綴或範圍的子網路計算、進位轉換與 IPv6 解讀", "BrowserInfo": "檢閱瀏覽器資訊和指紋", "SecurityChecklist": "全面的數位生活安全檢查清單", "PersonaCheck": "把網站看到的你,和你想扮成的本地人做對照" @@ -901,6 +902,135 @@ "value": "值", "manufacturer": "製造商資訊" }, + "ipcalculator": { + "Title": "IP 計算器", + "Note": "貼上一個 IPv4 或 IPv6 位址、CIDR 前綴、位址範圍、前綴清單、純數字,或者瀏覽器仍然認得的那些奇怪寫法,計算器會自動辨識輸入類型,只顯示適用的結果。適合這些情境:劃分子網路時要知道網路位址、廣播位址、可用主機數、遮罩、萬用字元遮罩,或者把一個大區段拆成小區塊;拿到一個 IPv6 位址想看懂它的類型、裡面嵌的 IPv4、介面識別碼是否洩漏了 MAC;寫防火牆或 ACL 時要把一段範圍換成最少的 CIDR,或者把一長串前綴合併;記錄檔或可疑連結裡出現一串數字,想還原成正常的位址。全部在瀏覽器本機計算,不會傳送到任何地方。", + "Note2": "輸入 IP、前綴、範圍、清單或數字開始計算:", + "Placeholder": "2001:db8::1/64", + "Calculate": "計算", + "Examples": "試試:", + "exampleObfuscated": "混淆寫法", + "exampleEui64": "EUI-64", + "MyIPs": "你的 IP:", + "InterpretedAs": "解讀為", + "copy": "複製", + "copyAll": "全部複製", + "yes": "是", + "no": "否", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "IPv4 前綴", + "ipv6": "IPv6", + "ipv6Cidr": "IPv6 前綴", + "integer": "整數", + "hex": "十六進位", + "range": "位址範圍", + "cidrList": "前綴清單" + }, + "scope": { + "global": "全球可路由", + "private": "私有", + "shared": "共享(CGNAT)", + "loopback": "迴路", + "linkLocal": "連結本機", + "multicast": "多播", + "documentation": "文件範例", + "reserved": "保留", + "unspecified": "未指定", + "broadcast": "廣播" + }, + "invalid": { + "empty": "請輸入要計算的內容", + "listNoValid": "清單裡沒有任何一項是合法的 IP 或前綴", + "cidrPrefix": "/ 後面的前綴長度超出了該位址族的範圍", + "cidrAddress": "/ 前面的部分不是合法的 IP", + "ipv6Syntax": "不是合法的 IPv6 位址", + "hexTooLarge": "十六進位值超過 128 位元", + "integerTooLarge": "整數超過 128 位元", + "badOctal": "以 0 開頭的數字會按八進位解析,而 8 和 9 不是八進位數字", + "range": "範圍兩端必須是同一位址族的合法 IP", + "unrecognized": "無法辨識的輸入" + }, + "section": { + "Network": "網路", + "Representations": "表示形式", + "Obfuscated": "混淆寫法", + "AsIPv6": "嵌入 IPv6", + "Split": "拆分子網路", + "Address": "位址", + "Type": "類型詳情", + "InterfaceId": "介面識別碼", + "Prefix": "前綴", + "Range": "範圍", + "Cidrs": "CIDR 區塊", + "IPv4Blocks": "IPv4 區塊", + "IPv6Blocks": "IPv6 區塊" + }, + "network": "網路位址", + "broadcast": "廣播位址", + "netmask": "子網路遮罩", + "wildcard": "萬用字元遮罩", + "firstHost": "第一個主機", + "lastHost": "最後一個主機", + "firstAddress": "第一個位址", + "lastAddress": "最後一個位址", + "usable": "可用主機數", + "total": "位址總數", + "ptr": "PTR 記錄", + "ptrZone": "反向解析區域", + "class": "類別", + "rfc3021Note": "/31(RFC 3021)和 /32 沒有獨立的網路位址與廣播位址,每個位址都可用。", + "networkBits": "網路位元", + "hostBits": "主機位元", + "prefixLength": "前綴長度", + "decimal": "十進位", + "hex": "十六進位", + "octal": "八進位", + "binary": "二進位", + "obfuscatedNote": "瀏覽器和多數函式庫同樣接受這些寫法,這也是簡單的 IP 過濾容易被繞過的原因。", + "dottedHex": "點分十六進位", + "dottedOctal": "點分八進位", + "shortForm": "縮寫({parts} 段)", + "mapped": "IPv4 對應", + "mappedHex": "IPv4 對應(十六進位)", + "compat": "IPv4 相容", + "nat64": "NAT64", + "sixToFour": "6to4 前綴", + "splitTo": "拆分為", + "showingFirst": "共 {total} 個子網路,僅顯示前 {shown} 個", + "subnetCount": "{count} 個子網路", + "compressed": "壓縮形式", + "expanded": "展開形式", + "zone": "區域 ID", + "embeddedIPv4": "嵌入的 IPv4", + "teredoServer": "Teredo 伺服器", + "teredoClient": "Teredo 用戶端", + "teredoPort": "Teredo 連接埠", + "teredoCone": "Cone NAT", + "locallyAssigned": "本機指派(fd00::/8)", + "globalId": "全域 ID", + "subnetId": "子網路 ID", + "mcastScope": "多播範圍", + "flags": "旗標", + "flagT": "T(暫時)", + "flagP": "P(基於前綴)", + "flagR": "R(內嵌 RP)", + "noFlags": "無", + "solicitedNodeSuffix": "被請求節點後綴", + "iid": "介面 ID", + "mac": "由 EUI-64 還原的 MAC", + "solicitedNode": "被請求節點多播位址", + "universalMac": "介面 ID 是由全球唯一 MAC 產生的修改版 EUI-64,裝置走到哪裡都能被認出來。", + "localMac": "介面 ID 是由本機管理的 MAC 產生的修改版 EUI-64。", + "privacyIid": "介面 ID 不是 EUI-64 形態:可能是隱私擴充、隨機產生或手動指定的識別碼。", + "subnetRouterAnycast": "介面 ID 全為零:這是該前綴的子網路路由器任播位址。", + "slash64s": "/64 子網路數", + "start": "起始", + "end": "結束", + "aggregated": "等價單一區塊", + "aggregationSummary": "輸入 {input} 個區塊,輸出 {output} 個,共 {addresses} 個位址", + "invalidTokens": "已忽略:{list}" + }, "whois": { "Title": "Whois 查詢", "Note": "Whois 查詢是一種用於查詢網域名稱註冊資訊的服務。透過輸入網域名稱或 IP 位址,可以查詢到網域名稱的註冊資訊、註冊商、註冊日期、過期日期等資訊。IP 與網域名稱的 Whois 資訊欄位不完全一致。同時,部分比較小眾的網域名稱(TLD)可能無法查詢到相關資訊。", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "開啟隱身測試面板", "EnhancedDnsLeakTest": "開啟深度 DNS 洩漏測試面板", "MacChecker": "開啟實體位址查詢面板", + "IpCalculator": "開啟 IP 計算器面板", "BrowserInfo": "開啟瀏覽器資訊面板", "SecurityChecklist": "開啟安全檢查清單", "PersonaCheck": "開啟深度畫像檢測面板" diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index f7c7e4a76..559226e2f 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -871,6 +871,7 @@ "Whois": "对域名或 IP 进行 Whois 查询", "InvisibilityTest": "猜猜看我是否知道你挂了代理", "MacChecker": "查询物理地址的归属信息", + "IpCalculator": "任意 IP、前缀或范围的子网计算、进制转换与 IPv6 解读", "BrowserInfo": "检阅浏览器信息和指纹", "SecurityChecklist": "全面的数字生活安全检查清单", "PersonaCheck": "把网站看到的你,和你想扮成的本地人做对照" @@ -901,6 +902,135 @@ "value": "值", "manufacturer": "制造商信息" }, + "ipcalculator": { + "Title": "IP 计算器", + "Note": "粘贴一个 IPv4 或 IPv6 地址、CIDR 前缀、地址范围、前缀列表、纯数字,或者浏览器仍然认得的那些奇怪写法,计算器会自动识别输入类型,只展示适用的结果。适合这些场景:划分子网时要知道网络地址、广播地址、可用主机数、掩码、反掩码,或者把一个大段拆成小块;拿到一个 IPv6 地址想看懂它的类型、里面嵌的 IPv4、接口标识是否暴露了 MAC;写防火墙或 ACL 时要把一段范围换成最少的 CIDR,或者把一长串前缀合并;日志或可疑链接里出现一串数字,想还原成正常的地址。全部在浏览器本地计算,不会发送到任何地方。", + "Note2": "输入 IP、前缀、范围、列表或数字开始计算:", + "Placeholder": "2001:db8::1/64", + "Calculate": "计算", + "Examples": "试试:", + "exampleObfuscated": "混淆写法", + "exampleEui64": "EUI-64", + "MyIPs": "你的 IP:", + "InterpretedAs": "解读为", + "copy": "复制", + "copyAll": "全部复制", + "yes": "是", + "no": "否", + "kind": { + "ipv4": "IPv4", + "ipv4Cidr": "IPv4 前缀", + "ipv6": "IPv6", + "ipv6Cidr": "IPv6 前缀", + "integer": "整数", + "hex": "十六进制", + "range": "地址范围", + "cidrList": "前缀列表" + }, + "scope": { + "global": "全球可路由", + "private": "私有", + "shared": "共享(CGNAT)", + "loopback": "环回", + "linkLocal": "链路本地", + "multicast": "组播", + "documentation": "文档示例", + "reserved": "保留", + "unspecified": "未指定", + "broadcast": "广播" + }, + "invalid": { + "empty": "请输入要计算的内容", + "listNoValid": "列表里没有一项是合法的 IP 或前缀", + "cidrPrefix": "/ 后面的前缀长度超出了该地址族的范围", + "cidrAddress": "/ 前面的部分不是合法的 IP", + "ipv6Syntax": "不是合法的 IPv6 地址", + "hexTooLarge": "十六进制值超过 128 位", + "integerTooLarge": "整数超过 128 位", + "badOctal": "以 0 开头的数字按八进制解析,而 8 和 9 不是八进制数字", + "range": "范围两端必须是同一地址族的合法 IP", + "unrecognized": "无法识别的输入" + }, + "section": { + "Network": "网络", + "Representations": "表示形式", + "Obfuscated": "混淆写法", + "AsIPv6": "嵌入 IPv6", + "Split": "拆分子网", + "Address": "地址", + "Type": "类型详情", + "InterfaceId": "接口标识符", + "Prefix": "前缀", + "Range": "范围", + "Cidrs": "CIDR 块", + "IPv4Blocks": "IPv4 块", + "IPv6Blocks": "IPv6 块" + }, + "network": "网络地址", + "broadcast": "广播地址", + "netmask": "子网掩码", + "wildcard": "反掩码", + "firstHost": "首个主机", + "lastHost": "末个主机", + "firstAddress": "首地址", + "lastAddress": "末地址", + "usable": "可用主机数", + "total": "地址总数", + "ptr": "PTR 记录", + "ptrZone": "反向解析区", + "class": "类别", + "rfc3021Note": "/31(RFC 3021)和 /32 没有单独的网络地址与广播地址,每个地址都可用。", + "networkBits": "网络位", + "hostBits": "主机位", + "prefixLength": "前缀长度", + "decimal": "十进制", + "hex": "十六进制", + "octal": "八进制", + "binary": "二进制", + "obfuscatedNote": "浏览器和多数库同样接受这些写法,这也是简单的 IP 过滤容易被绕过的原因。", + "dottedHex": "点分十六进制", + "dottedOctal": "点分八进制", + "shortForm": "缩写({parts} 段)", + "mapped": "IPv4 映射", + "mappedHex": "IPv4 映射(十六进制)", + "compat": "IPv4 兼容", + "nat64": "NAT64", + "sixToFour": "6to4 前缀", + "splitTo": "拆分为", + "showingFirst": "共 {total} 个子网,仅显示前 {shown} 个", + "subnetCount": "{count} 个子网", + "compressed": "压缩形式", + "expanded": "展开形式", + "zone": "区域 ID", + "embeddedIPv4": "嵌入的 IPv4", + "teredoServer": "Teredo 服务器", + "teredoClient": "Teredo 客户端", + "teredoPort": "Teredo 端口", + "teredoCone": "Cone NAT", + "locallyAssigned": "本地分配(fd00::/8)", + "globalId": "全局 ID", + "subnetId": "子网 ID", + "mcastScope": "组播范围", + "flags": "标志位", + "flagT": "T(临时)", + "flagP": "P(基于前缀)", + "flagR": "R(内嵌 RP)", + "noFlags": "无", + "solicitedNodeSuffix": "被请求节点后缀", + "iid": "接口 ID", + "mac": "由 EUI-64 还原的 MAC", + "solicitedNode": "被请求节点组播地址", + "universalMac": "接口 ID 是由全球唯一 MAC 生成的修改版 EUI-64,设备走到哪里都能被认出来。", + "localMac": "接口 ID 是由本地管理的 MAC 生成的修改版 EUI-64。", + "privacyIid": "接口 ID 不是 EUI-64 形态:可能是隐私扩展、随机生成或手动指定的标识符。", + "subnetRouterAnycast": "接口 ID 全零:这是该前缀的子网路由器任播地址。", + "slash64s": "/64 子网数", + "start": "起始", + "end": "结束", + "aggregated": "等价单一块", + "aggregationSummary": "输入 {input} 个块,输出 {output} 个,共 {addresses} 个地址", + "invalidTokens": "已忽略:{list}" + }, "whois": { "Title": "Whois 查询", "Note": "Whois 查询是一种用于查询域名注册信息的服务。通过输入域名或 IP 地址,可以查询到域名的注册信息、注册商、注册日期、过期日期等信息。IP 与域名的 Whois 信息字段不完全一致。同时,部分比较小众的域名(TLD)可能无法查询到相关信息。", @@ -1464,6 +1594,7 @@ "InvisibilityTest": "打开隐身测试面板", "EnhancedDnsLeakTest": "打开深度 DNS 泄露测试面板", "MacChecker": "打开物理地址查询面板", + "IpCalculator": "打开 IP 计算器面板", "BrowserInfo": "打开浏览器信息面板", "SecurityChecklist": "打开安全检查清单", "PersonaCheck": "打开深度画像检测面板" From 3b19e0253ef4fd2326f59610e98ac986b45a7c80 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:18:58 +0800 Subject: [PATCH 04/36] Docs: describe the IP Calculator and drop changelog entry counts READMEs and the AGENTS overview list the new tool; frontend/AGENTS.md records the shareable `?q=` input pattern. TRANSLATING.md no longer quotes the number of changelog entries, which went stale on every release. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 ++-- README.md | 1 + README_FR.md | 1 + README_PT-BR.md | 1 + README_RU.md | 1 + README_ZH-TW.md | 1 + README_ZH.md | 1 + TRANSLATING.md | 4 ++-- frontend/AGENTS.md | 4 ++++ 9 files changed, 14 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d511db7b..7e07d4d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ Area-specific details: @frontend/AGENTS.md (Vue SPA) · @api/AGENTS.md (Express **MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security -checklist, browser fingerprint, anonymity checks, persona check, and -more. Single repo, two +checklist, browser fingerprint, anonymity checks, persona check, IP +calculator, and more. Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API. ## Stack diff --git a/README.md b/README.md index e59483d64..0a00d94c8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Feel free to bookmark the demo or deploy your own. * 📟 **DNS Resolver**: Resolves a domain through multiple resolvers at once, grouped by country — an easy way to spot hijacking or contamination. * 📓 **Whois Search**: Performs Whois lookups for domain names and IP addresses. * 🗄️ **MAC Lookup**: Identifies the vendor and details behind a physical address. +* 🧮 **IP Calculator**: Subnet math, notation conversions and IPv6 interface details for any IP, prefix, range or list, computed locally. * 🛰️ **ASN Info & Upstream Topology**: Shows AS details, historical announcements for an IP prefix, and the upstream paths from an ASN to the Tier 1 backbone. * 📶 **Service Status**: Live availability of well-known services — Claude, OpenAI, GitHub, Cloudflare, and more — from their official status pages, with recent incidents. diff --git a/README_FR.md b/README_FR.md index 71b2125af..a786b1d71 100644 --- a/README_FR.md +++ b/README_FR.md @@ -63,6 +63,7 @@ N'hésitez pas à mettre la démo en favori ou à déployer votre propre instanc * 📟 **Résolveur DNS** : Résout un domaine via plusieurs résolveurs à la fois, regroupés par pays — un moyen simple de repérer un détournement ou une contamination. * 📓 **Recherche Whois** : Effectue des recherches Whois pour les noms de domaine et les adresses IP. * 🗄️ **Recherche MAC** : Identifie le fabricant et les détails derrière une adresse physique. +* 🧮 **Calculateur IP** : Calcul de sous-réseau, conversions de notation et détails d'interface IPv6 pour toute IP, préfixe, plage ou liste, le tout en local. * 🛰️ **Infos ASN et topologie amont** : Affiche les détails d'un AS, l'historique des annonces d'un préfixe IP et les chemins amont d'un ASN vers la dorsale Tier 1. * 📶 **État des services** : Disponibilité en direct de services connus — Claude, OpenAI, GitHub, Cloudflare et d'autres — depuis leurs pages d'état officielles, avec les incidents récents. diff --git a/README_PT-BR.md b/README_PT-BR.md index 87452f319..723d4e601 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -63,6 +63,7 @@ Adicione a demonstração aos favoritos ou faça sua própria implantação. * 📟 **Resolução DNS**: Resolve um domínio por vários resolvedores de uma só vez, agrupados por país — um jeito fácil de detectar sequestro ou contaminação. * 📓 **Pesquisa Whois**: Realiza consultas Whois para nomes de domínio e endereços IP. * 🗄️ **Consulta de MAC**: Identifica o fabricante e os detalhes por trás de um endereço físico. +* 🧮 **Calculadora de IP**: Cálculo de sub-rede, conversões de notação e detalhes de interface IPv6 para qualquer IP, prefixo, intervalo ou lista, tudo localmente. * 🛰️ **Informações de ASN e topologia de upstream**: Mostra detalhes do AS, anúncios históricos de um prefixo IP e os caminhos de upstream de um ASN até o backbone Tier 1. * 📶 **Status dos serviços**: Disponibilidade em tempo real de serviços conhecidos — Claude, OpenAI, GitHub, Cloudflare e outros — a partir de suas páginas oficiais de status, com incidentes recentes. diff --git a/README_RU.md b/README_RU.md index d2bc15657..725382ee4 100644 --- a/README_RU.md +++ b/README_RU.md @@ -63,6 +63,7 @@ * 📟 **Разрешение DNS**: разрешает домен сразу через несколько резолверов, сгруппированных по странам, — простой способ заметить перехват или загрязнение DNS. * 📓 **Поиск Whois**: выполняет Whois-запросы для доменных имён и IP-адресов. * 🗄️ **Поиск MAC-адреса**: определяет производителя и другие сведения по физическому адресу. +* 🧮 **IP-калькулятор**: расчёт подсетей, преобразование записей и сведения об интерфейсе IPv6 для любого IP, префикса, диапазона или списка — всё локально. * 🛰️ **Сведения об ASN и топология вышестоящих сетей**: показывают данные автономной системы, историю анонсов IP-префикса и пути от ASN к магистральным сетям Tier 1. * 📶 **Состояние сервисов**: актуальная доступность известных сервисов — Claude, OpenAI, GitHub, Cloudflare и других — по данным их официальных страниц состояния, включая недавние инциденты. diff --git a/README_ZH-TW.md b/README_ZH-TW.md index 8de4cfbfb..67601a3ab 100644 --- a/README_ZH-TW.md +++ b/README_ZH-TW.md @@ -63,6 +63,7 @@ * 📟 **DNS 解析**:同時透過多個解析器解析網域,並依國家分組——輕鬆看出是否存在劫持或污染。 * 📓 **Whois 查詢**:對網域名稱與 IP 位址進行 Whois 查詢。 * 🗄️ **MAC 位址查詢**:識別實體位址背後的廠商與詳細資訊。 +* 🧮 **IP 計算器**:對任意 IP、前綴、範圍或清單做子網路計算、進位轉換與 IPv6 介面解讀,全部本機完成。 * 🛰️ **ASN 資訊與上游拓撲**:顯示 AS 詳細資訊、IP 前綴的歷史宣告記錄,以及該 ASN 到 Tier 1 骨幹網路的上游路徑。 * 📶 **服務可用性**:知名服務(Claude、OpenAI、GitHub、Cloudflare 等)的即時可用狀態,資料來自它們的官方狀態頁,並附最近的事故。 diff --git a/README_ZH.md b/README_ZH.md index 66046eb78..3d18a6dee 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -63,6 +63,7 @@ * 📟 **DNS 解析**:同时通过多个解析器解析一个域名,并按国家分组 —— 轻松发现劫持或污染。 * 📓 **Whois 查询**:对域名和 IP 地址进行 Whois 查询。 * 🗄️ **MAC 地址查询**:识别一个物理地址背后的厂商与详细信息。 +* 🧮 **IP 计算器**:对任意 IP、前缀、范围或列表做子网计算、进制转换与 IPv6 接口解读,全部本地完成。 * 🛰️ **ASN 信息与上游拓扑**:展示 AS 详情、IP 前缀的历史宣告记录,以及从某个 ASN 到 Tier 1 骨干网的上游路径。 * 📶 **服务可用性**:知名服务的实时可用状态 —— Claude、OpenAI、GitHub、Cloudflare 等 —— 数据来自它们的官方状态页,并附最近的事故。 diff --git a/TRANSLATING.md b/TRANSLATING.md index dd8a0e74c..bfb7ce177 100644 --- a/TRANSLATING.md +++ b/TRANSLATING.md @@ -44,7 +44,7 @@ can see at a glance which strings you actually wrote. | `frontend/locales/.json` | The main pack — every string in the app UI (~1,150 keys) | **Yes** — all keys, values may be `""` | | `frontend/locales/privacy/.json` | Privacy policy copy (~50 keys) | No — but whole or not at all | | `frontend/locales/security-checklist/.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 (163 entries) | No — beta languages are exempt | +| `frontend/data/changelog.json` | Release history, one string per language per entry | No — beta languages are exempt | `en.json` is the reference for all of them: a translation may lag behind English, never contradict it. @@ -201,7 +201,7 @@ maintainer decision, made when the language is actually complete: - Main pack, privacy copy and security checklist all at 100% against `en` — not a single `""` left (`pnpm i18n-status` shows this). -- Changelog history back-filled — all 163 entries in `frontend/data/changelog.json`. +- Changelog history back-filled — every entry in `frontend/data/changelog.json`. - Enough of a track record that copy changes will keep landing in it. Once `status` flips to `'full'`, `tests/locale-packs.test.js` and `tests/changelog.test.js` diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 369e59f2b..5f4eb8452 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -144,6 +144,10 @@ Copy the named exemplar instead of re-inventing: pills, `h-7 rounded-full px-2.5 text-xs` (IPHistory, DnsResolver). Never the default `spacing=0` connected form: its `border-l-0` / `first:border-l` seam only reads as one bar on a single line, and breaks the moment it wraps. +- **Shareable tool input** — a tool whose result is worth linking to reads its + query from `route.query.q` on mount and writes it back with `router.replace` + on every run (IpCalculator). Works on both `/tools/?q=` and + `/?tool=&q=`; `replace`, not `push`, so history doesn't grow per run. - **Fixed option sets** — a known, closed list of choices is a `Select`, not a toggle row, once it outgrows a comfortable single line (DnsResolver's record types, MtrTest's targets). From e7074cc12c32322a9ea27b4bcad4b5cee0abf2ab Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:18:58 +0800 Subject: [PATCH 05/36] Chore(release): open v7.6.0 with the IP Calculator changelog entry Co-Authored-By: Claude Fable 5.1 --- frontend/data/changelog.json | 17 +++++++++++++++++ package.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 283433d87..c59319dee 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -2086,5 +2086,22 @@ } } ] + }, + { + "version": "v7.6.0", + "date": "Beta", + "content": [ + { + "type": "add", + "change": { + "en": "New tool: IP Calculator — paste any IP, prefix, range, list or number and get subnet math, notation conversions, address type and IPv6 interface details, all computed locally", + "zh": "新增工具:IP 计算器——粘贴任意 IP、前缀、范围、列表或数字,本地算出子网信息、进制转换、地址类型与 IPv6 接口详情", + "zh-TW": "新增工具:IP 計算器——貼上任意 IP、前綴、範圍、清單或數字,本機算出子網路資訊、進位轉換、位址類型與 IPv6 介面詳情", + "fr": "Nouvel outil : Calculateur IP — collez une IP, un préfixe, une plage, une liste ou un nombre et obtenez le calcul de sous-réseau, les conversions de notation, le type d'adresse et les détails d'interface IPv6, le tout calculé localement", + "ru": "Новый инструмент: IP-калькулятор — вставьте любой IP, префикс, диапазон, список или число и получите расчёт подсети, преобразование записей, тип адреса и сведения об интерфейсе IPv6, всё вычисляется локально", + "pt-BR": "Nova ferramenta: Calculadora de IP — cole qualquer IP, prefixo, intervalo, lista ou número e obtenha o cálculo de sub-rede, conversões de notação, tipo de endereço e detalhes de interface IPv6, tudo calculado localmente" + } + } + ] } ] diff --git a/package.json b/package.json index df25239a3..93cfd27d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "7.5.0", + "version": "7.6.0", "type": "module", "packageManager": "pnpm@11.22.0", "engines": { From eed1f985d13f8dbbd12a84b6f82a3a7203ca6ef3 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:21:22 +0800 Subject: [PATCH 06/36] Fix(ipcalculator): use the number-input emoji for the tool card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abacus reads as region-specific; 🔢 is the neutral choice, since Unicode's pocket calculator (U+1F5A9) has no colour emoji presentation. Co-Authored-By: Claude Fable 5.1 --- frontend/data/tools.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/data/tools.js b/frontend/data/tools.js index b100a2bb6..2240c68fa 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -27,7 +27,7 @@ export const ADVANCED_TOOLS = [ { slug: 'censorshipcheck', emoji: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', component: () => import('@/components/advanced-tools/CensorshipCheck.vue') }, { slug: 'whois', emoji: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', component: () => import('@/components/advanced-tools/Whois.vue') }, { slug: 'macchecker', emoji: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', component: () => import('@/components/advanced-tools/MacChecker.vue') }, - { slug: 'ipcalculator', emoji: '🧮', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', component: () => import('@/components/advanced-tools/IpCalculator.vue') }, + { slug: 'ipcalculator', emoji: '🔢', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', component: () => import('@/components/advanced-tools/IpCalculator.vue') }, { slug: 'browserinfo', emoji: '🖥️', titleKey: 'browserinfo.Title', noteKey: 'advancedtools.BrowserInfo', component: () => import('@/components/advanced-tools/BrowserInfo.vue') }, { slug: 'securitychecklist', emoji: '📋', titleKey: 'securitychecklist.Title', noteKey: 'advancedtools.SecurityChecklist', component: () => import('@/components/advanced-tools/SecurityChecklist.vue') }, { slug: 'servicestatus', emoji: '📡', titleKey: 'serviceStatus.Title', noteKey: 'advancedtools.ServiceStatus', component: () => import('@/components/advanced-tools/ServiceStatus.vue') }, From 65145ca5e8cf3f4efe2340bd41d7f374c54649c2 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 19:10:40 +0800 Subject: [PATCH 07/36] Improvements --- package.json | 26 +- pnpm-lock.yaml | 819 +++++++++++++++++--------------------------- pnpm-workspace.yaml | 4 +- 3 files changed, 336 insertions(+), 513 deletions(-) diff --git a/package.json b/package.json index 93cfd27d2..5dfd3451f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "7.6.0", "type": "module", - "packageManager": "pnpm@11.22.0", + "packageManager": "pnpm@11.25.0", "engines": { "node": ">=24" }, @@ -29,12 +29,12 @@ "@iconify-json/circle-flags": "^1.2.10", "@iconify/vue": "^5.0.1", "@khmyznikov/pwa-install": "^0.6.4", - "@lucide/vue": "^1.33.0", + "@lucide/vue": "^1.40.0", "@photostructure/tz-lookup": "^11.6.1", - "@sentry/node": "^10.70.0", - "@sentry/vue": "^10.70.0", - "@tanstack/vue-table": "^9.1.2", - "@thumbmarkjs/thumbmarkjs": "^1.10.1", + "@sentry/node": "^10.73.0", + "@sentry/vue": "^10.73.0", + "@tanstack/vue-table": "^9.2.4", + "@thumbmarkjs/thumbmarkjs": "^1.11.0", "@vueuse/core": "^14.4.0", "chart.js": "^4.5.1", "chartjs-chart-geo": "^4.3.6", @@ -46,8 +46,8 @@ "dagre": "^0.8.5", "dotenv": "^17.4.2", "express": "^5.2.1", - "express-rate-limit": "^8.6.2", - "express-slow-down": "^3.1.0", + "express-rate-limit": "^8.7.0", + "express-slow-down": "^3.1.1", "firebase": "^12.18.0", "html-to-image": "^1.11.13", "http-proxy-middleware": "^4.2.0", @@ -56,18 +56,18 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", - "reka-ui": "^2.10.3", + "reka-ui": "^2.10.4", "tailwind-merge": "^3.6.0", "tar": "^7.5.22", "tw-animate-css": "^1.4.0", "ua-parser-js": "^2.0.10", "unbzip2-stream": "^1.4.3", "vaul-vue": "^0.4.1", - "vue": "^3.5.41", - "vue-i18n": "^11.4.8", + "vue": "^3.5.42", + "vue-i18n": "^11.4.10", "vue-input-otp": "^0.4.0", "vue-markdown-render": "^2.3.1", - "vue-router": "^5.2.0", + "vue-router": "^5.3.1", "vue-sonner": "^2.0.9", "whoiser": "^1.18.0", "world-atlas": "^2.0.2" @@ -76,7 +76,7 @@ "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/vite": "^4.3.3", "@vitejs/plugin-vue": "^6.0.8", - "code-inspector-plugin": "^2.0.7", + "code-inspector-plugin": "^2.0.8", "nodemon": "^3.1.14", "tailwindcss": "^4.3.3", "vconsole": "^3.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d14449e0f..73cb0e080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,31 +16,31 @@ importers: version: 1.2.10 '@iconify/vue': specifier: ^5.0.1 - version: 5.0.1(vue@3.5.41) + version: 5.0.1(vue@3.5.42) '@khmyznikov/pwa-install': specifier: ^0.6.4 version: 0.6.4(@lit/react@1.0.8(@types/react@19.2.14))(@types/dom-chromium-installation-events@101.0.4)(@types/web-app-manifest@1.0.9)(lit@3.3.3) '@lucide/vue': - specifier: ^1.33.0 - version: 1.33.0(vue@3.5.41) + specifier: ^1.40.0 + version: 1.40.0(vue@3.5.42) '@photostructure/tz-lookup': specifier: ^11.6.1 version: 11.6.1 '@sentry/node': - specifier: ^10.70.0 - version: 10.70.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@5.5.0) + specifier: ^10.73.0 + version: 10.73.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@5.5.0) '@sentry/vue': - specifier: ^10.70.0 - version: 10.70.0(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41))(vue@3.5.41) + specifier: ^10.73.0 + version: 10.73.0(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42))(vue@3.5.42) '@tanstack/vue-table': - specifier: ^9.1.2 - version: 9.1.2(vue@3.5.41) + specifier: ^9.2.4 + version: 9.2.4(vue@3.5.42) '@thumbmarkjs/thumbmarkjs': - specifier: ^1.10.1 - version: 1.10.1 + specifier: ^1.11.0 + version: 1.11.0 '@vueuse/core': specifier: ^14.4.0 - version: 14.4.0(vue@3.5.41) + version: 14.4.0(vue@3.5.42) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -72,11 +72,11 @@ importers: specifier: ^5.2.1 version: 5.2.1(supports-color@5.5.0) express-rate-limit: - specifier: ^8.6.2 - version: 8.6.2(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0) + specifier: ^8.7.0 + version: 8.7.0(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0) express-slow-down: - specifier: ^3.1.0 - version: 3.1.0(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0) + specifier: ^3.1.1 + version: 3.1.1(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0) firebase: specifier: ^12.18.0 version: 12.18.0 @@ -91,7 +91,7 @@ importers: version: 5.0.7 pinia: specifier: ^4.0.3 - version: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41) + version: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42) pino: specifier: ^10.3.1 version: 10.3.1 @@ -102,8 +102,8 @@ importers: specifier: ^13.1.3 version: 13.1.3 reka-ui: - specifier: ^2.10.3 - version: 2.10.3(vue@3.5.41) + specifier: ^2.10.4 + version: 2.10.4(vue@3.5.42) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -121,22 +121,22 @@ importers: version: 1.4.3 vaul-vue: specifier: ^0.4.1 - version: 0.4.1(reka-ui@2.10.3(vue@3.5.41))(vue@3.5.41) + version: 0.4.1(reka-ui@2.10.4(vue@3.5.42))(vue@3.5.42) vue: - specifier: ^3.5.41 - version: 3.5.41 + specifier: ^3.5.42 + version: 3.5.42 vue-i18n: - specifier: ^11.4.8 - version: 11.4.8(vue@3.5.41) + specifier: ^11.4.10 + version: 11.4.10(vue@3.5.42) vue-input-otp: specifier: ^0.4.0 - version: 0.4.0(vue@3.5.41) + version: 0.4.0(vue@3.5.42) vue-markdown-render: specifier: ^2.3.1 - version: 2.3.1(vue@3.5.41) + version: 2.3.1(vue@3.5.42) vue-router: - specifier: ^5.2.0 - version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41))(rolldown@1.2.5)(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41) + specifier: ^5.3.1 + version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42))(rolldown@1.2.5)(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42) vue-sonner: specifier: ^2.0.9 version: 2.0.9 @@ -155,10 +155,10 @@ importers: version: 4.3.3(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0)) '@vitejs/plugin-vue': specifier: ^6.0.8 - version: 6.0.8(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41) + version: 6.0.8(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42) code-inspector-plugin: - specifier: ^2.0.7 - version: 2.0.7(supports-color@5.5.0) + specifier: ^2.0.8 + version: 2.0.8(supports-color@5.5.0) nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -174,17 +174,6 @@ importers: packages: - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': - resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} - engines: {node: '>=18.0.0'} - - '@apm-js-collab/code-transformer@0.18.1': - resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==} - hasBin: true - - '@apm-js-collab/tracing-hooks@0.13.0': - resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -201,10 +190,6 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/generator@8.0.0': - resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} @@ -227,18 +212,10 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0': - resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.4': - resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -257,11 +234,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.4': - resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -282,16 +254,12 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.4': - resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@cloudflare/speedtest@1.13.1': resolution: {integrity: sha512-PLk4OqbozJHEutX4xZs2Jg08JYr8ml2HEKbhpooZhYdZzgDy2Cc8RnKl20WewhzaOENZFC8/JQMF7ypw1R8Kaw==} engines: {node: '>=18'} - '@code-inspector/core@2.0.7': - resolution: {integrity: sha512-9frLzGdS1TvPNSiDt5f/FFJ+aiaU8driaOWmyISOa7Cxy9LC1UOiNcJ3fmhqdIb3mHVqgdWT2NMWdr/6zWPtmA==} + '@code-inspector/core@2.0.8': + resolution: {integrity: sha512-RIGPzE5YGRThjFWvW9coAsg2H02ByJWI1yviOm/mJ6qQVFRliTG9rLwSU4gRzLsMu5V8VF+8hypMp6xSOFH5uQ==} peerDependencies: '@anthropic-ai/claude-agent-sdk': ^0.2.29 '@openai/codex-sdk': ^0.106.0 @@ -304,20 +272,20 @@ packages: '@opencode-ai/sdk': optional: true - '@code-inspector/esbuild@2.0.7': - resolution: {integrity: sha512-xFe6OSxWapEcLyf/KQimhdnRA34IYvYfSDrlKh8gbk8IvCEiRHUnSr2KRxRCXLCrpAlaRvSneiYIiuRJgq+NBQ==} + '@code-inspector/esbuild@2.0.8': + resolution: {integrity: sha512-xnfpBiBKrIOGN0j9IZ+k2p6aGq8TOIeHndGY+W8mxeODhO5KbM9rym0FSgVe3tEm6JcUk3kHP9h/EG61mnqDuw==} - '@code-inspector/mako@2.0.7': - resolution: {integrity: sha512-gABCx0mM7rtxXnPy0bYpu0j/2nn21DHFRmlUSMdNdSqxNQHxzP4sbEetZR/63DfsCHOzsikDGk6O2QDAsB27iA==} + '@code-inspector/mako@2.0.8': + resolution: {integrity: sha512-l243SWfGr4sUACVOCd5NbkjuFRobACnmpWXA2IyDFkedYFeGbYYpjZUwP88lxrVHt1PHZyB81HMiScE24P0ONw==} - '@code-inspector/turbopack@2.0.7': - resolution: {integrity: sha512-FD/Kp+S5a/0nSgnfxCWcnP5qUCxkKOPBWszQxwgxv7oe6x+Ca8z9imAurq4UtQAwse2XPXFmbZf2NKfIY4TDBA==} + '@code-inspector/turbopack@2.0.8': + resolution: {integrity: sha512-iLbGsojN6MwcqClTUQrqTHZHKUgbHBXSyDc4CAicmPytr5EcSYCCyICPrAY238ny+CdmzxGLaobZTA/YpScNiA==} - '@code-inspector/vite@2.0.7': - resolution: {integrity: sha512-EVJuoPvQJEjXSlub2aMkZ3k55tCOZYe6Hm/U9orVfRjd3i6vMe7HKpHqLD67fWuW8+FB0Bdu9EHPGJaJHmeOcw==} + '@code-inspector/vite@2.0.8': + resolution: {integrity: sha512-qj8lOiA6Q1cZ8iJI2zGSdEd85yok/8EFa0G2KiSrY1PaD3pLZquayRgzWMCLTC2pmLSWGM4L1UbTyDM2c8joDg==} - '@code-inspector/webpack@2.0.7': - resolution: {integrity: sha512-sA1B9RA7pNcPoZxGmegBxv+0Ac2yoUYJMcQ6aYREnINwfAVvgxKkfksjPubMuglKIEMtY1qinAp+JiQFSuImag==} + '@code-inspector/webpack@2.0.8': + resolution: {integrity: sha512-lz6LvkugtibrKbA+t/jFBkdSThveFEcLe1zutr4MUUOJh4XXWo1MYAjFHkUKnSy/+KqMC9MfBnDDtObYICBUmA==} '@firebase/ai@2.15.0': resolution: {integrity: sha512-Aj7TbFdAIWZdkX8JfdDStERpR35g6WNs+7XhNPtFLFOizUotj6k4N/D8HJkR45165HhnMJBe4hjOeeaxnnn56Q==} @@ -585,20 +553,20 @@ packages: '@internationalized/number@3.6.6': resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} - '@intlify/core-base@11.4.8': - resolution: {integrity: sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==} + '@intlify/core-base@11.4.10': + resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==} engines: {node: '>= 22'} - '@intlify/devtools-types@11.4.8': - resolution: {integrity: sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==} + '@intlify/devtools-types@11.4.10': + resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==} engines: {node: '>= 22'} - '@intlify/message-compiler@11.4.8': - resolution: {integrity: sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==} + '@intlify/message-compiler@11.4.10': + resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==} engines: {node: '>= 22'} - '@intlify/shared@11.4.8': - resolution: {integrity: sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==} + '@intlify/shared@11.4.10': + resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==} engines: {node: '>= 22'} '@isaacs/fs-minipass@4.0.1': @@ -643,8 +611,8 @@ packages: '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} - '@lucide/vue@1.33.0': - resolution: {integrity: sha512-8d3HnIIglHnosvsnm+IWNy8XhYYJvkf4YEgQqb74HnY3ihzdz+7XX4MxMZoyKdw7pbKOGno2HoaHTq7DH/v5AA==} + '@lucide/vue@1.40.0': + resolution: {integrity: sha512-d4Qpgu0yn5SqebZm62ySuIWeXq1QFBfNcjhxIVspxa16OMQCnQGNpwim+pyj1CIz9DiTPhGLoIHRhgKV068j2A==} peerDependencies: vue: '>=3.0.1' @@ -825,12 +793,12 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@sentry/browser-utils@10.70.0': - resolution: {integrity: sha512-IvjhafF5NFXrPCg5EHbAPGDwIhHxgUcLlHTklZ3DAdA6ky88BJYMfevbcnOEmgavf4nylhCaquRUFNB5+szj+A==} + '@sentry/browser-utils@10.73.0': + resolution: {integrity: sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==} engines: {node: '>=18'} - '@sentry/browser@10.70.0': - resolution: {integrity: sha512-IK6+J+8H06tZe+A8L37TT5ZxxwNtyQatW8zl5RYYJ/e9CsjrM8fPi8I1OT7uquTw8UtjqFHt7bEef/Vy63ksPg==} + '@sentry/browser@10.73.0': + resolution: {integrity: sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==} engines: {node: '>=18'} '@sentry/bundler-plugins@10.65.0': @@ -909,16 +877,16 @@ packages: resolution: {integrity: sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==} engines: {node: '>=18'} - '@sentry/core@10.70.0': - resolution: {integrity: sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA==} + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} engines: {node: '>=18'} - '@sentry/feedback@10.70.0': - resolution: {integrity: sha512-6VQn2ETJjHkk4QQDdx/587/JDfXsk3yBTLZ3UZOMtBVrkmwgk+1FJZ8ULJ3ud1xZcuh2icunIkc7tGVv2axdnw==} + '@sentry/feedback@10.73.0': + resolution: {integrity: sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==} engines: {node: '>=18'} - '@sentry/node-core@10.70.0': - resolution: {integrity: sha512-oPOEVVNxv5WHtckx2i06Wi9FLWyvOg/1DUeX732jZ4iqT2nupINaMH4nF4f4kSvUThFnxkFSRQxwqOxgzMKhKA==} + '@sentry/node-core@10.73.0': + resolution: {integrity: sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -938,36 +906,36 @@ packages: '@opentelemetry/sdk-trace-base': optional: true - '@sentry/node@10.70.0': - resolution: {integrity: sha512-SPOOVxmKTVIEtqvOKkQT163e/pOwucjS7OPsCHyRs8sFR4nfBNu0EThplyqnvqd5BWBMTPH6WTBQfo+QWHV+HA==} + '@sentry/node@10.73.0': + resolution: {integrity: sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg==} engines: {node: '>=18'} - '@sentry/opentelemetry@10.70.0': - resolution: {integrity: sha512-UNV/2tqypcUK6FDzerAsFJn1Km/c4VZCYkUZDNbnV5S0cwAq2BYKMo4M5vovaLDBQlxA+Wk9ovbxi5wYjjl9fw==} + '@sentry/opentelemetry@10.73.0': + resolution: {integrity: sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 '@opentelemetry/core': ^1.30.1 || ^2.1.0 '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 - '@sentry/replay-canvas@10.70.0': - resolution: {integrity: sha512-irzpw22bK5CF3jbecDa0gBUcfjv7tgeUoLAvtIfeHOP5ajmf3o4Cp99a9RQqkfgvkcMeRZBUwE91SQhp6Ank+w==} + '@sentry/replay-canvas@10.73.0': + resolution: {integrity: sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==} engines: {node: '>=18'} - '@sentry/replay@10.70.0': - resolution: {integrity: sha512-xMnSGzJn9Xd29rYd32lkx/gFW+5mtgqADJ2FiZvis0MBGZuDlNRwPn0/Cs1xA3JNXKV3NGfhdmmvs90w4dHSmw==} + '@sentry/replay@10.73.0': + resolution: {integrity: sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==} engines: {node: '>=18'} - '@sentry/server-utils@10.70.0': - resolution: {integrity: sha512-rzegZjMFFgCp3o+N8+XU13rfSvz4B+f8rU0ijBGrQcHdMNyfsFDTu1UTm262JofmrV2u+s+D0u0vFTnqtOGkbA==} + '@sentry/server-utils@10.73.0': + resolution: {integrity: sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA==} engines: {node: '>=18'} '@sentry/vite-plugin@5.4.0': resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} engines: {node: '>= 18'} - '@sentry/vue@10.70.0': - resolution: {integrity: sha512-oopTjKv8/WvxBeRDmgh3xJtEAbP/K0qmtAkOkj9jDYAYN5mgBm33sv7cYHXdrAR4o6tE1VFVu2hWqge64dGbHw==} + '@sentry/vue@10.73.0': + resolution: {integrity: sha512-pbUt65YO7mcLT8q0geK62K4HxlKQkya3ZNqXo+BRz99uMvC78BdNSK+ME4jvTYeHwRJdLqQfAwrcXvKgAQlweg==} engines: {node: '>=18'} peerDependencies: '@tanstack/vue-router': ^1.64.0 @@ -1076,18 +1044,18 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/store@0.11.0': - resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} - '@tanstack/table-core@9.1.2': - resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==} + '@tanstack/table-core@9.2.4': + resolution: {integrity: sha512-GwdDyGGr6UXAtubF14yAwcXvdaogqfsQgIk89Suebjxob/Rjq2xvfmXsjDF1rQmKmhHsJm9TOY7myxv3i6geJw==} engines: {node: '>=20'} '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} - '@tanstack/vue-table@9.1.2': - resolution: {integrity: sha512-CJNFrB0ehPT/iQYpl+bAx0ddbGTZGum91nEtqLhX5DMYeROSfcn7gIHAMs98ACBI+4OoZTHHrqZN1UzhKjz+Eg==} + '@tanstack/vue-table@9.2.4': + resolution: {integrity: sha512-GSD6ZkeQvADs6u9KUyhUuiD+qYgA+Lt6hTBDSEadCBIbC8q2SaoHzseLwgDKVUnRClTIMj9+NwCEF+Gp3P7ChQ==} engines: {node: '>=20'} peerDependencies: vue: '>=3.2' @@ -1097,8 +1065,8 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 - '@thumbmarkjs/thumbmarkjs@1.10.1': - resolution: {integrity: sha512-BH7HP3g+NeIXgcYDxtNjx2wfO5cHaH6DjkoDzi4adYe0fknkQZABtKag+2j75p5p8mGMhl9mq2Ee5m1UosYPUw==} + '@thumbmarkjs/thumbmarkjs@1.11.0': + resolution: {integrity: sha512-aJ7lwzR2sd1EIk8o+T1TATkxbqBPsW3u6AGGg1DGMAQ358yHlVWmBTFj2dJVE4/zuJEtx0gHJPNCErsyplBR7g==} '@types/d3-geo@3.1.1': resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} @@ -1109,15 +1077,9 @@ packages: '@types/dom-chromium-installation-events@101.0.4': resolution: {integrity: sha512-jV4HXmW5D18bpndzAPF1REGE5xagQqrAexHgX9WoWIPpNaaHtsHQgu5uFbL2z0NIc9fOD0TIC1TRpJhYA1OPxw==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - '@types/node@24.1.0': resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} @@ -1158,36 +1120,30 @@ packages: vue: optional: true - '@vue/compiler-core@3.5.39': - resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} - - '@vue/compiler-core@3.5.40': - resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} - '@vue/compiler-core@3.5.41': resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} - '@vue/compiler-dom@3.5.39': - resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} - - '@vue/compiler-dom@3.5.40': - resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} '@vue/compiler-dom@3.5.41': resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} - '@vue/compiler-sfc@3.5.39': - resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} '@vue/compiler-sfc@3.5.41': resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} - '@vue/compiler-ssr@3.5.39': - resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} '@vue/compiler-ssr@3.5.41': resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} @@ -1200,27 +1156,24 @@ packages: '@vue/devtools-shared@8.1.5': resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} - '@vue/reactivity@3.5.41': - resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} - '@vue/runtime-core@3.5.41': - resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} - '@vue/runtime-dom@3.5.41': - resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} - '@vue/server-renderer@3.5.41': - resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} - - '@vue/shared@3.5.39': - resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} - - '@vue/shared@3.5.40': - resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} '@vue/shared@3.5.41': resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} @@ -1300,10 +1253,6 @@ packages: resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} engines: {node: '>=20.19.0'} - astring@1.9.0: - resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -1419,8 +1368,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - code-inspector-plugin@2.0.7: - resolution: {integrity: sha512-Gaj5syVdtQt3UfFmypbHa76IPp5zSJwJCQfvjBb1SpEhJjvCk6I8uNFsNnLKvHcMPCkl/pABnCHXiiHbVXO3Vg==} + code-inspector-plugin@2.0.8: + resolution: {integrity: sha512-Ig7pHNGdlVprf6zRFLhSFI28D4PrKG72YkqNQ0uxYZHEomvKvD6FwK1DrYypbthz+N7c7g9v/uvAFM7tJn3ntw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1596,14 +1545,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1617,8 +1558,14 @@ packages: peerDependencies: express: '>= 4.11' - express-slow-down@3.1.0: - resolution: {integrity: sha512-0gZ1HHow8H83z1/+81DdWB60RSGHI0mJB0ZM1m5P6/BexORFcA8P1TgU0NKEwOiRmxyCIoktZYqmA+1UCc83+A==} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express-slow-down@3.1.1: + resolution: {integrity: sha512-u0qGu2aIt+ZKj/vPhJ510YorAou5r/NTeinDRg+R9SAEOGlLgADnif6tOV2F3bVrM75t2VfeCdOwGLwILGDJvQ==} engines: {node: '>= 16'} peerDependencies: express: 4 || 5 || ^5.0.0-beta.1 @@ -1851,8 +1798,8 @@ packages: engines: {node: '>=6'} hasBin: true - launch-ide@1.4.8: - resolution: {integrity: sha512-SeY4/242PZ6M2cUtZvwEatbmIAjSK2zBm5PFUHFgJsR+1Kw51SBcqUHYzd5yofVAiyt0L047Kya7DqxoP+0Ouw==} + launch-ide@1.4.9: + resolution: {integrity: sha512-M67fqPmkMQQuiFnMtsgtMvHuB2a+8KLxEwH9ccftpy0f3CJQNoRCebZvKSn/ujxR42cMDOIBstuC/KXR/IgXeQ==} lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} @@ -2014,10 +1961,6 @@ packages: lit@3.3.3: resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - local-pkg@1.2.1: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} @@ -2072,10 +2015,6 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - meriyah@6.1.4: - resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} - engines: {node: '>=18.0.0'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2122,11 +2061,6 @@ packages: mutation-observer@1.0.3: resolution: {integrity: sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2262,10 +2196,6 @@ packages: resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} engines: {node: '>= 10.12'} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -2336,8 +2266,8 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - reka-ui@2.10.3: - resolution: {integrity: sha512-nJGZbwcha8AcP2wbnjodfzsciTKBqp1mIzepC9Pi2xDI/YK3++ej3vpJOSPyxqrkq1Oorj4K+BdJkbVCV6x6ag==} + reka-ui@2.10.4: + resolution: {integrity: sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==} peerDependencies: vue: '>= 3.4.0' @@ -2377,9 +2307,6 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - semifies@1.0.0: - resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2431,10 +2358,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2567,10 +2490,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin-utils@0.3.1: - resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} - engines: {node: '>=20.19.0'} - unplugin-utils@0.3.2: resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} engines: {node: '>=20.19.0'} @@ -2681,8 +2600,8 @@ packages: '@vue/composition-api': optional: true - vue-i18n@11.4.8: - resolution: {integrity: sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==} + vue-i18n@11.4.10: + resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==} engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 @@ -2697,8 +2616,8 @@ packages: peerDependencies: vue: ^3.3.4 - vue-router@5.2.0: - resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} + vue-router@5.3.1: + resolution: {integrity: sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==} peerDependencies: '@pinia/colada': '>=0.21.2' '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 @@ -2729,8 +2648,8 @@ packages: nuxt: optional: true - vue@3.5.41: - resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -2830,30 +2749,6 @@ packages: snapshots: - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': - dependencies: - '@apm-js-collab/code-transformer': 0.18.1 - es-module-lexer: 2.3.0 - magic-string: 0.30.21 - module-details-from-path: 1.0.4 - - '@apm-js-collab/code-transformer@0.18.1': - dependencies: - '@types/estree': 1.0.9 - astring: 1.9.0 - esquery: 1.7.0 - meriyah: 6.1.4 - semifies: 1.0.0 - source-map: 0.6.1 - - '@apm-js-collab/tracing-hooks@0.13.0(supports-color@5.5.0)': - dependencies: - '@apm-js-collab/code-transformer': 0.18.1 - debug: 4.4.3(supports-color@5.5.0) - module-details-from-path: 1.0.4 - transitivePeerDependencies: - - supports-color - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2884,19 +2779,10 @@ snapshots: '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/generator@8.0.0': - dependencies: - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@types/jsesc': 2.5.1 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.29.7': @@ -2912,7 +2798,7 @@ snapshots: '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -2927,47 +2813,39 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-string-parser@8.0.0': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.4': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 - '@babel/parser@8.0.4': - dependencies: - '@babel/types': 8.0.4 - '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -2982,18 +2860,13 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.4': - dependencies: - '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.4 - '@cloudflare/speedtest@1.13.1': {} - '@code-inspector/core@2.0.7(supports-color@5.5.0)': + '@code-inspector/core@2.0.8(supports-color@5.5.0)': dependencies: - '@vue/compiler-dom': 3.5.40 + '@vue/compiler-dom': 3.5.41 chalk: 4.1.2 - launch-ide: 1.4.8 + launch-ide: 1.4.9 portfinder: 1.0.38(supports-color@5.5.0) ws: 8.21.0 optionalDependencies: @@ -3003,9 +2876,9 @@ snapshots: - supports-color - utf-8-validate - '@code-inspector/esbuild@2.0.7(supports-color@5.5.0)': + '@code-inspector/esbuild@2.0.8(supports-color@5.5.0)': dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' - '@openai/codex-sdk' @@ -3014,9 +2887,9 @@ snapshots: - supports-color - utf-8-validate - '@code-inspector/mako@2.0.7(supports-color@5.5.0)': + '@code-inspector/mako@2.0.8(supports-color@5.5.0)': dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' - '@openai/codex-sdk' @@ -3025,10 +2898,10 @@ snapshots: - supports-color - utf-8-validate - '@code-inspector/turbopack@2.0.7(supports-color@5.5.0)': + '@code-inspector/turbopack@2.0.8(supports-color@5.5.0)': dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) - '@code-inspector/webpack': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) + '@code-inspector/webpack': 2.0.8(supports-color@5.5.0) transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' - '@openai/codex-sdk' @@ -3037,9 +2910,9 @@ snapshots: - supports-color - utf-8-validate - '@code-inspector/vite@2.0.7(supports-color@5.5.0)': + '@code-inspector/vite@2.0.8(supports-color@5.5.0)': dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) chalk: 4.1.1 transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' @@ -3049,9 +2922,9 @@ snapshots: - supports-color - utf-8-validate - '@code-inspector/webpack@2.0.7(supports-color@5.5.0)': + '@code-inspector/webpack@2.0.8(supports-color@5.5.0)': dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' - '@openai/codex-sdk' @@ -3389,11 +3262,11 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@floating-ui/vue@1.1.11(vue@3.5.41)': + '@floating-ui/vue@1.1.11(vue@3.5.42)': dependencies: '@floating-ui/dom': 1.7.6 '@floating-ui/utils': 0.2.11 - vue-demi: 0.14.10(vue@3.5.41) + vue-demi: 0.14.10(vue@3.5.42) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -3416,10 +3289,10 @@ snapshots: '@iconify/types@2.0.0': {} - '@iconify/vue@5.0.1(vue@3.5.41)': + '@iconify/vue@5.0.1(vue@3.5.42)': dependencies: '@iconify/types': 2.0.0 - vue: 3.5.41 + vue: 3.5.42 '@internationalized/date@3.12.1': dependencies: @@ -3429,23 +3302,23 @@ snapshots: dependencies: '@swc/helpers': 0.5.21 - '@intlify/core-base@11.4.8': + '@intlify/core-base@11.4.10': dependencies: - '@intlify/devtools-types': 11.4.8 - '@intlify/message-compiler': 11.4.8 - '@intlify/shared': 11.4.8 + '@intlify/devtools-types': 11.4.10 + '@intlify/message-compiler': 11.4.10 + '@intlify/shared': 11.4.10 - '@intlify/devtools-types@11.4.8': + '@intlify/devtools-types@11.4.10': dependencies: - '@intlify/core-base': 11.4.8 - '@intlify/shared': 11.4.8 + '@intlify/core-base': 11.4.10 + '@intlify/shared': 11.4.10 - '@intlify/message-compiler@11.4.8': + '@intlify/message-compiler@11.4.10': dependencies: - '@intlify/shared': 11.4.8 + '@intlify/shared': 11.4.10 source-map-js: 1.2.1 - '@intlify/shared@11.4.8': {} + '@intlify/shared@11.4.10': {} '@isaacs/fs-minipass@4.0.1': dependencies: @@ -3489,9 +3362,9 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.5.1 - '@lucide/vue@1.33.0(vue@3.5.41)': + '@lucide/vue@1.40.0(vue@3.5.42)': dependencies: - vue: 3.5.41 + vue: 3.5.42 '@opentelemetry/api-logs@0.220.0': dependencies: @@ -3609,19 +3482,19 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@sentry/browser-utils@10.70.0': + '@sentry/browser-utils@10.73.0': dependencies: '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 - '@sentry/browser@10.70.0': + '@sentry/browser@10.73.0': dependencies: - '@sentry/browser-utils': 10.70.0 + '@sentry/browser-utils': 10.73.0 '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - '@sentry/feedback': 10.70.0 - '@sentry/replay': 10.70.0 - '@sentry/replay-canvas': 10.70.0 + '@sentry/core': 10.73.0 + '@sentry/feedback': 10.73.0 + '@sentry/replay': 10.73.0 + '@sentry/replay-canvas': 10.73.0 '@sentry/bundler-plugins@10.65.0(supports-color@5.5.0)': dependencies: @@ -3688,19 +3561,19 @@ snapshots: dependencies: '@sentry/conventions': 0.15.1 - '@sentry/core@10.70.0': + '@sentry/core@10.73.0': dependencies: '@sentry/conventions': 0.16.0 - '@sentry/feedback@10.70.0': + '@sentry/feedback@10.73.0': dependencies: - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 - '@sentry/node-core@10.70.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/node-core@10.73.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - '@sentry/opentelemetry': 10.70.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/core': 10.73.0 + '@sentry/opentelemetry': 10.73.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) import-in-the-middle: 3.3.1 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -3708,49 +3581,44 @@ snapshots: '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0) '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) - '@sentry/node@10.70.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@5.5.0)': + '@sentry/node@10.73.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@5.5.0)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0) '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - '@sentry/node-core': 10.70.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) - '@sentry/opentelemetry': 10.70.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) - '@sentry/server-utils': 10.70.0(supports-color@5.5.0) + '@sentry/core': 10.73.0 + '@sentry/node-core': 10.73.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@5.5.0))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.73.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.73.0 import-in-the-middle: 3.3.1 transitivePeerDependencies: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - supports-color - '@sentry/opentelemetry@10.70.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/opentelemetry@10.73.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 + '@sentry/core': 10.73.0 - '@sentry/replay-canvas@10.70.0': + '@sentry/replay-canvas@10.73.0': dependencies: - '@sentry/core': 10.70.0 - '@sentry/replay': 10.70.0 + '@sentry/core': 10.73.0 + '@sentry/replay': 10.73.0 - '@sentry/replay@10.70.0': + '@sentry/replay@10.73.0': dependencies: - '@sentry/browser-utils': 10.70.0 - '@sentry/core': 10.70.0 + '@sentry/browser-utils': 10.73.0 + '@sentry/core': 10.73.0 - '@sentry/server-utils@10.70.0(supports-color@5.5.0)': + '@sentry/server-utils@10.73.0': dependencies: - '@apm-js-collab/code-transformer-bundler-plugins': 0.7.4 - '@apm-js-collab/tracing-hooks': 0.13.0(supports-color@5.5.0) '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - meriyah: 6.1.4 - transitivePeerDependencies: - - supports-color + '@sentry/core': 10.73.0 '@sentry/vite-plugin@5.4.0(supports-color@5.5.0)': dependencies: @@ -3761,14 +3629,14 @@ snapshots: - supports-color - webpack - '@sentry/vue@10.70.0(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41))(vue@3.5.41)': + '@sentry/vue@10.73.0(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42))(vue@3.5.42)': dependencies: - '@sentry/browser': 10.70.0 + '@sentry/browser': 10.73.0 '@sentry/conventions': 0.16.0 - '@sentry/core': 10.70.0 - vue: 3.5.41 + '@sentry/core': 10.73.0 + vue: 3.5.42 optionalDependencies: - pinia: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41) + pinia: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42) '@swc/helpers@0.5.21': dependencies: @@ -3842,26 +3710,26 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0) - '@tanstack/store@0.11.0': {} + '@tanstack/store@0.11.1': {} - '@tanstack/table-core@9.1.2': + '@tanstack/table-core@9.2.4': dependencies: - '@tanstack/store': 0.11.0 + '@tanstack/store': 0.11.1 '@tanstack/virtual-core@3.14.0': {} - '@tanstack/vue-table@9.1.2(vue@3.5.41)': + '@tanstack/vue-table@9.2.4(vue@3.5.42)': dependencies: - '@tanstack/store': 0.11.0 - '@tanstack/table-core': 9.1.2 - vue: 3.5.41 + '@tanstack/store': 0.11.1 + '@tanstack/table-core': 9.2.4 + vue: 3.5.42 - '@tanstack/vue-virtual@3.13.24(vue@3.5.41)': + '@tanstack/vue-virtual@3.13.24(vue@3.5.42)': dependencies: '@tanstack/virtual-core': 3.14.0 - vue: 3.5.41 + vue: 3.5.42 - '@thumbmarkjs/thumbmarkjs@1.10.1': {} + '@thumbmarkjs/thumbmarkjs@1.11.0': {} '@types/d3-geo@3.1.1': dependencies: @@ -3871,12 +3739,8 @@ snapshots: '@types/dom-chromium-installation-events@101.0.4': {} - '@types/estree@1.0.9': {} - '@types/geojson@7946.0.16': {} - '@types/jsesc@2.5.1': {} - '@types/node@24.1.0': dependencies: undici-types: 7.8.0 @@ -3902,72 +3766,47 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41)': + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42)': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0) - vue: 3.5.41 + vue: 3.5.42 - '@vue-macros/common@3.1.3(vue@3.5.41)': + '@vue-macros/common@3.1.3(vue@3.5.42)': dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.41 ast-kit: 2.2.0 - local-pkg: 1.1.2 + local-pkg: 1.2.1 magic-string-ast: 1.0.3 - unplugin-utils: 0.3.1 + unplugin-utils: 0.3.2 optionalDependencies: - vue: 3.5.41 - - '@vue/compiler-core@3.5.39': - dependencies: - '@babel/parser': 7.29.7 - '@vue/shared': 3.5.39 - entities: 7.0.1 - estree-walker: 2.0.2 - source-map-js: 1.2.1 + vue: 3.5.42 - '@vue/compiler-core@3.5.40': + '@vue/compiler-core@3.5.41': dependencies: - '@babel/parser': 7.29.7 - '@vue/shared': 3.5.40 + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-core@3.5.41': + '@vue/compiler-core@3.5.42': dependencies: '@babel/parser': 7.29.8 - '@vue/shared': 3.5.41 + '@vue/shared': 3.5.42 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.39': - dependencies: - '@vue/compiler-core': 3.5.39 - '@vue/shared': 3.5.39 - - '@vue/compiler-dom@3.5.40': - dependencies: - '@vue/compiler-core': 3.5.40 - '@vue/shared': 3.5.40 - '@vue/compiler-dom@3.5.41': dependencies: '@vue/compiler-core': 3.5.41 '@vue/shared': 3.5.41 - '@vue/compiler-sfc@3.5.39': + '@vue/compiler-dom@3.5.42': dependencies: - '@babel/parser': 7.29.7 - '@vue/compiler-core': 3.5.39 - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.25 - source-map-js: 1.2.1 + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 '@vue/compiler-sfc@3.5.41': dependencies: @@ -3981,16 +3820,28 @@ snapshots: postcss: 8.5.26 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.39': + '@vue/compiler-sfc@3.5.42': dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.41': dependencies: '@vue/compiler-dom': 3.5.41 '@vue/shared': 3.5.41 + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + '@vue/devtools-api@6.6.4': {} '@vue/devtools-api@8.1.5': @@ -4006,40 +3857,38 @@ snapshots: '@vue/devtools-shared@8.1.5': {} - '@vue/reactivity@3.5.41': + '@vue/reactivity@3.5.42': dependencies: - '@vue/shared': 3.5.41 + '@vue/shared': 3.5.42 - '@vue/runtime-core@3.5.41': + '@vue/runtime-core@3.5.42': dependencies: - '@vue/reactivity': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 - '@vue/runtime-dom@3.5.41': + '@vue/runtime-dom@3.5.42': dependencies: - '@vue/reactivity': 3.5.41 - '@vue/runtime-core': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 csstype: 3.2.3 - '@vue/server-renderer@3.5.41': + '@vue/server-renderer@3.5.42': dependencies: - '@vue/compiler-ssr': 3.5.41 - '@vue/runtime-dom': 3.5.41 - '@vue/shared': 3.5.41 - - '@vue/shared@3.5.39': {} - - '@vue/shared@3.5.40': {} + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 '@vue/shared@3.5.41': {} - '@vueuse/core@10.11.1(vue@3.5.41)': + '@vue/shared@3.5.42': {} + + '@vueuse/core@10.11.1(vue@3.5.42)': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.41) - vue-demi: 0.14.10(vue@3.5.41) + '@vueuse/shared': 10.11.1(vue@3.5.42) + vue-demi: 0.14.10(vue@3.5.42) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -4049,16 +3898,16 @@ snapshots: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 12.8.2 '@vueuse/shared': 12.8.2 - vue: 3.5.41 + vue: 3.5.42 transitivePeerDependencies: - typescript - '@vueuse/core@14.4.0(vue@3.5.41)': + '@vueuse/core@14.4.0(vue@3.5.42)': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.4.0 - '@vueuse/shared': 14.4.0(vue@3.5.41) - vue: 3.5.41 + '@vueuse/shared': 14.4.0(vue@3.5.42) + vue: 3.5.42 '@vueuse/metadata@10.11.1': {} @@ -4066,22 +3915,22 @@ snapshots: '@vueuse/metadata@14.4.0': {} - '@vueuse/shared@10.11.1(vue@3.5.41)': + '@vueuse/shared@10.11.1(vue@3.5.42)': dependencies: - vue-demi: 0.14.10(vue@3.5.41) + vue-demi: 0.14.10(vue@3.5.42) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/shared@12.8.2': dependencies: - vue: 3.5.41 + vue: 3.5.42 transitivePeerDependencies: - typescript - '@vueuse/shared@14.4.0(vue@3.5.41)': + '@vueuse/shared@14.4.0(vue@3.5.42)': dependencies: - vue: 3.5.41 + vue: 3.5.42 accepts@2.0.0: dependencies: @@ -4119,17 +3968,15 @@ snapshots: ast-kit@2.2.0: dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 pathe: 2.0.3 ast-walker-scope@0.9.0: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 ast-kit: 2.2.0 - astring@1.9.0: {} - async@3.2.6: {} atomic-sleep@1.0.0: {} @@ -4262,14 +4109,14 @@ snapshots: clsx@2.1.1: {} - code-inspector-plugin@2.0.7(supports-color@5.5.0): + code-inspector-plugin@2.0.8(supports-color@5.5.0): dependencies: - '@code-inspector/core': 2.0.7(supports-color@5.5.0) - '@code-inspector/esbuild': 2.0.7(supports-color@5.5.0) - '@code-inspector/mako': 2.0.7(supports-color@5.5.0) - '@code-inspector/turbopack': 2.0.7(supports-color@5.5.0) - '@code-inspector/vite': 2.0.7(supports-color@5.5.0) - '@code-inspector/webpack': 2.0.7(supports-color@5.5.0) + '@code-inspector/core': 2.0.8(supports-color@5.5.0) + '@code-inspector/esbuild': 2.0.8(supports-color@5.5.0) + '@code-inspector/mako': 2.0.8(supports-color@5.5.0) + '@code-inspector/turbopack': 2.0.8(supports-color@5.5.0) + '@code-inspector/vite': 2.0.8(supports-color@5.5.0) + '@code-inspector/webpack': 2.0.8(supports-color@5.5.0) chalk: 4.1.1 transitivePeerDependencies: - '@anthropic-ai/claude-agent-sdk' @@ -4411,12 +4258,6 @@ snapshots: escape-html@1.0.3: {} - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@2.0.2: {} etag@1.8.1: {} @@ -4429,7 +4270,15 @@ snapshots: transitivePeerDependencies: - supports-color - express-slow-down@3.1.0(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0): + express-rate-limit@8.7.0(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + express: 5.2.1(supports-color@5.5.0) + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express-slow-down@3.1.1(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0): dependencies: express: 5.2.1(supports-color@5.5.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@5.5.0))(supports-color@5.5.0) @@ -4692,7 +4541,7 @@ snapshots: json5@2.2.3: {} - launch-ide@1.4.8: + launch-ide@1.4.9: dependencies: chalk: 4.1.2 dotenv: 16.6.1 @@ -4815,12 +4664,6 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 - local-pkg@1.1.2: - dependencies: - mlly: 1.8.2 - pkg-types: 2.3.0 - quansync: 0.2.11 - local-pkg@1.2.1: dependencies: mlly: 1.8.2 @@ -4873,8 +4716,6 @@ snapshots: merge-descriptors@2.0.0: {} - meriyah@6.1.4: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4915,8 +4756,6 @@ snapshots: mutation-observer@1.0.3: {} - nanoid@3.3.16: {} - nanoid@3.3.18: {} negotiator@1.0.0: {} @@ -4995,11 +4834,11 @@ snapshots: picomatch@4.0.5: {} - pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41): + pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42): dependencies: '@vue/devtools-api': 8.1.5 nostics: 1.1.4 - vue: 3.5.41 + vue: 3.5.42 pino-abstract-transport@3.0.0: dependencies: @@ -5063,12 +4902,6 @@ snapshots: transitivePeerDependencies: - supports-color - postcss@8.5.25: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -5138,19 +4971,19 @@ snapshots: real-require@0.2.0: {} - reka-ui@2.10.3(vue@3.5.41): + reka-ui@2.10.4(vue@3.5.42): dependencies: '@floating-ui/dom': 1.7.6 - '@floating-ui/vue': 1.1.11(vue@3.5.41) + '@floating-ui/vue': 1.1.11(vue@3.5.42) '@internationalized/date': 3.12.1 '@internationalized/number': 3.6.6 - '@tanstack/vue-virtual': 3.13.24(vue@3.5.41) - '@vueuse/core': 14.4.0(vue@3.5.41) - '@vueuse/shared': 14.4.0(vue@3.5.41) + '@tanstack/vue-virtual': 3.13.24(vue@3.5.42) + '@vueuse/core': 14.4.0(vue@3.5.42) + '@vueuse/shared': 14.4.0(vue@3.5.42) aria-hidden: 1.2.6 defu: 6.1.7 ohash: 2.0.11 - vue: 3.5.41 + vue: 3.5.42 transitivePeerDependencies: - '@vue/composition-api' @@ -5208,8 +5041,6 @@ snapshots: secure-json-parse@4.1.0: {} - semifies@1.0.0: {} - semver@6.3.1: {} semver@7.6.3: {} @@ -5281,8 +5112,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - split2@4.2.0: {} statuses@2.0.2: {} @@ -5401,11 +5230,6 @@ snapshots: unpipe@1.0.0: {} - unplugin-utils@0.3.1: - dependencies: - pathe: 2.0.3 - picomatch: 4.0.5 - unplugin-utils@0.3.2: dependencies: pathe: 2.0.3 @@ -5428,11 +5252,11 @@ snapshots: vary@1.1.2: {} - vaul-vue@0.4.1(reka-ui@2.10.3(vue@3.5.41))(vue@3.5.41): + vaul-vue@0.4.1(reka-ui@2.10.4(vue@3.5.42))(vue@3.5.42): dependencies: - '@vueuse/core': 10.11.1(vue@3.5.41) - reka-ui: 2.10.3(vue@3.5.41) - vue: 3.5.41 + '@vueuse/core': 10.11.1(vue@3.5.42) + reka-ui: 2.10.4(vue@3.5.42) + vue: 3.5.42 transitivePeerDependencies: - '@vue/composition-api' @@ -5456,40 +5280,39 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vue-demi@0.14.10(vue@3.5.41): + vue-demi@0.14.10(vue@3.5.42): dependencies: - vue: 3.5.41 + vue: 3.5.42 - vue-i18n@11.4.8(vue@3.5.41): + vue-i18n@11.4.10(vue@3.5.42): dependencies: - '@intlify/core-base': 11.4.8 - '@intlify/devtools-types': 11.4.8 - '@intlify/shared': 11.4.8 + '@intlify/core-base': 11.4.10 + '@intlify/devtools-types': 11.4.10 + '@intlify/shared': 11.4.10 '@vue/devtools-api': 6.6.4 - vue: 3.5.41 + vue: 3.5.42 - vue-input-otp@0.4.0(vue@3.5.41): + vue-input-otp@0.4.0(vue@3.5.42): dependencies: '@vueuse/core': 12.8.2 - reka-ui: 2.10.3(vue@3.5.41) - vue: 3.5.41 + reka-ui: 2.10.4(vue@3.5.42) + vue: 3.5.42 transitivePeerDependencies: - '@vue/composition-api' - typescript - vue-markdown-render@2.3.1(vue@3.5.41): + vue-markdown-render@2.3.1(vue@3.5.42): dependencies: markdown-it: 14.2.0 - vue: 3.5.41 + vue: 3.5.42 - vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41))(rolldown@1.2.5)(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41): + vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42))(rolldown@1.2.5)(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42): dependencies: - '@babel/generator': 8.0.0 - '@vue-macros/common': 3.1.3(vue@3.5.41) + '@vue-macros/common': 3.1.3(vue@3.5.42) '@vue/devtools-api': 8.1.5 ast-walker-scope: 0.9.0 chokidar: 5.0.0 - json5: 2.2.3 + confbox: 0.2.4 local-pkg: 1.2.1 magic-string: 0.30.21 mlly: 1.8.2 @@ -5501,11 +5324,10 @@ snapshots: tinyglobby: 0.2.17 unplugin: 3.3.0(rolldown@1.2.5)(vite@8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0)) unplugin-utils: 0.3.2 - vue: 3.5.41 - yaml: 2.9.0 + vue: 3.5.42 optionalDependencies: - '@vue/compiler-sfc': 3.5.41 - pinia: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.41) + '@vue/compiler-sfc': 3.5.42 + pinia: 4.0.3(@vue/devtools-api@8.1.5)(vue@3.5.42) vite: 8.2.2(@types/node@24.1.0)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' @@ -5519,13 +5341,13 @@ snapshots: vue-sonner@2.0.9: {} - vue@3.5.41: + vue@3.5.42: dependencies: - '@vue/compiler-dom': 3.5.41 - '@vue/compiler-sfc': 3.5.41 - '@vue/runtime-dom': 3.5.41 - '@vue/server-renderer': 3.5.41 - '@vue/shared': 3.5.41 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 web-vitals@4.2.4: {} @@ -5578,7 +5400,8 @@ snapshots: yallist@5.0.0: {} - yaml@2.9.0: {} + yaml@2.9.0: + optional: true yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 55365df14..a7d638c45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,7 +39,7 @@ minimumReleaseAgeExclude: - '@firebase/remote-config@0.9.0 || 0.9.2' - firebase@12.16.0 || 12.18.0 - pinia@4.0.2 || 4.0.3 - - vue-router@5.2.0 + - vue-router@5.2.0 || 5.3.1 - '@sentry/browser-utils@10.68.0' - '@sentry/browser@10.68.0' - '@sentry/core@10.68.0' @@ -102,4 +102,4 @@ minimumReleaseAgeExclude: - '@firebase/storage@0.14.5' - '@firebase/util@1.15.3' - '@firebase/webchannel-wrapper@1.0.7' - - '@lucide/vue@1.33.0' + - '@lucide/vue@1.33.0 || 1.40.0' From a63ef4521af5275b87a37154ab7a4c532c3ada7f Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 19:39:51 +0800 Subject: [PATCH 08/36] Improvements --- .github/workflows/ci.yml | 5 ++++- .github/workflows/docker-image.yml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bda661a5..c0cced780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: check: name: Test + Build runs-on: ubuntu-latest + # Normal run is ~1 min; a hosted runner that loses its connection would + # otherwise sit "in progress" for the 6h default before failing. + timeout-minutes: 10 steps: - name: Checkout @@ -22,7 +25,7 @@ jobs: - name: Setup pnpm # Reads the `packageManager` field in package.json to pin the pnpm version, # so CI uses the exact same pnpm as local dev. - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 2f4e83c8d..e0500ffc3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -22,6 +22,9 @@ jobs: build-and-push: runs-on: ubuntu-latest environment: production + # The multi-arch build normally lands in ~5 min; a hosted runner that loses + # its connection would otherwise sit "in progress" for the 6h default. + timeout-minutes: 20 steps: - name: Check Out Repo From 3cf534d6ee00e1ae19dd5eaff41d0b8241fc7065 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 20:01:08 +0800 Subject: [PATCH 09/36] Improvements --- .../advanced-tools/IpCalculator.vue | 73 ++++++++++++------- frontend/locales/en.json | 4 +- frontend/locales/fr.json | 4 +- frontend/locales/pt-BR.json | 4 +- frontend/locales/ru.json | 4 +- frontend/locales/zh-TW.json | 4 +- frontend/locales/zh.json | 4 +- 7 files changed, 57 insertions(+), 40 deletions(-) diff --git a/frontend/components/advanced-tools/IpCalculator.vue b/frontend/components/advanced-tools/IpCalculator.vue index 499b6751e..0e3e31a23 100644 --- a/frontend/components/advanced-tools/IpCalculator.vue +++ b/frontend/components/advanced-tools/IpCalculator.vue @@ -5,8 +5,8 @@ The query rides the URL as `?q=` on both /tools/ipcalculator and /?tool=ipcalculator, written back on every run so results are shareable. - Example pills under the input teach the accepted syntaxes; on the home - page the visitor's own IPs (store.allIPs) are offered the same way. --> + 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). -->