itube-specs 0.0.1070 → 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.
Files changed (35) hide show
  1. package/SITE_UPDATES.md +16 -0
  2. package/assets/scss/themes/dark/_theme.scss +8 -0
  3. package/assets/scss/themes/light/_theme.scss +8 -0
  4. package/components/adv/faphouse/index.vue +4 -1
  5. package/components/card/category.vue +1 -0
  6. package/components/card/channel.vue +1 -0
  7. package/components/card/collections-more.vue +1 -0
  8. package/components/card/playlist-mini.vue +1 -0
  9. package/components/card/playlist.vue +2 -0
  10. package/components/card/video/index.vue +5 -1
  11. package/components/card/video/mini.vue +1 -0
  12. package/components/card/webcam/index.vue +4 -1
  13. package/components/info/main.vue +1 -0
  14. package/components/layout/age-banner.vue +108 -0
  15. package/components/library/feed.vue +1 -0
  16. package/components/library/item.vue +1 -0
  17. package/components/library/upload-card.vue +1 -0
  18. package/components/player/channel.vue +1 -0
  19. package/components/player/core.vue +10 -3
  20. package/components/player/follow.vue +1 -0
  21. package/components/player/sponsored.vue +1 -0
  22. package/components/ui/avatar.vue +6 -2
  23. package/components/ui/chips.vue +1 -0
  24. package/components/ui/img.vue +49 -1
  25. package/composables/use-age-gate.ts +16 -0
  26. package/i18n/locales/en-US.json +7 -0
  27. package/layouts/default.vue +2 -0
  28. package/middleware/age.global.ts +9 -9
  29. package/package.json +1 -1
  30. package/runtime/index.ts +2 -0
  31. package/runtime/utils/server/__tests__/ip-ranges.test.ts +78 -0
  32. package/runtime/utils/server/age.ts +1 -1
  33. package/runtime/utils/server/ip-ranges.ts +81 -0
  34. package/runtime/utils/server/search-bot-ips.ts +62 -0
  35. package/server/api/age/url.get.ts +11 -0
package/SITE_UPDATES.md CHANGED
@@ -11,6 +11,22 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1071 — 2026-11-03
15
+
16
+ - **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hd-sex-new), срочно:** без новых токенов сборка падает
17
+ на `Undefined variable`. Добавить `$layout-age-banner-bg`, `$layout-age-banner-title`, `$layout-age-banner-text`,
18
+ `$layout-age-banner-btn-bg`, `$layout-age-banner-btn-bg--hover`, `$layout-age-banner-btn-text`, `$ui-img-gate-bg`,
19
+ `$ui-img-gate-text` — значения по аналогии с `themes/dark|light/_theme.scss` слоя.
20
+ - **Сайтам с `FeatureAgeVerificationEnabled` — к сведению:** верификация больше не редиректит на agerify при заходе,
21
+ а тестовый `NL` в списке стран заменён на `GB`.
22
+ Пока она не пройдена, превью (видео, категории, каналы, плейлисты, вебкамы, FapHouse, аватары каналов и
23
+ пользователей, картинки в чипсах, логотипы спонсоров, картинка в шапке категории/канала) показываются
24
+ заглушкой «18+» (не закрыты только портреты моделей), ховер-превью не играют, под шапкой липкая плашка с кнопкой
25
+ верификации (`LayoutAgeBanner`). Свой `UiImg` со взрослым превью — ставить проп `adult`. Плеер при этом заблокирован (сплошной фон без постера, текст и
26
+ кнопка верификации, без рекламы) — нужен `itube-modern-player` ≥ 0.16.0 (`npm i itube-modern-player@^0.16.0`), на
27
+ старом плеер гейт не покажет и ролик будет играть. Новая группа общих ключей `age_gate` (пока только en-US).
28
+ Сайтам без флага делать ничего не надо.
29
+
14
30
  ## 0.0.1068 — 2026-11-03
15
31
 
16
32
  - **Всем — решить флаг:** верификация возраста (редирект на agerify, пока не пройдена) снова работает, за флагом
@@ -33,6 +33,14 @@ $layout-header-border: $color-zinc-800-30;
33
33
  $layout-nav-border: $color-zinc-800-50;
