Skip to content
This repository was archived by the owner on Dec 30, 2022. It is now read-only.

Commit 9d4b913

Browse files
authored
language redirects without cache (github#25872)
* redirect to your preferred language (github#25664) * redirect to your preferred language * refactorings * use js-cookies * make sure no cache when language is involved in the redirect * fix tests
1 parent 4e628d6 commit 9d4b913

File tree

7 files changed

+167
-46
lines changed

7 files changed

+167
-46
lines changed

components/page-header/LanguagePicker.tsx

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { useRouter } from 'next/router'
2+
import Cookies from 'js-cookie'
3+
24
import { Link } from 'components/Link'
35
import { useLanguages } from 'components/context/LanguagesContext'
46
import { Picker } from 'components/ui/Picker'
57
import { useTranslation } from 'components/hooks/useTranslation'
68

9+
// This value is replicated in two places! See middleware/detect-language.js
10+
const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'
11+
712
type Props = {
813
variant?: 'inline'
914
}
@@ -22,6 +27,22 @@ export const LanguagePicker = ({ variant }: Props) => {
2227
// in a "denormalized" way.
2328
const routerPath = router.asPath.split('#')[0]
2429

30+
function rememberPreferredLanguage(code: string) {
31+
try {
32+
Cookies.set(PREFERRED_LOCALE_COOKIE_NAME, code, {
33+
expires: 365,
34+
secure: document.location.protocol !== 'http:',
35+
})
36+
} catch (err) {
37+
// You can never be too careful because setting a cookie
38+
// can fail. For example, some browser
39+
// extensions disallow all setting of cookies and attempts
40+
// at the `document.cookie` setter could throw. Just swallow
41+
// and move on.
42+
console.warn('Unable to set preferred language cookie', err)
43+
}
44+
}
45+
2546
return (
2647
<Picker
2748
variant={variant}
@@ -33,7 +54,13 @@ export const LanguagePicker = ({ variant }: Props) => {
3354
text: lang.nativeName || lang.name,
3455
selected: lang === selectedLang,
3556
item: (
36-
<Link href={routerPath} locale={lang.code}>
57+
<Link
58+
href={routerPath}
59+
locale={lang.code}
60+
onClick={() => {
61+
rememberPreferredLanguage(lang.code)
62+
}}
63+
>
3764
{lang.nativeName ? (
3865
<>
3966
<span lang={lang.code}>{lang.nativeName}</span> (

lib/get-redirect.js

+3-8
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const nonEnterpriseDefaultVersionPrefix = `/${nonEnterpriseDefaultVersion}`
99

1010
// Return the new URI if there is one, otherwise return undefined.
1111
export default function getRedirect(uri, context) {
12-
const { redirects, pages } = context
12+
const { redirects, userLanguage } = context
1313

14-
let language = 'en'
14+
let language = userLanguage || 'en'
1515
let withoutLanguage = uri
1616
if (languagePrefixRegex.test(uri)) {
1717
language = uri.match(languagePrefixRegex)[1]
@@ -109,12 +109,7 @@ export default function getRedirect(uri, context) {
109109
}
110110

111111
if (basicCorrection) {
112-
return (
113-
getRedirect(basicCorrection, {
114-
redirects,
115-
pages,
116-
}) || basicCorrection
117-
)
112+
return getRedirect(basicCorrection, context) || basicCorrection
118113
}
119114

120115
if (withoutLanguage.startsWith('/admin/')) {

middleware/detect-language.js

+21-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import libLanguages from '../lib/languages.js'
1+
import languages, { languageKeys } from '../lib/languages.js'
22
import parser from 'accept-language-parser'
3-
const languageCodes = Object.keys(libLanguages)
43

54
const chineseRegions = ['CN', 'HK']
65

6+
// This value is replicated in two places! See <LanguagePicker/> component.
7+
// Note, the only reason this is exported is to benefit the tests.
8+
export const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'
9+
710
function translationExists(language) {
811
if (language.code === 'zh') {
912
return chineseRegions.includes(language.region)
1013
}
11-
return languageCodes.includes(language.code)
14+
return languageKeys.includes(language.code)
1215
}
1316

1417
function getLanguageCode(language) {
@@ -17,33 +20,41 @@ function getLanguageCode(language) {
1720

1821
function getUserLanguage(browserLanguages) {
1922
try {
20-
let userLanguage = getLanguageCode(browserLanguages[0])
2123
let numTopPreferences = 1
2224
for (let lang = 0; lang < browserLanguages.length; lang++) {
2325
// If language has multiple regions, Chrome adds the non-region language to list
2426
if (lang > 0 && browserLanguages[lang].code !== browserLanguages[lang - 1].code)
2527
numTopPreferences++
2628
if (translationExists(browserLanguages[lang]) && numTopPreferences < 3) {
27-
userLanguage = getLanguageCode(browserLanguages[lang])
28-
break
29+
return getLanguageCode(browserLanguages[lang])
2930
}
3031
}
31-
return userLanguage
3232
} catch {
3333
return undefined
3434
}
3535
}
3636

37+
function getUserLanguageFromCookie(req) {
38+
const value = req.cookies[PREFERRED_LOCALE_COOKIE_NAME]
39+
// But if it's a WIP language, reject it.
40+
if (value && languages[value] && !languages[value].wip) {
41+
return value
42+
}
43+
}
44+
3745
// determine language code from a path. Default to en if no valid match
3846
export function getLanguageCodeFromPath(path) {
3947
const maybeLanguage = (path.split('/')[path.startsWith('/_next/data/') ? 4 : 1] || '').slice(0, 2)
40-
return languageCodes.includes(maybeLanguage) ? maybeLanguage : 'en'
48+
return languageKeys.includes(maybeLanguage) ? maybeLanguage : 'en'
4149
}
4250

4351
export default function detectLanguage(req, res, next) {
4452
req.language = getLanguageCodeFromPath(req.path)
4553
// Detecting browser language by user preference
46-
const browserLanguages = parser.parse(req.headers['accept-language'])
47-
req.userLanguage = getUserLanguage(browserLanguages)
54+
req.userLanguage = getUserLanguageFromCookie(req)
55+
if (!req.userLanguage) {
56+
const browserLanguages = parser.parse(req.headers['accept-language'])
57+
req.userLanguage = getUserLanguage(browserLanguages)
58+
}
4859
return next()
4960
}

middleware/redirects/handle-redirects.js

+14-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import patterns from '../../lib/patterns.js'
22
import { URL } from 'url'
3-
import languages, { pathLanguagePrefixed } from '../../lib/languages.js'
3+
import { pathLanguagePrefixed } from '../../lib/languages.js'
44
import getRedirect from '../../lib/get-redirect.js'
55
import { cacheControlFactory } from '../cache-control.js'
66

@@ -13,16 +13,7 @@ export default function handleRedirects(req, res, next) {
1313

1414
// blanket redirects for languageless homepage
1515
if (req.path === '/') {
16-
let language = 'en'
17-
18-
// if set, redirect to user's preferred language translation or else English
19-
if (
20-
req.context.userLanguage &&
21-
languages[req.context.userLanguage] &&
22-
!languages[req.context.userLanguage].wip
23-
) {
24-
language = req.context.userLanguage
25-
}
16+
const language = getLanguage(req)
2617

2718
// Undo the cookie setting that CSRF sets.
2819
res.removeHeader('set-cookie')
@@ -70,17 +61,12 @@ export default function handleRedirects(req, res, next) {
7061
// needs to become `/en/authentication/connecting-to-github-with-ssh`
7162
const possibleRedirectTo = `/en${req.path}`
7263
if (possibleRedirectTo in req.context.pages) {
73-
// As of Jan 2022 we always redirect to `/en` if the URL doesn't
74-
// specify a language. ...except for the root home page (`/`).
75-
// It's unfortunate but that's how it currently works.
76-
// It's tracked in #1145
77-
// Perhaps a more ideal solution would be to do something similar to
78-
// the code above for `req.path === '/'` where we look at the user
79-
// agent for a header and/or cookie.
64+
const language = getLanguage(req)
65+
8066
// Note, it's important to use `req.url` here and not `req.path`
8167
// because the full URL can contain query strings.
8268
// E.g. `/foo?json=breadcrumbs`
83-
redirect = `/en${req.url}`
69+
redirect = `/${language}${req.url}`
8470
}
8571
}
8672

@@ -106,12 +92,21 @@ export default function handleRedirects(req, res, next) {
10692
// do the redirect if the from-URL already had a language in it
10793
if (pathLanguagePrefixed(req.path)) {
10894
cacheControl(res)
95+
} else {
96+
noCacheControl(res)
10997
}
11098

11199
const permanent = usePermanentRedirect(req)
112100
return res.redirect(permanent ? 301 : 302, redirect)
113101
}
114102

103+
function getLanguage(req, default_ = 'en') {
104+
// req.context.userLanguage, if it truthy, is always a valid supported
105+
// language. It's whatever was in the user's request but filtered
106+
// based on non-WIP languages in lib/languages.js
107+
return req.context.userLanguage || default_
108+
}
109+
115110
function usePermanentRedirect(req) {
116111
// If the redirect was to essentially swap `enterprise-server@latest`
117112
// for `[email protected]` then, we definitely don't want to

tests/rendering/server.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ describe('server', () => {
632632
expect(res.statusCode).toBe(302)
633633
expect(res.headers['set-cookie']).toBeUndefined()
634634
// no cache control because a language prefix had to be injected
635-
expect(res.headers['cache-control']).toBeUndefined()
635+
expect(res.headers['cache-control']).toBe('private, no-store')
636636
})
637637

638638
test('redirects old articles to their slugified URL', async () => {
@@ -702,7 +702,8 @@ describe('server', () => {
702702
expect(res.statusCode).toBe(302)
703703
expect(res.headers.location.startsWith('/en/')).toBe(true)
704704
expect(res.headers['set-cookie']).toBeUndefined()
705-
expect(res.headers['cache-control']).toBeUndefined()
705+
// no cache control because a language prefix had to be injected
706+
expect(res.headers['cache-control']).toBe('private, no-store')
706707
})
707708

708709
test('redirects that not only injects /en/ should have cache-control', async () => {

tests/routing/redirects.js

+84-6
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { fileURLToPath } from 'url'
22
import path from 'path'
33
import { isPlainObject } from 'lodash-es'
44
import supertest from 'supertest'
5+
import { jest } from '@jest/globals'
6+
57
import createApp from '../../lib/app.js'
68
import enterpriseServerReleases from '../../lib/enterprise-server-releases.js'
79
import Page from '../../lib/page.js'
810
import { get } from '../helpers/supertest.js'
911
import versionSatisfiesRange from '../../lib/version-satisfies-range.js'
10-
import { jest } from '@jest/globals'
12+
import { PREFERRED_LOCALE_COOKIE_NAME } from '../../middleware/detect-language.js'
1113

1214
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1315

@@ -132,6 +134,28 @@ describe('redirects', () => {
132134
expect(res.headers.location).toBe('/ja')
133135
expect(res.headers['cache-control']).toBe('private, no-store')
134136
})
137+
test('homepage redirects to preferred language by cookie', async () => {
138+
const res = await get('/', {
139+
headers: {
140+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
141+
'Accept-Language': 'es', // note how this is going to be ignored
142+
},
143+
})
144+
expect(res.statusCode).toBe(302)
145+
expect(res.headers.location).toBe('/ja')
146+
expect(res.headers['cache-control']).toBe('private, no-store')
147+
})
148+
test('homepage redirects to preferred language by cookie if valid', async () => {
149+
const res = await get('/', {
150+
headers: {
151+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=xy`,
152+
'Accept-Language': 'ja', // note how this is going to be ignored
153+
},
154+
})
155+
expect(res.statusCode).toBe(302)
156+
expect(res.headers.location).toBe('/ja')
157+
expect(res.headers['cache-control']).toBe('private, no-store')
158+
})
135159
})
136160

137161
describe('external redirects', () => {
@@ -149,14 +173,68 @@ describe('redirects', () => {
149173
})
150174

151175
describe('localized redirects', () => {
176+
const redirectFrom =
177+
'/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
178+
const redirectTo =
179+
'/desktop/contributing-and-collaborating-using-github-desktop/working-with-your-remote-repository-on-github-or-github-enterprise/changing-a-remotes-url-from-github-desktop'
180+
152181
test('redirect_from for renamed pages', async () => {
153-
const { res } = await get(
154-
'/ja/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
155-
)
182+
const { res } = await get(`/ja${redirectFrom}`)
156183
expect(res.statusCode).toBe(301)
157-
const expected =
158-
'/ja/desktop/contributing-and-collaborating-using-github-desktop/working-with-your-remote-repository-on-github-or-github-enterprise/changing-a-remotes-url-from-github-desktop'
184+
const expected = `/ja${redirectTo}`
185+
expect(res.headers.location).toBe(expected)
186+
})
187+
188+
test('redirect_from for renamed pages by Accept-Language header', async () => {
189+
const { res } = await get(redirectFrom, {
190+
headers: {
191+
'Accept-Language': 'ja',
192+
},
193+
})
194+
expect(res.statusCode).toBe(302)
195+
const expected = `/ja${redirectTo}`
159196
expect(res.headers.location).toBe(expected)
197+
expect(res.headers['cache-control']).toBe('private, no-store')
198+
})
199+
200+
test('redirect_from for renamed pages but ignore Accept-Language header if not recognized', async () => {
201+
const { res } = await get(redirectFrom, {
202+
headers: {
203+
// None of these are recognized
204+
'Accept-Language': 'sv,fr,gr',
205+
},
206+
})
207+
expect(res.statusCode).toBe(302)
208+
const expected = `/en${redirectTo}`
209+
expect(res.headers.location).toBe(expected)
210+
expect(res.headers['cache-control']).toBe('private, no-store')
211+
})
212+
213+
test('redirect_from for renamed pages but ignore unrecognized Accept-Language header values', async () => {
214+
const { res } = await get(redirectFrom, {
215+
headers: {
216+
// Only the last one is recognized
217+
'Accept-Language': 'sv,ja',
218+
},
219+
})
220+
expect(res.statusCode).toBe(302)
221+
const expected = `/ja${redirectTo}`
222+
expect(res.headers.location).toBe(expected)
223+
expect(res.headers['cache-control']).toBe('private, no-store')
224+
})
225+
226+
test('will inject the preferred language from cookie', async () => {
227+
const { res } = await get(redirectFrom, {
228+
headers: {
229+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
230+
'Accept-Language': 'es', // note how this is going to be ignored
231+
},
232+
})
233+
// 302 because the redirect depended on cookie
234+
expect(res.statusCode).toBe(302)
235+
const expected = `/ja${redirectTo}`
236+
expect(res.headers.location).toBe(expected)
237+
expect(res.headers['cache-control']).toBe('private, no-store')
160238
})
161239
})
162240

tests/unit/get-redirect.js

+14
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,18 @@ describe('getRedirect basics', () => {
148148
// it already has the enterprise-server prefix.
149149
expect(getRedirect('/enterprise-server/foo', ctx)).toBe(`/en/enterprise-server@${latest}/bar`)
150150
})
151+
152+
it('should redirect according to the req.context.userLanguage', () => {
153+
const ctx = {
154+
pages: {},
155+
redirects: {
156+
'/foo': '/bar',
157+
},
158+
userLanguage: 'ja',
159+
}
160+
expect(getRedirect('/foo', ctx)).toBe(`/ja/bar`)
161+
// falls back to 'en' if it's falsy
162+
ctx.userLanguage = null
163+
expect(getRedirect('/foo', ctx)).toBe(`/en/bar`)
164+
})
151165
})

0 commit comments

Comments
 (0)