itube-specs 0.0.1008 → 0.0.1010
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/components/info/model.vue +3 -0
- package/components/layout/header.vue +15 -9
- package/components/layout/language.vue +2 -1
- package/composables/use-auth-cookie.ts +35 -0
- package/composables/use-client-country.ts +29 -4
- package/composables/use-lang-choice.ts +3 -5
- package/composables/use-shared-cookie-domain.ts +13 -0
- package/composables/use-user.ts +6 -7
- package/config/__tests__/locale-domains.test.ts +10 -0
- package/config/locale-domains.ts +20 -0
- package/middleware/auth.global.ts +4 -6
- package/package.json +1 -1
- package/plugins/countries.server.ts +10 -2
- package/server/middleware/country.ts +12 -0
- package/server/utils/__tests__/locale-redirect.test.ts +8 -0
- package/server/utils/locale-redirect.ts +15 -1
|
@@ -224,6 +224,9 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
224
224
|
title: props.card.title,
|
|
225
225
|
videosCount: props.card.videosCount,
|
|
226
226
|
thumbUrl: props.card.img || '',
|
|
227
|
+
primaryImageSmallUrl: props.card.img || undefined,
|
|
228
|
+
primaryImageSmallAvifUrl: props.card.imgAvifUrl,
|
|
229
|
+
gender: props.card.gender,
|
|
227
230
|
guid: props.card.guid || '',
|
|
228
231
|
parameters: [],
|
|
229
232
|
country: shareCountry.value,
|
|
@@ -10,16 +10,16 @@
|
|
|
10
10
|
glow
|
|
11
11
|
class="layout-header__logo"
|
|
12
12
|
/>
|
|
13
|
-
<LayoutNiche
|
|
14
|
-
class="layout-header__niche _from-sd"
|
|
15
|
-
icon-size="20"
|
|
16
|
-
/>
|
|
17
|
-
|
|
18
13
|
<LayoutNotifications
|
|
19
14
|
v-if="featureFlags.FeatureFollowEnabled"
|
|
20
15
|
class="layout-header__notifications"
|
|
21
16
|
/>
|
|
22
17
|
|
|
18
|
+
<LayoutNiche
|
|
19
|
+
class="layout-header__niche _from-sd"
|
|
20
|
+
icon-size="20"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
23
|
<div
|
|
24
24
|
ref="languageRef"
|
|
25
25
|
class="layout-header__language _from-sd"
|
|
@@ -147,6 +147,12 @@ function closeLanguage() {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
.layout-header.--no-notifications .layout-header__niche {
|
|
151
|
+
@include from-br($brkpnt-burger) {
|
|
152
|
+
grid-column: 3;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
150
156
|
.layout-header.--no-notifications .layout-header__language {
|
|
151
157
|
@include from-br($brkpnt-burger) {
|
|
152
158
|
grid-column: 4;
|
|
@@ -195,11 +201,11 @@ function closeLanguage() {
|
|
|
195
201
|
/**
|
|
196
202
|
* Поиск стоит строго по центру ШАПКИ, а не по центру свободного места: он растянут
|
|
197
203
|
* на всю строку сетки и центрирован `justify-self`. Иначе центр уезжал влево —
|
|
198
|
-
* правая группа (ниша,
|
|
204
|
+
* правая группа (колокольчик, ниша, язык, вход) шире логотипа, и с добавлением
|
|
199
205
|
* переключателя ниш перекос дошёл до сотни пикселей.
|
|
200
206
|
*
|
|
201
207
|
* Наезжать при этом не на что: по обе стороны резервируется `--header-side-space`
|
|
202
|
-
* — ширина более широкой (правой) группы: ниша 54
|
|
208
|
+
* — ширина более широкой (правой) группы: колокольчик, ниша 54, язык и вход.
|
|
203
209
|
* Считается БЕЗ бокового паддинга шапки, потому что `100%` у поиска — это уже
|
|
204
210
|
* контентная ширина. Не хватило места — поиск сужается, а боковые группы остаются
|
|
205
211
|
* на своих местах. Значение вынесено в переменную: у сайта без переключателя ниш
|
|
@@ -240,7 +246,7 @@ function closeLanguage() {
|
|
|
240
246
|
|
|
241
247
|
.layout-header__niche {
|
|
242
248
|
@include from-br($brkpnt-burger) {
|
|
243
|
-
grid-column:
|
|
249
|
+
grid-column: 4;
|
|
244
250
|
grid-row: 1;
|
|
245
251
|
}
|
|
246
252
|
}
|
|
@@ -253,7 +259,7 @@ function closeLanguage() {
|
|
|
253
259
|
|
|
254
260
|
.layout-header__notifications {
|
|
255
261
|
@include from-br($brkpnt-burger) {
|
|
256
|
-
grid-column:
|
|
262
|
+
grid-column: 3;
|
|
257
263
|
grid-row: 1;
|
|
258
264
|
}
|
|
259
265
|
}
|
|
@@ -151,6 +151,7 @@ const {
|
|
|
151
151
|
countriesItems,
|
|
152
152
|
setCountry,
|
|
153
153
|
loadCountries,
|
|
154
|
+
withCountryCarry,
|
|
154
155
|
} = useClientCountry();
|
|
155
156
|
|
|
156
157
|
// Именительный падеж считает ICU, своей таблицы имён нет. Пустой `place` = служебный код
|
|
@@ -213,7 +214,7 @@ function changeLocale(locale: string) {
|
|
|
213
214
|
emit('change', locale);
|
|
214
215
|
rememberLangChoice(locale);
|
|
215
216
|
localeCookie.value = locale;
|
|
216
|
-
window.location.href = switchLocalePath(locale);
|
|
217
|
+
window.location.href = withCountryCarry(switchLocalePath(locale));
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
async function changeCountry(item: ISelectItem) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useSharedCookieDomain } from './use-shared-cookie-domain';
|
|
2
|
+
|
|
3
|
+
const AUTH_COOKIE = 'jwtoken';
|
|
4
|
+
const EXPIRED = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Кука авторизации `jwtoken`. У локалей на поддоменах — общая для всех языков (`.a.com`), иначе
|
|
8
|
+
* вход на `ru.a.com` не виден на `a.com`. Старые host-only куки (до общего домена) и куку на
|
|
9
|
+
* `xDomain` гасит при входе и выходе, чтобы под одним именем не висели две разные.
|
|
10
|
+
*/
|
|
11
|
+
export const useAuthCookie = () => {
|
|
12
|
+
const domain = useSharedCookieDomain();
|
|
13
|
+
const xDomain = useRuntimeConfig().public.xDomain as string | undefined;
|
|
14
|
+
const cookie = useCookie<string | null | undefined>(AUTH_COOKIE, { path: '/', secure: true, httpOnly: false, sameSite: 'lax', domain });
|
|
15
|
+
|
|
16
|
+
function expireStale() {
|
|
17
|
+
if (!import.meta.client) return;
|
|
18
|
+
const stale = [undefined, xDomain].filter(item => item !== domain);
|
|
19
|
+
for (const item of stale) {
|
|
20
|
+
document.cookie = [`${AUTH_COOKIE}=`, 'path=/', item ? `domain=${item}` : '', EXPIRED, 'Secure'].filter(Boolean).join('; ');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setToken(token: string) {
|
|
25
|
+
expireStale();
|
|
26
|
+
cookie.value = token;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clearToken() {
|
|
30
|
+
cookie.value = null;
|
|
31
|
+
expireStale();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { cookie, setToken, clearToken };
|
|
35
|
+
};
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import type { ISelectItem } from '../types';
|
|
2
2
|
import { isoCodeMap } from '../utils/iso-code-map';
|
|
3
3
|
import { DictionariesApiService } from '../services/api/dictionaries.service';
|
|
4
|
+
import { useSharedCookieDomain } from './use-shared-cookie-domain';
|
|
5
|
+
import { COUNTRY_CARRY_PARAM, hasLocaleSubdomains, type LocaleSubdomains } from '../config/locale-domains';
|
|
4
6
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts. У
|
|
9
|
+
* локалей на поддоменах кука общая для всех языков, иначе смена языка теряет выбор.
|
|
10
|
+
*/
|
|
11
|
+
const cookieOpts = () => ({
|
|
12
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
13
|
+
path: '/',
|
|
14
|
+
secure: true,
|
|
15
|
+
sameSite: 'lax' as const,
|
|
16
|
+
domain: useSharedCookieDomain(),
|
|
17
|
+
});
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
|
|
@@ -24,7 +35,7 @@ export const useClientCountry = () => {
|
|
|
24
35
|
// падежей и артиклей в ECMA-402 нет. Ручной выбор страны в селекторе перезагружает
|
|
25
36
|
// страницу, поэтому клиенту словарь не нужен.
|
|
26
37
|
const place = useState<string>('clientCountryPlace', () => '');
|
|
27
|
-
const codeCookie = useCookie<string | null>('clientCountryCode',
|
|
38
|
+
const codeCookie = useCookie<string | null>('clientCountryCode', cookieOpts());
|
|
28
39
|
|
|
29
40
|
/** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
|
|
30
41
|
const toIcon = (value: string) => {
|
|
@@ -71,5 +82,19 @@ export const useClientCountry = () => {
|
|
|
71
82
|
}
|
|
72
83
|
};
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Страна не зависит от языка. Обычно её переносит общая cookie, а где её не бывает (localhost, IP
|
|
87
|
+
* при локалях на поддоменах) — ручной выбор едет в URL перехода на другую локаль.
|
|
88
|
+
*/
|
|
89
|
+
const withCountryCarry = (url: string): string => {
|
|
90
|
+
const subdomains = useRuntimeConfig().public.locales?.subdomains as LocaleSubdomains | undefined;
|
|
91
|
+
if (!import.meta.client || !hasLocaleSubdomains(subdomains) || useSharedCookieDomain() || !codeCookie.value) return url;
|
|
92
|
+
|
|
93
|
+
const target = new URL(url, window.location.href);
|
|
94
|
+
target.searchParams.set(COUNTRY_CARRY_PARAM, codeCookie.value);
|
|
95
|
+
|
|
96
|
+
return target.toString();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return { code, icon, place, flagSrc, countriesItems, setCountry, loadCountries, withCountryCarry };
|
|
75
100
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LANG_COOKIE,
|
|
1
|
+
import { LANG_COOKIE, hasLocaleSubdomains, sharedCookieDomain, type LocaleSubdomains } from '../config/locale-domains';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Явный выбор языка на сайтах с локалями на поддоменах. Пишет cookie `lang` (значение — поддомен
|
|
@@ -12,8 +12,7 @@ export const useLangChoice = () => {
|
|
|
12
12
|
function rememberLangChoice(code: string) {
|
|
13
13
|
if (!import.meta.client || !hasLocaleSubdomains(subdomains)) return;
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
const isLocalhost = /^localhost(?::\d+)?$|^\d+\.\d+\.\d+\.\d+/.test(base);
|
|
15
|
+
const domain = sharedCookieDomain(window.location.host, subdomains);
|
|
17
16
|
|
|
18
17
|
const parts = [
|
|
19
18
|
`${LANG_COOKIE}=${subdomains[code] || ''}`,
|
|
@@ -21,8 +20,7 @@ export const useLangChoice = () => {
|
|
|
21
20
|
`max-age=${60 * 60 * 24 * 365}`,
|
|
22
21
|
'samesite=lax',
|
|
23
22
|
];
|
|
24
|
-
|
|
25
|
-
if (!isLocalhost) parts.push(`domain=.${base}`);
|
|
23
|
+
if (domain) parts.push(`domain=${domain}`);
|
|
26
24
|
if (window.location.protocol === 'https:') parts.push('secure');
|
|
27
25
|
|
|
28
26
|
document.cookie = parts.join('; ');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { sharedCookieDomain, type LocaleSubdomains } from '../config/locale-domains';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `domain` для cookie, общих для всех языковых поддоменов (авторизация, выбор страны): `.a.com`
|
|
5
|
+
* с `ru.a.com`. У префиксных локалей, на localhost и IP — `undefined` (host-only).
|
|
6
|
+
*/
|
|
7
|
+
export const useSharedCookieDomain = (): string | undefined => {
|
|
8
|
+
const subdomains = useRuntimeConfig().public.locales?.subdomains as LocaleSubdomains | undefined;
|
|
9
|
+
const headers = import.meta.server ? useRequestHeaders(['x-forwarded-host', 'host']) : {};
|
|
10
|
+
const host = import.meta.server ? headers['x-forwarded-host'] || headers['host'] || '' : window.location.host;
|
|
11
|
+
|
|
12
|
+
return sharedCookieDomain(host, subdomains);
|
|
13
|
+
};
|
package/composables/use-user.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
IRegistrateForm,
|
|
8
8
|
IChangeEmail
|
|
9
9
|
} from '../types';
|
|
10
|
+
import { useAuthCookie } from './use-auth-cookie';
|
|
10
11
|
|
|
11
12
|
function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
|
12
13
|
const result = {} as Omit<T, K>;
|
|
@@ -24,10 +25,11 @@ function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K
|
|
|
24
25
|
*/
|
|
25
26
|
export const useUser = (apiService) => {
|
|
26
27
|
const { show, showError } = useSnackbar();
|
|
28
|
+
const { cookie: authCookie, setToken, clearToken } = useAuthCookie();
|
|
27
29
|
const register = async (form: IRegistrateForm) => {
|
|
28
30
|
const response = await apiService.register(form);
|
|
29
31
|
if (response) {
|
|
30
|
-
|
|
32
|
+
setToken(response.token);
|
|
31
33
|
await getProfile();
|
|
32
34
|
}
|
|
33
35
|
};
|
|
@@ -64,7 +66,7 @@ export const useUser = (apiService) => {
|
|
|
64
66
|
const login = async (form: ILoginForm) => {
|
|
65
67
|
const response = await apiService.login(form);
|
|
66
68
|
if (response) {
|
|
67
|
-
|
|
69
|
+
setToken(response.token);
|
|
68
70
|
await getProfile();
|
|
69
71
|
}
|
|
70
72
|
};
|
|
@@ -111,10 +113,7 @@ export const useUser = (apiService) => {
|
|
|
111
113
|
|
|
112
114
|
const signOut = () => {
|
|
113
115
|
const { generateLink } = useGenerateLink();
|
|
114
|
-
|
|
115
|
-
cookie.value = undefined; // или null
|
|
116
|
-
const domain = useRuntimeConfig().public.xDomain;
|
|
117
|
-
document.cookie = `jwtoken=; path=/; domain=${domain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`;
|
|
116
|
+
clearToken();
|
|
118
117
|
|
|
119
118
|
const profile = useState<IProfileData | null>('profileData');
|
|
120
119
|
profile.value = null;
|
|
@@ -124,7 +123,7 @@ export const useUser = (apiService) => {
|
|
|
124
123
|
};
|
|
125
124
|
|
|
126
125
|
return {
|
|
127
|
-
isAuthorized: computed(() => !!
|
|
126
|
+
isAuthorized: computed(() => !!authCookie.value),
|
|
128
127
|
register,
|
|
129
128
|
login,
|
|
130
129
|
password,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
localeDomains,
|
|
7
7
|
localeFromHost,
|
|
8
8
|
localesWithDomains,
|
|
9
|
+
sharedCookieDomain,
|
|
9
10
|
subdomainsOf,
|
|
10
11
|
} from '../locale-domains';
|
|
11
12
|
|
|
@@ -36,6 +37,15 @@ describe('locale-domains', () => {
|
|
|
36
37
|
expect(baseDomainFromHost('api.a.com', SUBS)).toBe('api.a.com');
|
|
37
38
|
});
|
|
38
39
|
|
|
40
|
+
it('общий домен cookie: базовый с точкой, без поддоменов и на localhost/IP — host-only', () => {
|
|
41
|
+
expect(sharedCookieDomain('ru.a.com', SUBS)).toBe('.a.com');
|
|
42
|
+
expect(sharedCookieDomain('a.com', SUBS)).toBe('.a.com');
|
|
43
|
+
expect(sharedCookieDomain('jp.stage.a.com:8080', SUBS)).toBe('.stage.a.com');
|
|
44
|
+
expect(sharedCookieDomain('ru.localhost:3000', SUBS)).toBeUndefined();
|
|
45
|
+
expect(sharedCookieDomain('192.168.1.5:3000', SUBS)).toBeUndefined();
|
|
46
|
+
expect(sharedCookieDomain('ru.a.com', {})).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
39
49
|
it('локаль по хосту, иначе дефолтная', () => {
|
|
40
50
|
expect(localeFromHost('jp.a.com', SUBS, 'en')).toBe('ja');
|
|
41
51
|
expect(localeFromHost('a.com', SUBS, 'en')).toBe('en');
|
package/config/locale-domains.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
/** Имя cookie явного выбора языка. Значение — поддомен локали, у дефолтной пусто. */
|
|
15
15
|
export const LANG_COOKIE = 'lang';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Query, в котором переключатель языка переносит ручной выбор страны на хост другой локали, когда
|
|
19
|
+
* общей cookie нет (localhost, IP). Читает и тут же убирает server/middleware/country.ts.
|
|
20
|
+
*/
|
|
21
|
+
export const COUNTRY_CARRY_PARAM = 'country';
|
|
22
|
+
|
|
17
23
|
/** Код локали → её поддомен. У дефолтной локали поддомена нет. */
|
|
18
24
|
export type LocaleSubdomains = Readonly<Record<string, string>>;
|
|
19
25
|
|
|
@@ -63,6 +69,20 @@ export function baseDomainFromHost(host: string, subdomains: LocaleSubdomains):
|
|
|
63
69
|
return Object.values(subdomains).includes(firstLabel) ? clean.slice(firstLabel.length + 1) : clean;
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Атрибут `domain` для cookie, общих для всех языковых поддоменов (выбор языка, страны):
|
|
74
|
+
* `.a.com` с `ru.a.com` и `a.com`. Без поддоменов, на localhost и IP — `undefined` (host-only):
|
|
75
|
+
* domain-атрибут без точки в имени браузеры там отбрасывают.
|
|
76
|
+
*/
|
|
77
|
+
export function sharedCookieDomain(host: string, subdomains?: LocaleSubdomains | null): string | undefined {
|
|
78
|
+
if (!hasLocaleSubdomains(subdomains)) return undefined;
|
|
79
|
+
|
|
80
|
+
const base = baseDomainFromHost(host, subdomains as LocaleSubdomains).replace(/:\d+$/, '');
|
|
81
|
+
if (base === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(base)) return undefined;
|
|
82
|
+
|
|
83
|
+
return `.${base}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
/** Код локали по фактическому хосту (по языковому поддомену), иначе дефолтная. */
|
|
67
87
|
export function localeFromHost(host: string, subdomains: LocaleSubdomains, defaultCode: string): string {
|
|
68
88
|
const firstLabel = cleanHost(host).split('.')[0];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Глобальный auth-middleware: при наличии jwtoken-куки подтягивает профиль в
|
|
2
2
|
// useState('profileData') один раз за сессию (если ещё не загружен). На 400/401
|
|
3
3
|
// считает токен невалидным и чистит куку. Работает и на SSR, и на клиенте.
|
|
4
|
+
import { useAuthCookie } from '../composables/use-auth-cookie';
|
|
5
|
+
|
|
4
6
|
export default defineNuxtRouteMiddleware(async () => {
|
|
5
|
-
const
|
|
7
|
+
const { cookie, clearToken } = useAuthCookie();
|
|
6
8
|
if (!cookie.value) return;
|
|
7
9
|
|
|
8
10
|
const profileData = useState<any>('profileData', () => null);
|
|
@@ -23,11 +25,7 @@ export default defineNuxtRouteMiddleware(async () => {
|
|
|
23
25
|
profileData.value = await fetchProfile();
|
|
24
26
|
} catch (error: any) {
|
|
25
27
|
if (error?.statusCode === 400 || error?.statusCode === 401) {
|
|
26
|
-
|
|
27
|
-
if (import.meta.client) {
|
|
28
|
-
// Nuxt-кука не всегда удаляется на клиенте с нужным domain — дочищаем вручную
|
|
29
|
-
document.cookie = `jwtoken=; path=/; domain=${runtimeConfig.public.xDomain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`;
|
|
30
|
-
}
|
|
28
|
+
clearToken();
|
|
31
29
|
} else {
|
|
32
30
|
console.error('Error get profile:', error);
|
|
33
31
|
}
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// сюда не кладётся. Полный список стран лениво догружает селектор языка на клиенте.
|
|
7
7
|
import { getCountryPlace } from '../lib/countries';
|
|
8
8
|
import { getPathLocale } from '../runtime/utils/server/get-path-locale';
|
|
9
|
+
import { hasLocaleSubdomains, localeFromHost, type LocaleSubdomains } from '../config/locale-domains';
|
|
9
10
|
|
|
10
11
|
/** Опции легаси-кук — нужны только чтобы их погасить (см. ниже) */
|
|
11
12
|
const LEGACY_COOKIE_OPTS = { path: '/', secure: true, sameSite: 'lax' as const };
|
|
@@ -26,7 +27,14 @@ export default defineNuxtPlugin(async () => {
|
|
|
26
27
|
|
|
27
28
|
if (!code) return;
|
|
28
29
|
|
|
29
|
-
const { codes, default: defaultCode } = useRuntimeConfig().public.locales as {
|
|
30
|
+
const { codes, default: defaultCode, subdomains } = useRuntimeConfig().public.locales as {
|
|
31
|
+
codes: string[], default: string, subdomains?: LocaleSubdomains
|
|
32
|
+
};
|
|
33
|
+
// Локаль i18n тут ещё не готова: у локалей на поддоменах она в хосте, у префиксных — в пути
|
|
34
|
+
const host = event?.node.req.headers['x-forwarded-host'] as string || event?.node.req.headers.host || '';
|
|
35
|
+
const locale = hasLocaleSubdomains(subdomains)
|
|
36
|
+
? localeFromHost(host, subdomains as LocaleSubdomains, defaultCode)
|
|
37
|
+
: getPathLocale(event?.path || '', codes, defaultCode);
|
|
30
38
|
|
|
31
|
-
countryPlace.value = await getCountryPlace(code,
|
|
39
|
+
countryPlace.value = await getCountryPlace(code, locale);
|
|
32
40
|
});
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
// Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
|
|
2
2
|
// сначала из cookie `clientCountryCode` (ручной выбор в селекторе), иначе из
|
|
3
3
|
// заголовков прокси (Cloudflare `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
4
|
+
// Выбор, перенесённый переключателем языка в query (там, где общей cookie между языковыми
|
|
5
|
+
// поддоменами нет), кладётся в cookie этого хоста, а query убирается редиректом.
|
|
6
|
+
import { COUNTRY_CARRY_PARAM } from '../../config/locale-domains';
|
|
7
|
+
|
|
4
8
|
export default defineEventHandler((event) => {
|
|
9
|
+
const carried = getQuery(event)[COUNTRY_CARRY_PARAM];
|
|
10
|
+
if (event.method === 'GET' && typeof carried === 'string' && /^[a-z]{2}$/i.test(carried)) {
|
|
11
|
+
setCookie(event, 'clientCountryCode', carried.toLowerCase(), { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' });
|
|
12
|
+
const url = getRequestURL(event);
|
|
13
|
+
url.searchParams.delete(COUNTRY_CARRY_PARAM);
|
|
14
|
+
return sendRedirect(event, `${url.pathname}${url.search}`, 302);
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
const cookie = getCookie(event, 'clientCountryCode');
|
|
6
18
|
if (cookie) {
|
|
7
19
|
event.context.clientCountryCode = cookie.trim().toLowerCase();
|
|
@@ -44,6 +44,14 @@ describe('resolveLocaleRedirect', () => {
|
|
|
44
44
|
expect(resolveLocaleRedirect({ ...base, method: 'POST' }).location).toBeUndefined();
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it('переход с самого сайта — выбор языка, без редиректа', () => {
|
|
48
|
+
const withReferer = (referer: string) => resolveLocaleRedirect({ ...base, headers: { ...base.headers, referer } });
|
|
49
|
+
expect(withReferer('https://jp.a.com/models')).toEqual({ vary: true });
|
|
50
|
+
expect(withReferer('https://a.com/')).toEqual({ vary: true });
|
|
51
|
+
expect(withReferer('https://google.com/search?q=a')).toEqual({ vary: true, location: 'https://jp.a.com/videos?page=2' });
|
|
52
|
+
expect(withReferer('not a url')).toEqual({ vary: true, location: 'https://jp.a.com/videos?page=2' });
|
|
53
|
+
});
|
|
54
|
+
|
|
47
55
|
it('неизвестное значение cookie — как отсутствие выбора', () => {
|
|
48
56
|
expect(resolveLocaleRedirect({ ...base, langCookie: 'xx' }).location).toBe('https://jp.a.com/videos?page=2');
|
|
49
57
|
});
|
|
@@ -33,6 +33,17 @@ function isKnownLangChoice(value: string, subdomains: LocaleSubdomains, codes: r
|
|
|
33
33
|
return !sub || Object.values(subdomains).includes(sub) || codes.includes(sub);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Пришёл ли запрос со страницы этого же сайта: базовый домен Referer совпадает с текущим. */
|
|
37
|
+
function isSameSiteReferer(referer: string | undefined, host: string, subdomains: LocaleSubdomains): boolean {
|
|
38
|
+
if (!referer) return false;
|
|
39
|
+
try {
|
|
40
|
+
return baseDomainFromHost(new URL(referer).host, subdomains) === baseDomainFromHost(host, subdomains);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export interface ILocaleRedirectInput {
|
|
37
48
|
method: string
|
|
38
49
|
path: string
|
|
@@ -54,7 +65,8 @@ export interface ILocaleRedirectDecision {
|
|
|
54
65
|
*
|
|
55
66
|
* Не уводит: не-GET, служебные пути и не-html, ботов (иначе поисковик индексирует не то,
|
|
56
67
|
* что объявлено в canonical/hreflang), заход уже на языковой поддомен, явный выбор в cookie
|
|
57
|
-
* `lang
|
|
68
|
+
* `lang`, переход с самого сайта (Referer на том же базовом домене) — это выбор языка
|
|
69
|
+
* переключателем, а cookie могла не доехать: на localhost она живёт только на своём поддомене.
|
|
58
70
|
*/
|
|
59
71
|
export function resolveLocaleRedirect(input: ILocaleRedirectInput): ILocaleRedirectDecision {
|
|
60
72
|
const { method, path, headers, langCookie, subdomains, codes, defaultCode } = input;
|
|
@@ -74,6 +86,8 @@ export function resolveLocaleRedirect(input: ILocaleRedirectInput): ILocaleRedir
|
|
|
74
86
|
|
|
75
87
|
if (langCookie !== undefined && isKnownLangChoice(langCookie, subdomains, codes)) return skip;
|
|
76
88
|
|
|
89
|
+
if (isSameSiteReferer(headers['referer'], host, subdomains)) return { vary: true };
|
|
90
|
+
|
|
77
91
|
const acceptLanguage = headers['accept-language'] || '';
|
|
78
92
|
if (!acceptLanguage) return skip;
|
|
79
93
|
|