34
34
  $layout-skip-link-bg: $color-zinc-800-60;
35
35
  $layout-skip-link-text: $color-white;
36
+ $layout-age-banner-bg: $color-emerald-600;
37
+ $layout-age-banner-title: $color-white;
38
+ $layout-age-banner-text: $color-white-90;
39
+ $layout-age-banner-btn-bg: $color-white;
40
+ $layout-age-banner-btn-bg--hover: $color-white-90;
41
+ $layout-age-banner-btn-text: $color-green-900;
42
+ $ui-img-gate-bg: $color-zinc-800;
43
+ $ui-img-gate-text: $color-zinc-500;
36
44
 
37
45
  // ─── Page footer links (mixin) ───
38
46
  $page-footer-links-border: $color-zinc-800;
@@ -44,6 +44,14 @@ $layout-header-border: transparent;
44
44
  $layout-nav-border: $color-slate-750;
45
45
  $layout-skip-link-bg: $color-slate-750;
46
46
  $layout-skip-link-text: $color-white;
47
+ $layout-age-banner-bg: $color-emerald-600;
48
+ $layout-age-banner-title: $color-white;
49
+ $layout-age-banner-text: $color-white-90;
50
+ $layout-age-banner-btn-bg: $color-white;
51
+ $layout-age-banner-btn-bg--hover: $color-white-90;
52
+ $layout-age-banner-btn-text: $color-green-900;
53
+ $ui-img-gate-bg: $color-gray-200;
54
+ $ui-img-gate-text: $color-gray-500;
47
55
 
48
56
  // ─── Page footer links (mixin) ───
49
57
  $page-footer-links-border: transparent;
@@ -39,6 +39,7 @@
39
39
  <UiImg
40
40
  :src="item.imageUrl"
41
41
  :alt="item.title"
42
+ adult
42
43
  class="adv-faphouse__img"
43
44
  width="335"
44
45
  height="188"
@@ -46,7 +47,7 @@
46
47
  loading="lazy"
47
48
  />
48
49
  <video
49
- v-if="previewIndex === index && item.previewUrl"
50
+ v-if="previewIndex === index && item.previewUrl && !isAgeGated"
50
51
  :src="item.previewUrl"
51
52
  class="adv-faphouse__video"
52
53
  muted
