itube-specs 0.0.1071 → 0.0.1072
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/middleware/age.global.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isGeoMatch, isAgeVerified } from '../runtime';
|
|
1
|
+
import { isGeoMatch, isAgeVerified, isSearchBotIp, getClientHeaders } from '../runtime';
|
|
2
2
|
import { useAgeGate } from '../composables/use-age-gate';
|
|
3
3
|
|
|
4
4
|
export default defineNuxtRouteMiddleware(async () => {
|
|
@@ -10,6 +10,8 @@ export default defineNuxtRouteMiddleware(async () => {
|
|
|
10
10
|
const event = useRequestEvent();
|
|
11
11
|
if (!(await isGeoMatch(event, config))) return;
|
|
12
12
|
|
|
13
|
+
if (await isSearchBotIp(getClientHeaders(event).remoteIP)) return;
|
|
14
|
+
|
|
13
15
|
const { isAgeGated } = useAgeGate();
|
|
14
16
|
try {
|
|
15
17
|
isAgeGated.value = !(await isAgeVerified(event, config));
|
package/package.json
CHANGED
package/runtime/index.ts
CHANGED
|
@@ -71,5 +71,7 @@ export * from './utils/check-device-width';
|
|
|
71
71
|
export * from './utils/is-mobile-device';
|
|
72
72
|
export * from './utils/vtt-helper';
|
|
73
73
|
export * from './utils/server/age';
|
|
74
|
+
export * from './utils/server/ip-ranges';
|
|
75
|
+
export * from './utils/server/search-bot-ips';
|
|
74
76
|
export * from './utils/server/get-client-headers';
|
|
75
77
|
export * from './utils/server/get-path-locale';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseIp, parseCidr, isIpInRanges, type IIpRange } from '../ip-ranges';
|
|
3
|
+
|
|
4
|
+
const ranges = (...cidrs: string[]) => cidrs.map((cidr) => parseCidr(cidr)).filter((range): range is IIpRange => range !== null);
|
|
5
|
+
|
|
6
|
+
describe('parseIp', () => {
|
|
7
|
+
it('разбирает IPv4', () => {
|
|
8
|
+
expect(parseIp('66.249.66.1')).toEqual({ bits: 32, value: 0x42f94201n });
|
|
9
|
+
expect(parseIp(' 0.0.0.0 ')).toEqual({ bits: 32, value: 0n });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('сводит IPv4-mapped IPv6 к IPv4', () => {
|
|
13
|
+
expect(parseIp('::ffff:66.249.66.1')).toEqual({ bits: 32, value: 0x42f94201n });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('разбирает IPv6 с сокращением и без', () => {
|
|
17
|
+
expect(parseIp('::1')).toEqual({ bits: 128, value: 1n });
|
|
18
|
+
expect(parseIp('::')).toEqual({ bits: 128, value: 0n });
|
|
19
|
+
expect(parseIp('2001:4860:4801:10::')?.value).toBe(0x20014860480100100000000000000000n);
|
|
20
|
+
expect(parseIp('2001:4860:4801:10:0:0:0:1')?.value).toBe(0x20014860480100100000000000000001n);
|
|
21
|
+
expect(parseIp('64:ff9b::1.2.3.4')?.value).toBe(0x0064ff9b000000000000000001020304n);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('невалидное — null', () => {
|
|
25
|
+
expect(parseIp('')).toBeNull();
|
|
26
|
+
expect(parseIp('256.1.1.1')).toBeNull();
|
|
27
|
+
expect(parseIp('1.2.3')).toBeNull();
|
|
28
|
+
expect(parseIp('1::2::3')).toBeNull();
|
|
29
|
+
expect(parseIp('1:2:3:4:5:6:7:8:9')).toBeNull();
|
|
30
|
+
expect(parseIp('gggg::1')).toBeNull();
|
|
31
|
+
expect(parseIp('unknown')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('parseCidr', () => {
|
|
36
|
+
it('обнуляет хост-биты базы', () => {
|
|
37
|
+
expect(parseCidr('66.249.66.77/24')).toEqual({ bits: 32, base: 0x42f94200n, mask: 0xffffff00n });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('без префикса — один адрес', () => {
|
|
41
|
+
expect(parseCidr('1.2.3.4')?.mask).toBe(0xffffffffn);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('невалидный префикс — null', () => {
|
|
45
|
+
expect(parseCidr('1.2.3.4/33')).toBeNull();
|
|
46
|
+
expect(parseCidr('1.2.3.4/x')).toBeNull();
|
|
47
|
+
expect(parseCidr('::/129')).toBeNull();
|
|
48
|
+
expect(parseCidr('nope/24')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('isIpInRanges', () => {
|
|
53
|
+
const list = ranges('66.249.64.0/27', '2001:4860:4801:10::/64', '157.55.39.0/24');
|
|
54
|
+
|
|
55
|
+
it('находит IPv4 внутри диапазона и на его границах', () => {
|
|
56
|
+
expect(isIpInRanges('66.249.64.0', list)).toBe(true);
|
|
57
|
+
expect(isIpInRanges('66.249.64.31', list)).toBe(true);
|
|
58
|
+
expect(isIpInRanges('157.55.39.200', list)).toBe(true);
|
|
59
|
+
expect(isIpInRanges('::ffff:66.249.64.5', list)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('не находит соседние адреса', () => {
|
|
63
|
+
expect(isIpInRanges('66.249.64.32', list)).toBe(false);
|
|
64
|
+
expect(isIpInRanges('66.249.63.255', list)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('находит IPv6 внутри /64 и не путает разрядности', () => {
|
|
68
|
+
expect(isIpInRanges('2001:4860:4801:10::1', list)).toBe(true);
|
|
69
|
+
expect(isIpInRanges('2001:4860:4801:10:ffff:ffff:ffff:ffff', list)).toBe(true);
|
|
70
|
+
expect(isIpInRanges('2001:4860:4801:11::1', list)).toBe(false);
|
|
71
|
+
expect(isIpInRanges('::42f9:4001', list)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('невалидный адрес и пустой список — false', () => {
|
|
75
|
+
expect(isIpInRanges('garbage', list)).toBe(false);
|
|
76
|
+
expect(isIpInRanges('66.249.64.1', [])).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface IIpRange {
|
|
2
|
+
bits: 32 | 128;
|
|
3
|
+
base: bigint;
|
|
4
|
+
mask: bigint;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function parseIpv4(ip: string): bigint | null {
|
|
8
|
+
const parts = ip.split('.');
|
|
9
|
+
if (parts.length !== 4) return null;
|
|
10
|
+
|
|
11
|
+
let value = 0n;
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return null;
|
|
14
|
+
value = (value << 8n) | BigInt(part);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseIpv6(ip: string): bigint | null {
|
|
20
|
+
let address = ip;
|
|
21
|
+
let tail: bigint | null = null;
|
|
22
|
+
|
|
23
|
+
const lastColon = address.lastIndexOf(':');
|
|
24
|
+
if (address.slice(lastColon + 1).includes('.')) {
|
|
25
|
+
tail = parseIpv4(address.slice(lastColon + 1));
|
|
26
|
+
if (tail === null) return null;
|
|
27
|
+
address = `${address.slice(0, lastColon + 1)}0:0`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const halves = address.split('::');
|
|
31
|
+
if (halves.length > 2) return null;
|
|
32
|
+
|
|
33
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
34
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
35
|
+
const missing = 8 - head.length - rest.length;
|
|
36
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
|
|
37
|
+
|
|
38
|
+
const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...rest];
|
|
39
|
+
|
|
40
|
+
let value = 0n;
|
|
41
|
+
for (const group of groups) {
|
|
42
|
+
if (!/^[\da-f]{1,4}$/i.test(group)) return null;
|
|
43
|
+
value = (value << 16n) | BigInt(`0x${group}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return tail === null ? value : (value & ~0xffffffffn) | tail;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Адрес → число и разрядность. IPv4-mapped IPv6 (`::ffff:1.2.3.4`) сводится к IPv4. */
|
|
50
|
+
export function parseIp(ip: string): { bits: 32 | 128, value: bigint } | null {
|
|
51
|
+
const trimmed = ip.trim();
|
|
52
|
+
const mapped = trimmed.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
53
|
+
const v4 = parseIpv4(mapped ? mapped[1] : trimmed);
|
|
54
|
+
if (v4 !== null) return { bits: 32, value: v4 };
|
|
55
|
+
|
|
56
|
+
const v6 = parseIpv6(trimmed);
|
|
57
|
+
return v6 === null ? null : { bits: 128, value: v6 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** CIDR (`66.249.64.0/27`, `2001:4860:4801:10::/64`) → диапазон для `isIpInRanges`. */
|
|
61
|
+
export function parseCidr(cidr: string): IIpRange | null {
|
|
62
|
+
const [ip, prefix] = cidr.split('/');
|
|
63
|
+
const parsed = parseIp(ip);
|
|
64
|
+
if (!parsed) return null;
|
|
65
|
+
|
|
66
|
+
const length = prefix === undefined ? parsed.bits : Number(prefix);
|
|
67
|
+
if (!Number.isInteger(length) || length < 0 || length > parsed.bits) return null;
|
|
68
|
+
|
|
69
|
+
const all = (1n << BigInt(parsed.bits)) - 1n;
|
|
70
|
+
const mask = all ^ ((1n << BigInt(parsed.bits - length)) - 1n);
|
|
71
|
+
|
|
72
|
+
return { bits: parsed.bits, base: parsed.value & mask, mask };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Попадает ли адрес хотя бы в один диапазон. Невалидный адрес — нет. */
|
|
76
|
+
export function isIpInRanges(ip: string, ranges: IIpRange[]): boolean {
|
|
77
|
+
const parsed = parseIp(ip);
|
|
78
|
+
if (!parsed) return false;
|
|
79
|
+
|
|
80
|
+
return ranges.some((range) => range.bits === parsed.bits && (parsed.value & range.mask) === range.base);
|
|
81
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isIpInRanges, parseCidr, type IIpRange } from './ip-ranges';
|
|
2
|
+
|
|
3
|
+
/** Официальные списки адресов поисковых роботов. */
|
|
4
|
+
const SOURCES = [
|
|
5
|
+
'https://developers.google.com/static/crawling/ipranges/common-crawlers.json',
|
|
6
|
+
'https://www.bing.com/toolbox/bingbot.json',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const TTL = 24 * 60 * 60 * 1000;
|
|
10
|
+
const RETRY_TTL = 5 * 60 * 1000;
|
|
11
|
+
const FETCH_TIMEOUT = 5000;
|
|
12
|
+
|
|
13
|
+
interface IPrefixesResponse {
|
|
14
|
+
prefixes?: { ipv4Prefix?: string, ipv6Prefix?: string }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rangesBySource = new Map<string, IIpRange[]>();
|
|
18
|
+
let expiresAt = 0;
|
|
19
|
+
let loading: Promise<void> | null = null;
|
|
20
|
+
|
|
21
|
+
async function loadSource(url: string): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const res = await $fetch<IPrefixesResponse>(url, { timeout: FETCH_TIMEOUT, parseResponse: JSON.parse });
|
|
24
|
+
const ranges = (res.prefixes || [])
|
|
25
|
+
.map((prefix) => parseCidr(prefix.ipv4Prefix || prefix.ipv6Prefix || ''))
|
|
26
|
+
.filter((range): range is IIpRange => range !== null);
|
|
27
|
+
if (!ranges.length) return false;
|
|
28
|
+
|
|
29
|
+
rangesBySource.set(url, ranges);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`Search bot IP list failed: ${url}`, error);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function refresh(): Promise<void> {
|
|
38
|
+
const results = await Promise.all(SOURCES.map(loadSource));
|
|
39
|
+
expiresAt = Date.now() + (results.every(Boolean) ? TTL : RETRY_TTL);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Адрес принадлежит проверенному поисковому роботу (Google, Bing) — по их официальным спискам, не по
|
|
44
|
+
* User-Agent: тот подделывается. Списки живут в памяти процесса: качаются при первой проверке, дальше
|
|
45
|
+
* обновляются в фоне раз в сутки (после ошибки — через 5 минут), упавший источник держит прежний список.
|
|
46
|
+
* Ни одного списка ещё нет — ждём первую загрузку, не дольше таймаута; не скачалось — «не робот».
|
|
47
|
+
*/
|
|
48
|
+
export async function isSearchBotIp(ip: string | undefined): Promise<boolean> {
|
|
49
|
+
if (!ip) return false;
|
|
50
|
+
|
|
51
|
+
if (Date.now() >= expiresAt && !loading) {
|
|
52
|
+
loading = refresh().finally(() => {
|
|
53
|
+
loading = null;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!rangesBySource.size && loading) await loading;
|
|
57
|
+
|
|
58
|
+
for (const ranges of rangesBySource.values()) {
|
|
59
|
+
if (isIpInRanges(ip, ranges)) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|