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 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: sign-in gate, drop zone (desktop) or file button (mobile), requirement checks of the picked file -->
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 { AuthSteps, UPLOAD_LIMITS, UploadAccount, UploadCheckStatus } from '../../runtime';
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 { openAuthPopup } = useAuthPopup();
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
+ }
@@ -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...",
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1071",
4
+ "version": "0.0.1073",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -18,7 +18,6 @@ export type UploadStatus = typeof UploadStatus[keyof typeof UploadStatus];
18
18
 
19
19
  export const UploadAccount = {
20
20
  Ok: 'ok',
21
- SignedOut: 'signed-out',
22
21
  Limit: 'limit',
23
22
  } as const;
24
23
 
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
+ }