Repository navigation
Expand file tree
/
Copy patharchived-enterprise-versions.ts
More file actions
576 lines (518 loc) · 22.9 KB
/
Copy patharchived-enterprise-versions.ts
File metadata and controls
576 lines (518 loc) · 22.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
import type { Response, NextFunction } from 'express'
import { fetchWithRetry } from '@/frame/lib/fetch-utils'
import statsd, { adaptForTimer } from '@/observability/lib/statsd'
import { createLogger } from '@/observability/logger'
import {
firstVersionDeprecatedOnNewSite,
lastVersionWithoutArchivedRedirectsFile,
deprecatedWithFunctionalRedirects,
firstReleaseStoredInBlobStorage,
} from '@/versions/lib/enterprise-server-releases'
import patterns from '@/frame/lib/patterns'
import versionSatisfiesRange from '@/versions/lib/version-satisfies-range'
import { isArchivedVersion } from '@/archives/lib/is-archived-version'
import { setFastlySurrogateKey, SURROGATE_ENUMS } from '@/frame/middleware/set-fastly-surrogate-key'
import { readCompressedJsonFileFallbackLazily } from '@/frame/lib/read-json-file'
import { archivedCacheControl, languageCacheControl } from '@/frame/middleware/cache-control'
import { pathLanguagePrefixed, languagePrefixPathRegex } from '@/languages/lib/languages-server'
import { languages as allLanguages } from '@/languages/lib/languages'
import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect'
import getRemoteJSON from '@/frame/lib/get-remote-json'
import { ExtendedRequest } from '@/types'
const logger = createLogger(import.meta.url)
const OLD_PUBLIC_AZURE_BLOB_URL = 'https://githubdocs.azureedge.net'
// Old Azure Blob Storage `enterprise` container.
const OLD_AZURE_BLOB_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/enterprise`
// Old Azure Blob storage `github-images` container with
// the root directory of 'enterprise'.
const OLD_GITHUB_IMAGES_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/github-images/enterprise`
const OLD_DEVELOPER_SITE_CONTAINER = `${OLD_PUBLIC_AZURE_BLOB_URL}/developer-site`
// This is the new repo naming convention we use for each archived enterprise
// version. E.g. https://github.github.com/docs-ghes-2.10
const ENTERPRISE_GH_PAGES_URL_PREFIX = 'https://github.github.com/docs-ghes-'
type ArchivedRedirects = {
[url: string]: string | null
}
// These files are huge so lazy-load them. But note that the
// `readJsonFileLazily()` function will, at import-time, check that
// the path does exist.
const archivedRedirects = readCompressedJsonFileFallbackLazily(
'./src/redirects/lib/static/archived-redirects-from-213-to-217.json',
) as () => ArchivedRedirects
type ArchivedFrontmatterURLs = {
[url: string]: string[]
}
const archivedFrontmatterValidURLS = readCompressedJsonFileFallbackLazily(
'./src/redirects/lib/static/archived-frontmatter-valid-urls.json',
) as () => ArchivedFrontmatterURLs
// Combine all the things you need to make sure the response is
// aggressively cached.
const cacheAggressively = (res: Response) => {
archivedCacheControl(res)
// This sets a custom Fastly surrogate key so that this response
// won't get updated in every deployment.
// Essentially, this sets a surrogate key such that Fastly
// doesn't do soft-purges on these responses on every
// automated deployment.
setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL)
}
// The way `got` does retries:
//
// sleep = 1000 * Math.pow(2, retry - 1) + Math.random() * 100
//
// So, it means:
//
// 1. ~1000ms
// 2. ~2000ms
// 3. ~4000ms
//
// ...if the limit we set is 3.
// Our own timeout, in @/frame/middleware/timeout.ts defaults to 10 seconds.
// So there's no point in trying more attempts than 3 because it would
// just timeout on the 10s. (i.e. 1000 + 2000 + 4000 + 8000 > 10,000)
const retryConfiguration = { limit: 3 }
// According to our Datadog metrics, the *average* time for the
// the 'archive_enterprise_proxy' metric is ~70ms (excluding spikes)
// which is much less than 3000ms.
// We have observed errors of timeout, in production, when it was
// set to 500ms and then 1500ms. Let's be more conservative here to
// avoid unnecessary error reporting during occasional slow responses.
const timeoutConfiguration = { response: 3000 }
// Monitoring thresholds for logging response times
// Log warnings when responses exceed half the timeout threshold
const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 // 1500ms
// Log info for responses that are noticeably slow but not concerning
const SLOW_RESPONSE_THRESHOLD = 500 // ms
// This module handles requests for deprecated GitHub Enterprise versions
// by routing them to static content in
// one of the docs-ghes-<release number> repos.
export default async function archivedEnterpriseVersions(
req: ExtendedRequest,
res: Response,
next: NextFunction,
) {
const { isArchived, requestedVersion } = isArchivedVersion(req)
if (!isArchived || !requestedVersion) return next()
// Skip asset paths
if (patterns.assetPaths.test(req.path)) return next()
const redirectCode = pathLanguagePrefixed(req.path) ? 301 : 302
// Redirects for releases 3.0+
if (deprecatedWithFunctionalRedirects.includes(requestedVersion)) {
const redirectTo = req.context ? getRedirect(req.path, req.context) : undefined
if (redirectTo) {
if (redirectCode === 302) {
languageCacheControl(res) // call first to get `vary`
}
archivedCacheControl(res) // call second to extend duration
return res.safeRedirect(redirectCode, redirectTo)
}
let redirectJson: Record<string, string>
try {
redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
retry: retryConfiguration,
// This is allowed to be different compared to the other requests
// we make because downloading the `redirects.json` once is very
// useful because it caches so well.
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
// to time out.
timeout: { response: 1000 },
})) as Record<string, string>
} catch (err) {
logger.error('Failed to fetch archived redirects.json', {
version: requestedVersion,
error: err instanceof Error ? err : new Error(String(err)),
})
throw err
}
if (!req.context) throw new Error('No context on request')
const [language, withoutLanguage] = splitPathByLanguage(req.path, req.context.userLanguage)
const newRedirectTo = redirectJson[withoutLanguage]
if (newRedirectTo && newRedirectTo !== withoutLanguage) {
if (redirectCode === 302) {
languageCacheControl(res) // call first to get `vary`
}
archivedCacheControl(res) // call second to extend duration
return res.safeRedirect(redirectCode, `/${language}${newRedirectTo}`)
}
}
// For releases 2.13 and lower, redirect language-prefixed URLs like /en/enterprise/2.10 -> /enterprise/2.10
if (
req.path.startsWith('/en/') &&
versionSatisfiesRange(requestedVersion, `<${firstVersionDeprecatedOnNewSite}`)
) {
archivedCacheControl(res)
return res.safeRedirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, ''))
}
// Redirects for releases 2.13 - 2.17
if (
versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) &&
versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`)
) {
const [language, withoutLanguagePath] = splitByLanguage(req.path)
// `archivedRedirects` is a callable because it's a lazy function
// and memoized so calling it is cheap.
const newPath = withoutLanguagePath && archivedRedirects()[withoutLanguagePath]
// Some entries in the lookup exists purely for the sake of injecting
// language.
// E.g. '/enterprise/2.15/user'
// URLs like this only need to redirect the original `req.path`
// didn't already have a language
if (newPath !== undefined && (newPath || !language)) {
// Construct the new URL by combining the new language and the
// new destination.
const redirect = `/${language || 'en'}${newPath || withoutLanguagePath}`
cacheAggressively(res)
return res.safeRedirect(redirectCode, redirect)
}
}
// Redirects for 2.18 - 3.0. Starting with 2.18, we updated the archival
// script to create a redirects.json file
if (
versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) &&
!deprecatedWithFunctionalRedirects.includes(requestedVersion)
) {
let redirectJson: Record<string, string>
try {
redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
retry: retryConfiguration,
// This is allowed to be different compared to the other requests
// we make because downloading the `redirects.json` once is very
// useful because it caches so well.
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
// to time out.
timeout: { response: 1000 },
})) as Record<string, string>
} catch (err) {
logger.error('Failed to fetch archived redirects.json', {
version: requestedVersion,
error: err instanceof Error ? err : new Error(String(err)),
})
throw err
}
// make redirects found via redirects.json redirect with a 301
if (redirectJson[req.path]) {
res.set('x-robots-tag', 'noindex')
cacheAggressively(res)
return res.safeRedirect(redirectCode, redirectJson[req.path])
}
}
// Short-circuit requests that will never resolve on the upstream
// GitHub Pages repos, avoiding unnecessary network requests.
const earlyNotFound = getEarlyNotFoundReason(req.path, requestedVersion)
if (earlyNotFound) {
statsd.increment('middleware.archived_early_not_found', 1, [
`reason:${earlyNotFound}`,
`version:${requestedVersion}`,
])
cacheAggressively(res)
return res.status(404).type('text').send('Page not found')
}
// Requests without a language prefix for versions > 2.17 will always
// 404 upstream (the archive repos store pages under /en/, /zh/, etc.).
// Skip the fetch and let downstream middleware handle the redirect.
if (
versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) &&
!pathLanguagePrefixed(req.path)
) {
statsd.increment('middleware.archived_skip_no_language', 1, [`version:${requestedVersion}`])
return next()
}
// Retrieve the page from the archived repo
const doGet = () =>
fetchWithRetry(
getProxyPath(req.path, requestedVersion),
{},
{
retries: retryConfiguration.limit,
timeout: timeoutConfiguration.response,
throwHttpErrors: false,
},
)
const statsdTags = [`version:${requestedVersion}`]
const startTime = Date.now()
const r = await statsd.asyncTimer(adaptForTimer(doGet), 'archive_enterprise_proxy', [
...statsdTags,
`path:${req.path}`,
])()
const responseTime = Date.now() - startTime
// Log warnings for slow responses to help identify degraded performance
// A response time over half the timeout indicates potential issues
if (responseTime > WARN_RESPONSE_THRESHOLD) {
logger.warn('Slow response from archived enterprise content', {
version: requestedVersion,
path: req.path,
responseTime: `${responseTime}ms`,
status: r.status,
threshold: `${WARN_RESPONSE_THRESHOLD}ms`,
})
}
// Log non-200 responses — use warn for 404s (expected for missing archived
// pages) and error for genuine upstream failures (5xx, timeouts).
if (r.status !== 200) {
let upstreamBody: string | undefined
try {
upstreamBody = await r.text()
} catch {
// ignore — body reading failure shouldn't affect error handling
}
const level = r.status === 404 ? 'warn' : 'error'
logger[level]('Failed to fetch archived enterprise content', {
version: requestedVersion,
path: req.path,
status: r.status,
statusText: r.statusText,
responseTime: `${responseTime}ms`,
url: getProxyPath(req.path, requestedVersion),
upstreamBody: upstreamBody?.slice(0, 500),
})
}
// Log successful responses with timing for monitoring trends
if (r.status === 200 && responseTime > SLOW_RESPONSE_THRESHOLD) {
logger.info('Archived enterprise content response', {
version: requestedVersion,
responseTime: `${responseTime}ms`,
status: r.status,
})
}
if (r.status === 200) {
const body = await r.text()
const [, withoutLanguagePath] = splitByLanguage(req.path)
const isDeveloperPage = withoutLanguagePath?.startsWith(
`/enterprise/${requestedVersion}/developer`,
)
res.set('x-robots-tag', 'noindex')
// make stubbed redirect files (which exist in versions <2.13) redirect with a 301
const staticRedirect = body.match(patterns.staticRedirect)
if (staticRedirect) {
cacheAggressively(res)
return res.safeRedirect(redirectCode, staticRedirect[1])
}
res.set('content-type', r.headers.get('content-type') || '')
cacheAggressively(res)
// Releases 3.2 and higher contain image asset paths with the
// old Azure Blob Storage URL. These need to be rewritten to
// the new archived enterprise repo URL.
if (
versionSatisfiesRange(requestedVersion, `>=${firstReleaseStoredInBlobStorage}`) &&
versionSatisfiesRange(requestedVersion, `<=3.9`)
) {
// `x-host` is a custom header set by Fastly.
// GLB automatically deletes the `x-forwarded-host` header.
const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host')
const modifiedBody = body
.replaceAll(
`${OLD_AZURE_BLOB_ENTERPRISE_DIR}/${requestedVersion}/assets/cb-`,
`${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets/cb-`,
)
.replaceAll(
`${OLD_AZURE_BLOB_ENTERPRISE_DIR}/${requestedVersion}/`,
`${req.protocol}://${host}/enterprise-server@${requestedVersion}/`,
)
return res.send(modifiedBody)
}
// Releases 3.1 and lower were previously hosted in the
// help-docs-archived-enterprise-versions repo. Only the images
// were stored in the old Azure Blob Storage `github-images` container.
// The image paths all need to be updated to reference the images in the
// new archived enterprise repo's root assets directory.
if (versionSatisfiesRange(requestedVersion, `<${firstReleaseStoredInBlobStorage}`)) {
let modifiedBody = body.replaceAll(
`${OLD_GITHUB_IMAGES_ENTERPRISE_DIR}/${requestedVersion}`,
`${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}`,
)
if (versionSatisfiesRange(requestedVersion, '<=2.18') && isDeveloperPage) {
modifiedBody = modifiedBody.replaceAll(
`${OLD_DEVELOPER_SITE_CONTAINER}/${requestedVersion}`,
`${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/developer`,
)
// Update all hrefs to add /developer to the path
modifiedBody = modifiedBody.replaceAll(
`="/enterprise/${requestedVersion}`,
`="/enterprise/${requestedVersion}/developer`,
)
// The changelog is the only thing remaining on developer.github.com
modifiedBody = modifiedBody.replaceAll(
'href="/changes',
'href="https://developer.github.com/changes',
)
}
// Continue with remaining replacements
modifiedBody = modifiedBody.replaceAll(
/="(\.\.\/)*assets/g,
`="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`,
)
// Fix broken hrefs on the 2.16 landing page
if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') {
modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16')
}
// Remove the search results container from the page
modifiedBody = modifiedBody.replaceAll('<div id="search-results-container"></div>', '')
return res.send(modifiedBody)
}
// In all releases, some assets were incorrectly scraped and contain
// deep relative paths. For example, releases 3.4+ use the webp format
// for images. The URLs for those images were never rewritten to pull
// from the Azure Blob Storage container. This may be due to not
// updating our scraping tool to handle the new image types. There
// are additional images in older versions that also have a relative path.
// We want to update the URLs in the format
// "../../../../../../assets/" to prefix the assets directory with the
// new archived enterprise repo URL.
let modifiedBody = body.replaceAll(
/="(\.\.\/)*assets/g,
`="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`,
)
// Fix broken hrefs on the 2.16 landing page
if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') {
modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16')
}
// Remove the search results container from the page, which removes a white
// box that prevents clicking on page links
modifiedBody = modifiedBody.replaceAll('<div id="search-results-container"></div>', '')
return res.send(modifiedBody)
}
// In releases 2.13 - 2.17, we lost access to frontmatter redirects
// during the archival process. This workaround finds potentially
// relevant frontmatter redirects in currently supported pages
if (
versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) &&
versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`)
) {
const statsTags = [`path:${req.path}`]
const fallbackRedirect = getFallbackRedirect(req)
if (fallbackRedirect) {
statsTags.push(`fallback:${fallbackRedirect}`)
statsd.increment('middleware.trying_fallback_redirect_success', 1, statsTags)
cacheAggressively(res)
return res.safeRedirect(redirectCode, fallbackRedirect)
}
statsd.increment('middleware.trying_fallback_redirect_failure', 1, statsTags)
}
return next()
}
function getProxyPath(reqPath: string, requestedVersion: string) {
const [, withoutLanguagePath] = splitByLanguage(reqPath)
const isDeveloperPage = withoutLanguagePath?.startsWith(
`/enterprise/${requestedVersion}/developer`,
)
// This was the last release supported on developer.github.com
if (isDeveloperPage) {
const enterprisePath = `/enterprise/${requestedVersion}`
const newReqPath = reqPath.replace(enterprisePath, '')
return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath
}
// Releases 2.18 and higher
if (versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`)) {
const newReqPath = reqPath.includes('redirects.json') ? `/${reqPath}` : `${reqPath}/index.html`
return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath
}
// Releases 2.13 - 2.17
// redirect.json files don't exist for these versions
if (versionSatisfiesRange(requestedVersion, `>=2.13`)) {
return `${ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + reqPath}/index.html`
}
// Releases 2.12 and lower
const enterprisePath = `/enterprise/${requestedVersion}`
const newReqPath = reqPath.replace(enterprisePath, '')
return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath
}
// Module-level global cache object.
// Get's populated lazily inside getFallbackRedirect().
const fallbackRedirectLookups = new Map()
function getFallbackRedirect(req: ExtendedRequest) {
// The file `lib/redirects/static/archived-frontmatter-valid-urls.json` which
// we depend on here, is structured like this:
//
// {
// "/enterprise/2.13/foo/bar": [
// "/enterprise/2.13/other/old/thing",
// "/enterprise/2.13/more/redirectable/url",
// "/enterprise/2.13/etc/etc"
// ],
// ...
//
// The keys are valid URLs that it can redirect to. I.e. these are
// URLs that we definitely know are valid and will be found
// in one of the docs-ghes-<release number> repos.
// The array values are possible URLs we deem acceptable redirect
// sources.
// But to avoid an unnecessary, O(n), loop every time, we turn this
// structure around to become:
//
// {
// "/enterprise/2.13/other/old/thing": "/enterprise/2.13/foo/bar",
// "/enterprise/2.13/more/redirectable/url": "/enterprise/2.13/foo/bar",
// "/enterprise/2.13/etc/etc": "/enterprise/2.13/foo/bar",
// ...
//
// Now potential lookups are fast.
if (!fallbackRedirectLookups.size) {
for (const [destination, sources] of Object.entries(archivedFrontmatterValidURLS())) {
for (const source of sources) {
fallbackRedirectLookups.set(source, destination)
}
}
}
// But before we proceed, remember that the
// file lib/redirects/static/archived-frontmatter-valid-urls.json never
// contains a language prefix.
// E.g. only `/enterprise/2.13/foo/bar` but the requested URL can be
// `/en/enterprise/2.13/foo/bar`, `/pt/enterprise/2.13/foo/bar`,
// or just `/enterprise/2.13/foo/bar`.
// Whatever it is, pop the language prefix, operate, and put it back
// again. In the end, it always has to have a language prefix.
const [language, withoutLanguage] = splitPathByLanguage(req.path)
const fallback = fallbackRedirectLookups.get(withoutLanguage)
if (fallback) {
return `/${language}${fallback}`
}
}
function splitByLanguage(uri: string) {
let language = null
let withoutLanguage = uri
const match = uri.match(languagePrefixPathRegex)
if (match) {
language = match[1]
withoutLanguage = uri.replace(languagePrefixPathRegex, '/')
}
return [language, withoutLanguage]
}
// Regex to extract any language-like prefix from the path, including
// "cn" which was the old Chinese language code used in archives ≤3.2.
const archiveLanguagePrefixRegex = new RegExp(`^/(${Object.keys(allLanguages).join('|')}|cn)(/|$)`)
// Detects request paths that will never resolve on the upstream GitHub
// Pages archive repos, so we can 404 immediately without making a
// network request. Returns a short reason string, or null if the
// request looks plausible.
function getEarlyNotFoundReason(reqPath: string, version: string): string | null {
// Double slashes in the path never resolve (e.g. ".../about-2fa//index.html")
if (reqPath.includes('//')) {
return 'double-slash'
}
// Duplicated "/developer/developer/" segment — these are broken
// crawler URLs from the old developer.github.com site.
if (reqPath.includes('/developer/developer/')) {
return 'developer-developer'
}
// Check if the language in the path actually exists in this version's
// archive. Each language has a `firstArchivedVersion` indicating when
// it was first included in the GHES archives.
const langMatch = reqPath.match(archiveLanguagePrefixRegex)
if (langMatch) {
const lang = langMatch[1]
// "cn" was the old Chinese language code; those archives are ancient
// and effectively dead traffic. Always 404.
if (lang === 'cn') {
return 'language-not-in-version'
}
const langDef = allLanguages[lang]
if (langDef?.firstArchivedVersion) {
// 404 if the requested version is older than when this language
// was first archived (e.g. /zh/ on v3.0 → 404 because zh started in 3.3)
if (!versionSatisfiesRange(version, `>=${langDef.firstArchivedVersion}`)) {
return 'language-not-in-version'
}
}
}
return null
}