itube-specs 0.0.1007 → 0.0.1009
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 +21 -0
- package/components/layout/header.vue +24 -1
- package/components/layout/language.vue +2 -1
- package/components/layout/logo.vue +5 -4
- 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
package/SITE_UPDATES.md
CHANGED
|
@@ -10,6 +10,27 @@
|
|
|
10
10
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
11
11
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
12
12
|
|
|
13
|
+
## 0.0.1008 — 2026-10-07
|
|
14
|
+
|
|
15
|
+
- **Сайтам с несколькими нишами (в `SiteConfig.niches` больше одной), где нет нишевых переводов** — проверить и
|
|
16
|
+
доделать всё по `NEW_SITE_CHECKLIST.md` §3a «Несколько ниш», чего у сайта ещё нет:
|
|
17
|
+
- `config/niches.config.ts` с `NICHE_SLUGS`, `NICHE_CONTENT_ROOTS` и `NicheConfig` (`nicheSlugs`,
|
|
18
|
+
`nicheFreeRoots`, `nicheAdjectives`, `nicheTextKeys`), разложить `...NicheConfig` в `app.config.ts`;
|
|
19
|
+
- `addNicheAliases` в хуке `pages:extend` в `nuxt.config.ts`;
|
|
20
|
+
- `postTranslation: nichePostTranslation` в `i18n/i18n.config.ts`
|
|
21
|
+
(`import { nichePostTranslation } from 'itube-specs/utils/niche-post-translation'`);
|
|
22
|
+
- `v-bind="useIslandNicheProps()"` (через константу в `<script setup>`) на своих `<SectionCategoriesIsland>`.
|
|
23
|
+
|
|
24
|
+
`nicheAdjectives` заполнить для **всех** локалей сайта: без записи вместо слова в `%niche%` подставится код
|
|
25
|
+
ниши (`gay`). Тексты под ниши — ключи `<key>_<niche>` для ключей из `nicheTextKeys` и
|
|
26
|
+
`meta.<page>.<sort>.<key>_<niche>` для меты; это контент, их не выдумывать — вынести в TODO сайта. Не сделать —
|
|
27
|
+
`/gays`, `/shemales` отдают SEO-тексты дефолтной ниши, слово ниши в переводах не подставляется. Сайтам с
|
|
28
|
+
одной нишей — ничего.
|
|
29
|
+
|
|
30
|
+
- **Сайтам, которым нужен свой размер лого в шапке (videosection):** в `config/site.config.ts` добавить
|
|
31
|
+
`logoSize: { width, height }` — размер в px на всех брейкпоинтах. Не задан — остаётся прежний адаптив
|
|
32
|
+
(120×20, с `sm` 144×24); футер свой размер держит сам. Ничего не делать, если текущий размер устраивает.
|
|
33
|
+
|
|
13
34
|
## 0.0.1007 — 2026-10-06
|
|
14
35
|
|
|
15
36
|
- **Сайтам, где `npm run dev` подключает локальный слой (`LAYER_PATH` в `.env`):** заменить скрипт `predev` в
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<header
|
|
3
|
-
:class="{'--burger-open': burgerShow}"
|
|
3
|
+
:class="{'--burger-open': burgerShow, '--no-notifications': !featureFlags.FeatureFollowEnabled}"
|
|
4
4
|
class="layout-header"
|
|
5
5
|
>
|
|
6
6
|
|
|
@@ -137,6 +137,29 @@ function closeLanguage() {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
.layout-header.--no-notifications {
|
|
141
|
+
@include from-br($brkpnt-burger) {
|
|
142
|
+
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@include to-br($brkpnt-burger) {
|
|
146
|
+
grid-template-columns: auto 1fr auto;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.layout-header.--no-notifications .layout-header__language {
|
|
151
|
+
@include from-br($brkpnt-burger) {
|
|
152
|
+
grid-column: 4;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.layout-header.--no-notifications .layout-header__user,
|
|
157
|
+
.layout-header.--no-notifications .layout-header__sign-up {
|
|
158
|
+
@include from-br($brkpnt-burger) {
|
|
159
|
+
grid-column: 5;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
140
163
|
.layout-header__logo {
|
|
141
164
|
@include from-br($brkpnt-burger) {
|
|
142
165
|
grid-column: 1;
|
|
@@ -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) {
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
>
|
|
13
13
|
<img
|
|
14
14
|
:alt="brandName"
|
|
15
|
+
:class="{'--adaptive': !logoSize}"
|
|
16
|
+
:width="logoSize?.width ?? 120"
|
|
17
|
+
:height="logoSize?.height ?? 20"
|
|
15
18
|
class="layout-logo__image"
|
|
16
19
|
src="/img/logo.svg"
|
|
17
|
-
width="120"
|
|
18
|
-
height="20"
|
|
19
20
|
>
|
|
20
21
|
</NuxtLink>
|
|
21
22
|
</template>
|
|
@@ -29,7 +30,7 @@ const emit = defineEmits<{
|
|
|
29
30
|
}>();
|
|
30
31
|
|
|
31
32
|
const { generateLink } = useGenerateLink();
|
|
32
|
-
const { brandName } = useAppConfig();
|
|
33
|
+
const { brandName, logoSize } = useAppConfig();
|
|
33
34
|
|
|
34
35
|
function onLogoClick() {
|
|
35
36
|
emit('click');
|
|
@@ -45,7 +46,7 @@ function onLogoClick() {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
.layout-logo__image {
|
|
49
|
+
.layout-logo__image.--adaptive {
|
|
49
50
|
@include from-br(sm) {
|
|
50
51
|
width: 144px;
|
|
51
52
|
height: 24px;
|
|
@@ -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
|
|