itube-specs 0.0.1071 → 0.0.1073
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/SITE_UPDATES.md +6 -0
- package/components/layout/burger.vue +4 -5
- package/components/layout/header.vue +8 -0
- package/components/upload/main.vue +1 -7
- package/components/upload/picker.vue +6 -67
- package/composables/use-upload-entry.ts +33 -0
- package/i18n/locales/en-US.json +1 -1
- package/middleware/age.global.ts +3 -1
- package/package.json +1 -1
- package/runtime/constants/upload.ts +0 -1
- package/runtime/index.ts +2 -0
- package/runtime/utils/server/__tests__/ip-ranges.test.ts +78 -0
- package/runtime/utils/server/ip-ranges.ts +81 -0
- package/runtime/utils/server/search-bot-ips.ts +62 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1073 — 2026-11-03
|
|
15
|
+
|
|
16
|
+
- **Всем — к сведению:** гость больше не видит заглушку «Sign in to upload» на `/upload`: клик по Upload (шапка,
|
|
17
|
+
бургер) и по выбору файла открывает попап регистрации с пояснением (новый общий ключ `register_to_upload`, пока
|
|
18
|
+
только en-US), после входа — сразу `/upload`. Делать ничего не надо.
|
|
19
|
+
|
|
14
20
|
## 0.0.1071 — 2026-11-03
|
|
15
21
|
|
|
16
22
|
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hd-sex-new), срочно:** без новых токенов сборка падает
|
|
@@ -162,6 +162,7 @@ function logOut() {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
165
|
+
const { requireAuth } = useUploadEntry();
|
|
165
166
|
|
|
166
167
|
const burgerButtons = computed<{ icon: string, label: string, action?: () => void, link?: string }[]>(() => [
|
|
167
168
|
...isAuthorized.value ? [
|
|
@@ -184,11 +185,9 @@ const burgerButtons = computed<{ icon: string, label: string, action?: () => voi
|
|
|
184
185
|
},
|
|
185
186
|
],
|
|
186
187
|
...featureFlags.FeatureUploadEnabled ? [
|
|
187
|
-
|
|
188
|
-
icon: 'upload',
|
|
189
|
-
label: 'upload',
|
|
190
|
-
link: generateLink('/upload'),
|
|
191
|
-
}
|
|
188
|
+
isAuthorized.value
|
|
189
|
+
? { icon: 'upload', label: 'upload', link: generateLink('/upload') }
|
|
190
|
+
: { icon: 'upload', label: 'upload', action: () => requireAuth() },
|
|
192
191
|
] : [],
|
|
193
192
|
]);
|
|
194
193
|
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
:tabindex="isUploadPage ? -1 : undefined"
|
|
64
64
|
:class="{'--current': isUploadPage}"
|
|
65
65
|
class="layout-header__upload _from-sd"
|
|
66
|
+
@click="onUploadClick"
|
|
66
67
|
>
|
|
67
68
|
<UiIcon
|
|
68
69
|
name="upload-arrow"
|
|
@@ -122,6 +123,13 @@ const route = useRoute();
|
|
|
122
123
|
const { generateLink } = useGenerateLink();
|
|
123
124
|
|
|
124
125
|
const uploadLink = computed(() => generateLink('/upload'));
|
|
126
|
+
const { requireAuth, watchUploadEntry } = useUploadEntry();
|
|
127
|
+
|
|
128
|
+
watchUploadEntry();
|
|
129
|
+
|
|
130
|
+
function onUploadClick(event: MouseEvent) {
|
|
131
|
+
if (!requireAuth()) event.preventDefault();
|
|
132
|
+
}
|
|
125
133
|
const isUploadPage = computed(() => route.path === uploadLink.value);
|
|
126
134
|
|
|
127
135
|
const burgerShow = ref(false);
|
|
@@ -67,10 +67,8 @@
|
|
|
67
67
|
|
|
68
68
|
<script setup lang="ts">
|
|
69
69
|
import { UploadAccount, UploadStage } from '../../runtime';
|
|
70
|
-
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
71
70
|
|
|
72
71
|
const { t } = useI18n();
|
|
73
|
-
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
74
72
|
|
|
75
73
|
const {
|
|
76
74
|
stage,
|
|
@@ -96,11 +94,7 @@ const isRulesOpen = ref(false);
|
|
|
96
94
|
|
|
97
95
|
const isPick = computed(() => stage.value === UploadStage.Pick);
|
|
98
96
|
|
|
99
|
-
const account = computed(() =>
|
|
100
|
-
if (!isAuthorized.value) return UploadAccount.SignedOut;
|
|
101
|
-
|
|
102
|
-
return remaining.value > 0 ? UploadAccount.Ok : UploadAccount.Limit;
|
|
103
|
-
});
|
|
97
|
+
const account = computed(() => remaining.value > 0 ? UploadAccount.Ok : UploadAccount.Limit);
|
|
104
98
|
|
|
105
99
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
106
100
|
if (!isUploading.value) return;
|
|
@@ -1,34 +1,6 @@
|
|
|
1
|
-
<!-- First upload step:
|
|
1
|
+
<!-- First upload step: drop zone (desktop) or file button (mobile), requirement checks of the picked file; a guest gets the auth popup instead of the file dialog -->
|
|
2
2
|
<template>
|
|
3
|
-
<div
|
|
4
|
-
v-if="account === UploadAccount.SignedOut"
|
|
5
|
-
class="upload-picker__gate"
|
|
6
|
-
>
|
|
7
|
-
<UiIcon
|
|
8
|
-
class="upload-picker__gate-icon"
|
|
9
|
-
name="lock"
|
|
10
|
-
size="24"
|
|
11
|
-
aria-hidden="true"
|
|
12
|
-
/>
|
|
13
|
-
<p class="upload-picker__gate-title">{{ t('upload_page.sign_in') }}</p>
|
|
14
|
-
<div class="upload-picker__gate-buttons">
|
|
15
|
-
<UiBtn
|
|
16
|
-
theme="primary"
|
|
17
|
-
@click="openAuthPopup(AuthSteps.Registration)"
|
|
18
|
-
>{{ t('auth.sign_up') }}
|
|
19
|
-
</UiBtn>
|
|
20
|
-
<UiBtn
|
|
21
|
-
theme="muted"
|
|
22
|
-
@click="openAuthPopup(AuthSteps.Login)"
|
|
23
|
-
>{{ t('auth.log_in') }}
|
|
24
|
-
</UiBtn>
|
|
25
|
-
</div>
|
|
26
|
-
</div>
|
|
27
|
-
|
|
28
|
-
<div
|
|
29
|
-
v-else
|
|
30
|
-
class="upload-picker"
|
|
31
|
-
>
|
|
3
|
+
<div class="upload-picker">
|
|
32
4
|
<div class="upload-picker__mobile _to-sd">
|
|
33
5
|
<UiBtn
|
|
34
6
|
:disabled="isDisabled"
|
|
@@ -120,7 +92,7 @@
|
|
|
120
92
|
</template>
|
|
121
93
|
|
|
122
94
|
<script setup lang="ts">
|
|
123
|
-
import {
|
|
95
|
+
import { UPLOAD_LIMITS, UploadAccount, UploadCheckStatus } from '../../runtime';
|
|
124
96
|
import type { IUploadFile } from '../../types';
|
|
125
97
|
import { checkUploadFile } from '../../utils/upload/check-upload-file';
|
|
126
98
|
import { createIdleUploadChecks } from '../../utils/upload/create-idle-upload-checks';
|
|
@@ -135,7 +107,7 @@ const emit = defineEmits<{
|
|
|
135
107
|
}>();
|
|
136
108
|
|
|
137
109
|
const { t } = useI18n();
|
|
138
|
-
const {
|
|
110
|
+
const { requireAuth } = useUploadEntry();
|
|
139
111
|
|
|
140
112
|
const inputRef = ref<HTMLInputElement>();
|
|
141
113
|
const isDragging = ref(false);
|
|
@@ -158,7 +130,7 @@ const zoneLabel = computed(() => {
|
|
|
158
130
|
});
|
|
159
131
|
|
|
160
132
|
function openDialog() {
|
|
161
|
-
inputRef.value?.click();
|
|
133
|
+
if (requireAuth()) inputRef.value?.click();
|
|
162
134
|
}
|
|
163
135
|
|
|
164
136
|
async function check(file?: File) {
|
|
@@ -182,7 +154,7 @@ function onDragOver() {
|
|
|
182
154
|
|
|
183
155
|
function onDrop(event: DragEvent) {
|
|
184
156
|
isDragging.value = false;
|
|
185
|
-
check(event.dataTransfer?.files[0]);
|
|
157
|
+
if (requireAuth()) check(event.dataTransfer?.files[0]);
|
|
186
158
|
}
|
|
187
159
|
|
|
188
160
|
function onChange(event: Event) {
|
|
@@ -283,37 +255,4 @@ function onChange(event: Event) {
|
|
|
283
255
|
color: $upload-text;
|
|
284
256
|
font-variant-numeric: tabular-nums;
|
|
285
257
|
}
|
|
286
|
-
|
|
287
|
-
.upload-picker__gate {
|
|
288
|
-
display: flex;
|
|
289
|
-
flex-direction: column;
|
|
290
|
-
justify-content: center;
|
|
291
|
-
align-items: center;
|
|
292
|
-
gap: 16px;
|
|
293
|
-
min-height: 180px;
|
|
294
|
-
padding: 24px;
|
|
295
|
-
text-align: center;
|
|
296
|
-
background-color: $upload-card-bg;
|
|
297
|
-
border-radius: $radius-12;
|
|
298
|
-
|
|
299
|
-
@include from-br(sm) {
|
|
300
|
-
min-height: 220px;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.upload-picker__gate-icon {
|
|
305
|
-
color: $upload-text;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
.upload-picker__gate-title {
|
|
309
|
-
@include font-base;
|
|
310
|
-
|
|
311
|
-
font-weight: 500;
|
|
312
|
-
color: $upload-title;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.upload-picker__gate-buttons {
|
|
316
|
-
display: flex;
|
|
317
|
-
gap: 8px;
|
|
318
|
-
}
|
|
319
258
|
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AuthSteps } from '../runtime';
|
|
2
|
+
import { AuthorizationApiService } from '../services/api/authorization.service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Entry to the upload page for guests: instead of a sign-in placeholder the auth popup opens with an explanation,
|
|
6
|
+
* and after a successful login the user lands on `/upload`. `watchUploadEntry` is called once, by the header.
|
|
7
|
+
*/
|
|
8
|
+
export function useUploadEntry() {
|
|
9
|
+
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
10
|
+
const { isAuthPopupOpen, openAuthPopup } = useAuthPopup();
|
|
11
|
+
const { generateLink } = useGenerateLink();
|
|
12
|
+
const isPending = useState('upload-entry-pending', () => false);
|
|
13
|
+
|
|
14
|
+
function requireAuth() {
|
|
15
|
+
if (isAuthorized.value) return true;
|
|
16
|
+
|
|
17
|
+
isPending.value = true;
|
|
18
|
+
openAuthPopup(AuthSteps.Registration, 'register_to_upload');
|
|
19
|
+
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function watchUploadEntry() {
|
|
24
|
+
watch(isAuthPopupOpen, (isOpen) => {
|
|
25
|
+
if (isOpen || !isPending.value) return;
|
|
26
|
+
|
|
27
|
+
isPending.value = false;
|
|
28
|
+
if (isAuthorized.value) navigateTo(generateLink('/upload'));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { requireAuth, watchUploadEntry };
|
|
33
|
+
}
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"register_to_upload": "Please sign up or log in to upload your videos",
|
|
2
3
|
"age_gate": {
|
|
3
4
|
"thumb": "18+",
|
|
4
5
|
"title": "Laws in your region require age verification to view 18+ content",
|
|
@@ -58,7 +59,6 @@
|
|
|
58
59
|
"title_details": "Video details",
|
|
59
60
|
"subtitle_pick": "Pick a file. You'll add models and tags while it uploads.",
|
|
60
61
|
"subtitle_details": "Your video keeps uploading while you fill this in.",
|
|
61
|
-
"sign_in": "Sign in to upload",
|
|
62
62
|
"drag": "Drag a video here",
|
|
63
63
|
"drag_another": "Drag another video here",
|
|
64
64
|
"checking_video": "Checking video...",
|
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
|
+
}
|