@@ -84,6 +85,8 @@ const { items } = isEnabled.value ? await useFetchFaphouse() : { items: computed
84
85
  const isMobile = useState<boolean>('isMobile');
85
86
  const previewIndex = ref(-1);
86
87
 
88
+ const { isAgeGated } = useAgeGate();
89
+
87
90
  const onPreviewShow = (index: number) => {
88
91
  if (isMobile.value) return;
89
92
 
@@ -11,6 +11,7 @@
11
11
  :src="currentImage"
12
12
  :loading="priority ? 'eager' : 'lazy'"
13
13
  :priority="priority"
14
+ adult
14
15
  class="card-category__img"
15
16
  sizes="320px"
16
17
  width="320"
@@ -12,6 +12,7 @@
12
12
  :alt="card.name"
13
13
  :loading="priority ? 'eager' : 'lazy'"
14
14
  :priority="priority"
15
+ adult
15
16
  class="card-channel__img"
16
17
  width="480"
17
18
  height="270"
@@ -16,6 +16,7 @@
16
16
  :width="ThumbSize.Small.split('x')[0]"
17
17
  :height="ThumbSize.Small.split('x')[1]"
18
18
  alt=""
19
+ adult
19
20
  class="card-collections-more__thumb"
20
21
  />
21
22
  </span>
@@ -13,6 +13,7 @@
13
13
  :key="`playlist-mini-image-${index}`"
14
14
  :src="getCurrentImage(index)"
15
15
  :alt="card.name"
16
+ adult
16
17
  class="card-playlist-mini__img"
17
18
  width="320"
18
19
  height="180"
@@ -20,6 +20,7 @@
20
20
  :alt="card.name"
21
21
  :loading="priority ? 'eager' : 'lazy'"
22
22
  :priority="priority"
23
+ adult
23
24
  class="card-playlist__img"
24
25
  @error="onImageError(index)"
25
26
  />
@@ -83,6 +84,7 @@
83
84
  v-if="avatar"
84
85
  :src="avatar"
85
86
  :alt="card.username"
87
+ adult
86
88
  class="card-collections-more__count _from-xs"
87
89
  width="36"
88
90
  height="36"
@@ -26,6 +26,7 @@
26
26
  :priority="priority"
27
27
  :alt="card.title"
28
28
  sizes="auto, (min-width: 1440px) 25vw, (min-width: 900px) 33vw, (min-width: 370px) 50vw, 100vw"
29
+ adult
29
30
  class="card-video__poster"
30
31
  width="480"
31
32
  height="270"
@@ -78,6 +79,7 @@
78
79
  :loading="loading"
79
80
  :src="currentAvatar"
80
81
  :alt="card.channelName"
82
+ adult
81
83
  class="card-video__channel-logo"
82
84
  width="44"
83
85
  height="44"
@@ -174,7 +176,9 @@ const { generateLink } = useGenerateLink();
174
176
  const previewId = useId();
175
177
  const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewByTouch } = useVideoPreview();
176
178
 
177
- const isPreviewShow = computed(() => activePreviewId.value === previewId);
179
+ const { isAgeGated } = useAgeGate();
180
+
181
+ const isPreviewShow = computed(() => activePreviewId.value === previewId && !isAgeGated.value);
178
182
 
179
183
  const posterError = ref(false);
180
184
  const avatarError = ref(false);
@@ -9,6 +9,7 @@
9
9
  <UiImg
10
10
  :src="currentPoster"
11
11
  :alt="card.title"
12
+ adult
12
13
  class="card-video-mini__img"
13
14
  sizes="140px"
14
15
  width="1600"
@@ -29,6 +29,7 @@
29
29
  <UiImg
30
30
  :src="poster"
31
31
  :alt="model.name"
32
+ adult
32
33
  class="card-webcam__img"
33
34
  width="335"
34
35
  height="188"
@@ -38,7 +39,7 @@
38
39
  />
39
40
 
40
41
  <video
41
- v-if="isPreviewActive && model.previewUrl"
42
+ v-if="isPreviewActive && model.previewUrl && !isAgeGated"
42
43
  ref="videoRef"
43
44
  :class="{'--ready': isPreviewReady}"
44
45
  class="card-webcam__video"
@@ -216,6 +217,8 @@ const isPreviewActive = previewSlot.isActive(previewId.value);
216
217
  // видео прозрачным, чтобы поверх постера не висел чёрный прямоугольник
217
218
  const isPreviewReady = ref(false);
218
219
 
220
+ const { isAgeGated } = useAgeGate();
221
+
219
222
  /** Ниша в пути: у straight префикса нет, у остальных — `/gay`, `/shemale`. */
220
223
  /**
221
224
  * Возраст в тумбе — ссылка на раздел с фильтром возраста ±1 год: так делал боевой
@@ -13,6 +13,7 @@
13
13
  :avif-src="currentAvifSrc"
14
14
  :alt="title"
15
15
  :sizes="IMG_SIZES"
16
+ adult
16
17
  class="info-main__img"
17
18
  width="80"
18
19
  height="80"
@@ -0,0 +1,108 @@
1
+ <!-- Плашка под шапкой, пока возрастная верификация требуется и не пройдена (useAgeGate) -->
2
+ <template>
3
+ <section
4
+ v-if="isAgeGated"
5
+ class="layout-age-banner"
6
+ >
7
+ <div class="layout-age-banner__inner _container">
8
+ <p class="layout-age-banner__content">
9
+ <strong class="layout-age-banner__title">{{ t('age_gate.title') }}</strong>
10
+ <span class="layout-age-banner__text">{{ t('age_gate.text') }}</span>
11
+ </p>
12
+ <UiBtn
13
+ :disabled="isPending"
14
+ :title="t('age_gate.button')"
15
+ theme="primary"
16
+ size="l"
17
+ class="layout-age-banner__btn"
18
+ @click="onVerify"
19
+ >{{ t('age_gate.button') }}</UiBtn>
20
+ </div>
21
+ </section>
22
+ </template>
23
+
24
+ <script setup lang="ts">
25
+ const { t } = useI18n();
26
+ const route = useRoute();
27
+ const { isAgeGated, startVerification } = useAgeGate();
28
+
29
+ const isPending = ref(false);
30
+
31
+ const onVerify = async () => {
32
+ isPending.value = true;
33
+ try {
34
+ await startVerification(route.fullPath);
35
+ } finally {
36
+ isPending.value = false;
37
+ }
38
+ };
39
+ </script>
40
+
41
+ <style lang="scss">
42
+ .layout-age-banner {
43
+ position: sticky;
44
+ top: var(--header-height);
45
+ z-index: calc(#{$z-index-header} - 2);
46
+ padding-block: 10px;
47
+ background-color: $layout-age-banner-bg;
48
+
49
+ @include from-br($brkpnt-burger) {
50
+ $layout-nav-height: 59px;
51
+
52
+ top: calc(var(--header-height) + #{$layout-nav-height});
53
+ padding-block: 12px;
54
+ }
55
+ }
56
+
57
+ .layout-age-banner__inner {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ gap: 12px;
62
+ text-align: center;
63
+
64
+ @include from-br(sm) {
65
+ flex-direction: row;
66
+ justify-content: space-between;
67
+ gap: 32px;
68
+ text-align: left;
69
+ }
70
+ }
71
+
72
+ .layout-age-banner__content {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 4px;
76
+ }
77
+
78
+ .layout-age-banner__title {
79
+ @include font-base;
80
+
81
+ font-weight: 700;
82
+ color: $layout-age-banner-title;
83
+
84
+ @include from-br(sm) {
85
+ @include font-lg;
86
+ }
87
+ }
88
+
89
+ .layout-age-banner__text {
90
+ @include font-sm;
91
+
92
+ color: $layout-age-banner-text;
93
+
94
+ @include from-br(sm) {
95
+ @include font-base;
96
+ }
97
+ }
98
+
99
+ .layout-age-banner__btn {
100
+ --button-bg: #{$layout-age-banner-btn-bg};
101
+ --button-bg--hover: #{$layout-age-banner-btn-bg--hover};
102
+ --button-text: #{$layout-age-banner-btn-text};
103
+ --button-border: transparent;
104
+
105
+ flex-shrink: 0;
106
+ font-weight: 700;
107
+ }
108
+ </style>
@@ -17,6 +17,7 @@
17
17
  :alt="item.title"
18
18
  :loading="priority ? 'eager' : 'lazy'"
19
19
  :priority="priority"
20
+ adult
20
21
  class="library-feed__avatar"
21
22
  width="64"
22
23
  height="64"
@@ -27,6 +27,7 @@
27
27
  v-if="avatar.avatarUrl && !failedAvatars.includes(index)"
28
28
  :src="avatar.avatarUrl"
29
29
  :alt="avatar.title"
30
+ adult
30
31
  class="library-item__photo"
31
32
  width="40"
32
33
  height="40"
@@ -11,6 +11,7 @@
11
11
  :urls="upload.thumbUrls"
12
12
  :alt="name"
13
13
  sizes="auto, (min-width: 1440px) 25vw, (min-width: 900px) 33vw, (min-width: 370px) 50vw, 100vw"
14
+ adult
14
15
  class="library-upload-card__image"
15
16
  width="480"
16
17
  height="270"
@@ -9,6 +9,7 @@
9
9
  v-if="avatar"
10
10
  :src="avatar"
11
11
  :alt="$t('channel_logo')"
12
+ adult
12
13
  class="player-channel__avatar"
13
14
  sizes="36px"
14
15
  width="36"
@@ -20,6 +20,7 @@
20
20
  @menurequest="onMenuRequest"
21
21
  @pause="onPause"
22
22
  @ready="siteHooks.onReady?.()"
23
+ @blockedclick="startVerification(route.fullPath)"
23
24
  >
24
25
  <template
25
26
  v-if="siteHooks.pauseScreen"
@@ -79,6 +80,9 @@ const emit = defineEmits<{
79
80
 
80
81
  const { t, n, locale } = useI18n();
81
82
 
83
+ const route = useRoute();
84
+ const { isAgeGated, startVerification } = useAgeGate();
85
+
82
86
  const siteHooks = usePlayerSiteHooks();
83
87
  const isPauseScreen = computed(() => !!siteHooks.pauseScreen?.enabled.value);
84
88
 
@@ -206,7 +210,7 @@ const currentSource = computed<VideoSource>(() => {
206
210
  type: 'application/x-mpegurl',
207
211
  title: props.data.title,
208
212
  description: props.data.description,
209
- poster: src
213
+ poster: src && !isAgeGated.value
210
214
  ? {
211
215
  src,
212
216
  fallback,
@@ -217,7 +221,7 @@ const currentSource = computed<VideoSource>(() => {
217
221
  alt: props.data.title,
218
222
  }
219
223
  : undefined,
220
- thumbnails: props.data.vttUrl,
224
+ thumbnails: isAgeGated.value ? undefined : props.data.vttUrl,
221
225
  // Главы: плеер сам рисует кнопку сцен, подписи и превью из VTT.
222
226
  // ITimestamp.duration — момент старта главы (секунды).
223
227
  // `end_duration` бекенд отдаёт не всегда — без него плеер досчитывает конец сам
@@ -251,7 +255,7 @@ function nextPreviewMeta(card: IVideoCard): PreviewMetaItem[] {
251
255
  // externalAdvance плеер его не грузит (реальный стрим придёт по ?view из main.vue).
252
256
  const nextSource = computed<VideoSource | null>(() => {
253
257
  const card = props.nextVideo;
254
- if (!card) return null;
258
+ if (!card || isAgeGated.value) return null;
255
259
 
256
260
  const poster = card.thumbUrls?.webp?.[ThumbSize.Big] || card.thumbUrls?.webp?.[ThumbSize.Medium];
257
261
 
@@ -333,11 +337,13 @@ const options = computed<Omit<PlayerOptions, 'source'>>(() => ({
333
337
  labels: { sceneTypes: t('jump_to') },
334
338
  // Запоминаем громкость/mute в localStorage.
335
339
  persist: { key: PLAYER_PERSIST_KEY },
340
+ blocked: isAgeGated.value ? { text: t('age_gate.player_text'), button: t('age_gate.button') } : undefined,
336
341
  }));
337
342
 
338
343
  // Async-фабрика: обёртка вызывает её параллельно загрузке ядра и конструирует плеер
339
344
  // с готовым adConfig — нам не нужно синхронизировать VAST-запрос с инициализацией.
340
345
  async function loadAdConfig(): Promise<AdsOptions | undefined> {
346
+ if (isAgeGated.value) return undefined;
341
347
  try {
342
348
  const vastTag = await AdvertsHelper.getPrerollVastLink(AdvertsHelper.getPrerollSpotType());
343
349
  if (vastTag && vastTag.trim() !== '') {
@@ -353,6 +359,7 @@ async function loadAdConfig(): Promise<AdsOptions | undefined> {
353
359
  <style lang="scss">
354
360
  .player-core {
355
361
  position: relative;
362
+ isolation: isolate;
356
363
  aspect-ratio: 16/9;
357
364
  overflow: hidden;
358
365
  width: 100%;
@@ -7,6 +7,7 @@
7
7
  <UiImg
8
8
  :src="card.avatarUrl || '/img/logo-placeholder.svg'"
9
9
  :alt="$t('channel_logo')"
10
+ adult
10
11
  class="player-follow__img"
11
12
  sizes="48px"
12
13
  width="48"
@@ -5,6 +5,7 @@
5
5
  v-if="card.logo"
6
6
  :src="card.logo"
7
7
  :alt="card.title"
8
+ adult
8
9
  class="player-sponsored__logo"
9
10
  sizes="56px"
10
11
  width="56"
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="ui-avatar">
3
3
  <img
4
- v-if="src || data.avatar"
4
+ v-if="(src || data.avatar) && !isAgeGated"
5
5
  :src="avatar"
6
6
  :width="size"
7
7
  :height="size"
@@ -11,7 +11,7 @@
11
11
  v-else
12
12
  :style="{ width: size + 'px', height: size + 'px' }"
13
13
  class="ui-avatar__placeholder"
14
- >{{ avatar }}</span>
14
+ >{{ isAgeGated ? initial : avatar }}</span>
15
15
  </div>
16
16
  </template>
17
17
 
@@ -30,6 +30,10 @@ const props = withDefaults(defineProps<{
30
30
  const data = computed(() => useState<IProfileData>('profileData').value);
31
31
 
32
32
  const avatar = computed(() => props.src || (data.value && (data.value.avatar || data.value.username.charAt(0))));
33
+
34
+ const initial = computed(() => data.value?.username?.charAt(0) || '');
35
+
36
+ const { isAgeGated } = useAgeGate();
33
37
  </script>
34
38
 
35
39
  <style lang="scss">
@@ -29,6 +29,7 @@
29
29
  :class="`--${imageFit}`"
30
30
  :width="imageBox.width"
31
31
  :height="imageBox.height"
32
+ adult
32
33
  class="ui-chips__img"
33
34
  sizes="auto, 24px"
34
35
  />
@@ -2,8 +2,16 @@
2
2
  <!-- `<picture>` только когда есть avif: обёртка сдвигает картинку на уровень ниже, а
3
3
  `card/playlist.vue` и `playlist-mini.vue` целятся в свои UiImg через `:first-child`/`:nth-child(2)`
4
4
  — внутри `<picture>` каждая из них стала бы первым ребёнком, и раскладка поехала бы. -->
5
+ <span
6
+ v-if="adult && isAgeGated"
7
+ v-bind="attrs"
8
+ :style="{ aspectRatio: `${width} / ${height}`, '--ui-img-gate-width': `${width}px` }"
9
+ class="ui-img-gate"
10
+ >
11
+ <span class="ui-img-gate__label">{{ $t('age_gate.thumb') }}</span>
12
+ </span>
5
13
  <picture
6
- v-if="avifSrcset"
14
+ v-else-if="avifSrcset"
7
15
  class="ui-img"
8
16
  >
9
17
  <source
@@ -41,6 +49,8 @@ const props = withDefaults(defineProps<{
41
49
  loading?: 'lazy' | 'eager',
42
50
  fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
43
51
  priority?: boolean,
52
+ /** Взрослое превью: при возрастном гейте вместо картинки — заглушка. Портреты моделей и аватары — без него. */
53
+ adult?: boolean,
44
54
  }>(), {
45
55
  loading: 'lazy',
46
56
  fit: 'cover',
@@ -48,6 +58,8 @@ const props = withDefaults(defineProps<{
48
58
 
49
59
  const attrs = useAttrs();
50
60
 
61
+ const { isAgeGated } = useAgeGate();
62
+
51
63
  /**
52
64
  * `sizes="auto"` точнее любого авторского списка, но молодой: Chrome 126, Firefox 150
53
65
  * (апрель 2026), Safari 27 (сентябрь 2026). Где не поддержан — деградирует в `100vw`,
@@ -102,4 +114,40 @@ const emit = defineEmits<{
102
114
  .ui-img source {
103
115
  display: none;
104
116
  }
117
+
118
+ :where(.ui-img-gate) {
119
+ position: relative;
120
+ display: block;
121
+ width: var(--ui-img-gate-width);
122
+ max-width: 100%;
123
+ }
124
+
125
+ .ui-img-gate {
126
+ container-type: inline-size;
127
+ background-color: $ui-img-gate-bg;
128
+ }
129
+
130
+ .ui-img-gate__label {
131
+ @include font-lg;
132
+
133
+ position: absolute;
134
+ inset: 0;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ font-weight: 700;
139
+ color: $ui-img-gate-text;
140
+ }
141
+
142
+ @include to-cq(160px) {
143
+ .ui-img-gate__label {
144
+ @include font-xs;
145
+ }
146
+ }
147
+
148
+ @include to-cq(56px) {
149
+ .ui-img-gate__label {
150
+ @include font-4xs;
151
+ }
152
+ }
105
153
  </style>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Возрастной гейт: верификация в регионе посетителя требуется и ещё не пройдена. Состояние
3
+ * ставит middleware/age.global.ts на сервере; пока оно включено, взрослые превью заменяются
4
+ * заглушкой, плеер заблокирован, под шапкой плашка. `startVerification` уводит на agerify и
5
+ * возвращает на текущую страницу.
6
+ */
7
+ export const useAgeGate = () => {
8
+ const isAgeGated = useState<boolean>('age-gate', () => false);
9
+
10
+ const startVerification = async (backPath: string) => {
11
+ const { url } = await $fetch<{ url: string }>('/bff/age/url', { query: { back: backPath } });
12
+ if (url) window.location.href = url;
13
+ };
14
+
15
+ return { isAgeGated, startVerification };
16
+ };
@@ -1,4 +1,11 @@
1
1
  {
2
+ "age_gate": {
3
+ "thumb": "18+",
4
+ "title": "Laws in your region require age verification to view 18+ content",
5
+ "text": "Takes a minute, and we keep no personal data",
6
+ "button": "Verify age",
7
+ "player_text": "Confirm your age to watch this video"
8
+ },
2
9
  "faphouse": {
3
10
  "promo_text": "❤️ Watch and download FULL videos in HD & 4K quality 🔥",
4
11
  "promo_action": "GET ACCESS"
@@ -12,6 +12,8 @@
12
12
 
13
13
  <LayoutSecondNav class="_to-sd"/>
14
14
 
15
+ <LayoutAgeBanner/>
16
+
15
17
  <main
16
18
  id="main"
17
19
  class="main"
@@ -1,8 +1,8 @@
1
- import { isGeoMatch, getAgeValidationUrl, isAgeVerified } from '../runtime';
1
+ import { isGeoMatch, isAgeVerified, isSearchBotIp, getClientHeaders } from '../runtime';
2
+ import { useAgeGate } from '../composables/use-age-gate';
2
3
 
3
- export default defineNuxtRouteMiddleware(async (to) => {
4
+ export default defineNuxtRouteMiddleware(async () => {
4
5
  if (!import.meta.server) return;
5
- if (to.path.startsWith('/bff/age/verify')) return;
6
6
 
7
7
  const config = useRuntimeConfig();
8
8
  if (!config.public.featureFlags?.FeatureAgeVerificationEnabled) return;
@@ -10,12 +10,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
10
10
  const event = useRequestEvent();
11
11
  if (!(await isGeoMatch(event, config))) return;
12
12
 
13
- const verified = await isAgeVerified(event, config);
13
+ if (await isSearchBotIp(getClientHeaders(event).remoteIP)) return;
14
14
 
15
- if (!verified) {
16
- const { url } = await getAgeValidationUrl(event, to.path, config);
17
- if (url) {
18
- return navigateTo(url, { external: true });
19
- }
15
+ const { isAgeGated } = useAgeGate();
16
+ try {
17
+ isAgeGated.value = !(await isAgeVerified(event, config));
18
+ } catch {
19
+ isAgeGated.value = true;
20
20
  }
21
21
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1070",
4
+ "version": "0.0.1072",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
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
+ });
@@ -4,7 +4,7 @@ import { getClientHeaders } from './get-client-headers';
4
4
  /** Возвращает true если пользователь находится в регионе где требуется верификация возраста. */
5
5
  export async function isGeoMatch(event, _config) {
6
6
  const AGE_VERIFY_REGIONS: string[] = [];
7
- const AGE_VERIFY_COUNTRIES = ['NL'];
7
+ const AGE_VERIFY_COUNTRIES = ['GB'];
8
8
 
9
9
  const headers = getClientHeaders(event);
10
10
  const countryCode = headers[ 'countryCode' ];
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ import { getQuery } from 'h3';
2
+ import { getAgeValidationUrl } from '../../../runtime';
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const back = String(getQuery(event).back || '/');
6
+ const backPath = back.startsWith('/') && !back.startsWith('//') ? back : '/';
7
+
8
+ const { url } = await getAgeValidationUrl(event, backPath, useRuntimeConfig(event));
9
+
10
+ return { url };
11
+ });