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.
- package/SITE_UPDATES.md +16 -0
- package/assets/scss/themes/dark/_theme.scss +8 -0
- package/assets/scss/themes/light/_theme.scss +8 -0
- package/components/adv/faphouse/index.vue +4 -1
- package/components/card/category.vue +1 -0
- package/components/card/channel.vue +1 -0
- package/components/card/collections-more.vue +1 -0
- package/components/card/playlist-mini.vue +1 -0
- package/components/card/playlist.vue +2 -0
- package/components/card/video/index.vue +5 -1
- package/components/card/video/mini.vue +1 -0
- package/components/card/webcam/index.vue +4 -1
- package/components/info/main.vue +1 -0
- package/components/layout/age-banner.vue +108 -0
- package/components/library/feed.vue +1 -0
- package/components/library/item.vue +1 -0
- package/components/library/upload-card.vue +1 -0
- package/components/player/channel.vue +1 -0
- package/components/player/core.vue +10 -3
- package/components/player/follow.vue +1 -0
- package/components/player/sponsored.vue +1 -0
- package/components/ui/avatar.vue +6 -2
- package/components/ui/chips.vue +1 -0
- package/components/ui/img.vue +49 -1
- package/composables/use-age-gate.ts +16 -0
- package/i18n/locales/en-US.json +7 -0
- package/layouts/default.vue +2 -0
- package/middleware/age.global.ts +9 -9
- package/package.json +1 -1
- package/runtime/index.ts +2 -0
- package/runtime/utils/server/__tests__/ip-ranges.test.ts +78 -0
- package/runtime/utils/server/age.ts +1 -1
- package/runtime/utils/server/ip-ranges.ts +81 -0
- package/runtime/utils/server/search-bot-ips.ts +62 -0
- 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
|
|
|
@@ -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
|
|
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);
|
|
@@ -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 год: так делал боевой
|
package/components/info/main.vue
CHANGED
|
@@ -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>
|
|
@@ -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%;
|
package/components/ui/avatar.vue
CHANGED
|
@@ -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">
|
package/components/ui/chips.vue
CHANGED
package/components/ui/img.vue
CHANGED
|
@@ -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
|
+
};
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -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"
|
package/layouts/default.vue
CHANGED
package/middleware/age.global.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isGeoMatch,
|
|
1
|
+
import { isGeoMatch, isAgeVerified, isSearchBotIp, getClientHeaders } from '../runtime';
|
|
2
|
+
import { useAgeGate } from '../composables/use-age-gate';
|
|
2
3
|
|
|
3
|
-
export default defineNuxtRouteMiddleware(async (
|
|
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
|
-
|
|
13
|
+
if (await isSearchBotIp(getClientHeaders(event).remoteIP)) return;
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
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 = ['
|
|
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
|
+
});
|