From 40fb82da276842ccd357ff84321b096e229033e7 Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Mon, 2 Oct 2023 10:54:53 +0300 Subject: [PATCH] src: automatically rewrite latests and docs links (#39) Co-authored-by: Claudio W --- src/constants/r2Prefixes.ts | 14 ++++- src/handlers/get.ts | 30 +++++++---- src/handlers/strategies/directoryListing.ts | 59 ++++++++++----------- src/util.ts | 19 +++++-- tests/e2e/directory.test.ts | 28 ---------- tests/unit/util.test.ts | 37 ++++++++----- 6 files changed, 98 insertions(+), 89 deletions(-) diff --git a/src/constants/r2Prefixes.ts b/src/constants/r2Prefixes.ts index 41f554d..d9a4c1d 100644 --- a/src/constants/r2Prefixes.ts +++ b/src/constants/r2Prefixes.ts @@ -3,6 +3,9 @@ // later on. // (e.g. url path `/dist` points to R2 path `nodejs/release`) // See https://raw.githubusercontent.com/nodejs/build/main/ansible/www-standalone/resources/config/nodejs.org +import map from './redirectLinks.json' assert { type: 'json' }; + +export const REDIRECT_MAP = new Map(map as [string, string][]); export const DIST_PATH_PREFIX = 'nodejs/release'; @@ -10,4 +13,13 @@ export const DOWNLOAD_PATH_PREFIX = 'nodejs'; export const DOCS_PATH_PREFIX = 'nodejs/docs'; -export const API_PATH_PREFIX = 'nodejs/docs/latest/api'; +export const API_PATH_PREFIX = `${REDIRECT_MAP.get('nodejs/docs/latest')}/api`; + +export const VIRTUAL_DIRS: Record> = { + 'docs/': new Set( + [...REDIRECT_MAP] + .filter(([key]) => key.startsWith('nodejs/docs/')) + .reverse() + .map(([key]) => key.substring(12) + '/') + ), +}; diff --git a/src/handlers/get.ts b/src/handlers/get.ts index bb2942b..17129c0 100644 --- a/src/handlers/get.ts +++ b/src/handlers/get.ts @@ -1,4 +1,5 @@ import responses from '../commonResponses'; +import { VIRTUAL_DIRS } from '../constants/r2Prefixes'; import { isCacheEnabled, isDirectoryPath, @@ -7,7 +8,10 @@ import { parseUrl, } from '../util'; import { Handler } from './handler'; -import { listDirectory } from './strategies/directoryListing'; +import { + listDirectory, + renderDirectoryListing, +} from './strategies/directoryListing'; import { getFile } from './strategies/serveFile'; const getHandler: Handler = async (request, env, ctx, cache) => { @@ -45,7 +49,7 @@ const getHandler: Handler = async (request, env, ctx, cache) => { return responses.FILE_NOT_FOUND(request); } - if (!hasTrailingSlash(bucketPath)) { + if (bucketPath && !hasTrailingSlash(requestUrl.pathname)) { // We always want to add trailing slashes to a directory URL requestUrl.pathname += '/'; @@ -53,14 +57,20 @@ const getHandler: Handler = async (request, env, ctx, cache) => { } } - // This returns a Promise that returns either a directory listing - // or a file response based on the requested URL - const responsePromise: Promise = isPathADirectory - ? listDirectory(requestUrl, request, bucketPath, env) - : getFile(requestUrl, request, bucketPath, env); - - // waits for the response to be resolved (async R2 request) - const response = await responsePromise; + let response: Response; + if (bucketPath in VIRTUAL_DIRS) { + response = renderDirectoryListing( + requestUrl, + request, + VIRTUAL_DIRS[bucketPath], + [], + env + ); + } else if (isPathADirectory) { + response = await listDirectory(requestUrl, request, bucketPath, env); + } else { + response = await getFile(requestUrl, request, bucketPath, env); + } // Cache response if cache is enabled if (shouldServeCache && response.status !== 304 && response.status !== 206) { diff --git a/src/handlers/strategies/directoryListing.ts b/src/handlers/strategies/directoryListing.ts index 4713ac7..93064a0 100644 --- a/src/handlers/strategies/directoryListing.ts +++ b/src/handlers/strategies/directoryListing.ts @@ -10,27 +10,23 @@ import htmlTemplate from '../../templates/directoryListing.out.js'; // Applies the Template into a Handlebars Template Function const handleBarsTemplate = Handlebars.template(htmlTemplate); -type DirectoryListingResponse = { - html: string; - lastModified: string; -}; - /** * @TODO: Simplify the iteration logic or make it more readable * * Renders the html for a directory listing response * @param url Parsed url of the request - * @param bucketPath Path in R2 bucket + * @param request Request object itself * @param delimitedPrefixes Directories in the bucket * @param listingResponse Listing response to render * @returns {@link DirectoryListingResponse} instance */ -function renderDirectoryListing( +export function renderDirectoryListing( url: URL, - bucketPath: string, + request: Request, delimitedPrefixes: Set, - objects: R2Object[] -): DirectoryListingResponse { + objects: R2Object[], + env: Env +): Response { // Holds all the html for each directory and file we're listing const tableElements = []; @@ -45,9 +41,7 @@ function renderDirectoryListing( const urlPathname = `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}`; // Renders all the subdirectories within the Directory - delimitedPrefixes.forEach(directory => { - // R2 sends us back the absolute path of the directory, cut it - const name = directory.substring(bucketPath.length); + delimitedPrefixes.forEach(name => { const extra = encodeURIComponent(name.substring(0, name.length - 1)); tableElements.push({ @@ -63,8 +57,7 @@ function renderDirectoryListing( // Renders all the Files within the Directory objects.forEach(object => { - // R2 sends us back the absolute path of the object, cut it - const name = object.key.substring(bucketPath.length); + const name = object.key; // Find the most recent date a file in this // directory was modified, we'll use it @@ -95,7 +88,13 @@ function renderDirectoryListing( // Gets an UTC-string on the ISO-8901 format of last modified date const lastModifiedUTC = (lastModified ?? new Date()).toUTCString(); - return { html: renderedListing, lastModified: lastModifiedUTC }; + return new Response(request.method === 'GET' ? renderedListing : null, { + headers: { + 'last-modified': lastModifiedUTC, + 'content-type': 'text/html', + 'cache-control': env.DIRECTORY_CACHE_CONTROL || 'no-store', + }, + }); } /** @@ -124,7 +123,10 @@ export async function listDirectory( cursor, }); - result.delimitedPrefixes.forEach(prefix => delimitedPrefixes.add(prefix)); + // R2 sends us back the absolute path of the object, cut it + result.delimitedPrefixes.forEach(prefix => + delimitedPrefixes.add(prefix.substring(bucketPath.length)) + ); const hasIndexFile = result.objects.find(object => object.key.endsWith('index.html') @@ -134,7 +136,13 @@ export async function listDirectory( return getFile(url, request, `${bucketPath}index.html`, env); } - objects.push(...result.objects); + // R2 sends us back the absolute path of the object, cut it + result.objects.forEach(object => + objects.push({ + ...object, + key: object.key.substring(bucketPath.length), + } as R2Object) + ); truncated = result.truncated; cursor = result.truncated ? result.cursor : undefined; @@ -145,18 +153,5 @@ export async function listDirectory( return responses.DIRECTORY_NOT_FOUND(request); } - const response = renderDirectoryListing( - url, - bucketPath, - delimitedPrefixes, - objects - ); - - return new Response(request.method === 'GET' ? response.html : null, { - headers: { - 'last-modified': response.lastModified, - 'content-type': 'text/html', - 'cache-control': env.DIRECTORY_CACHE_CONTROL || 'no-store', - }, - }); + return renderDirectoryListing(url, request, delimitedPrefixes, objects, env); } diff --git a/src/util.ts b/src/util.ts index f3171c1..406e117 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,6 +3,7 @@ import { DIST_PATH_PREFIX, DOCS_PATH_PREFIX, DOWNLOAD_PATH_PREFIX, + REDIRECT_MAP, } from './constants/r2Prefixes'; import { Env } from './env'; @@ -52,8 +53,7 @@ export function mapUrlPathToBucketPath( const urlToBucketPathMap: Record = { dist: DIST_PATH_PREFIX + url.pathname.substring(5), download: DOWNLOAD_PATH_PREFIX + url.pathname.substring(9), - docs: DOCS_PATH_PREFIX + url.pathname.substring(5), - api: API_PATH_PREFIX + url.pathname.substring(4), + api: API_PATH_PREFIX + (url.pathname.substring(4) || '/'), metrics: url.pathname.substring(1), // substring to cut off the / }; @@ -61,10 +61,16 @@ export function mapUrlPathToBucketPath( let bucketPath: string | undefined; const splitPath = url.pathname.split('/'); // ['', 'docs', 'asd', '123'] - const basePath = splitPath[1]; // 'docs' - if (basePath in urlToBucketPathMap) { + if ( + REDIRECT_MAP.has(`${DOWNLOAD_PATH_PREFIX}/${splitPath[1]}/${splitPath[2]}`) + ) { + // All items in REDIRECT_MAP are three levels deep, that is asserted in tests + bucketPath = `${REDIRECT_MAP.get( + `${DOWNLOAD_PATH_PREFIX}/${splitPath[1]}/${splitPath[2]}` + )}/${splitPath.slice(3).join('/')}`; + } else if (basePath in urlToBucketPathMap) { bucketPath = urlToBucketPathMap[basePath]; } else if (env.DIRECTORY_LISTING !== 'restricted') { bucketPath = url.pathname.substring(1); @@ -88,7 +94,10 @@ export function mapBucketPathToUrlPath( if (bucketPath.startsWith(DIST_PATH_PREFIX)) { const path = bucketPath.substring(15); return [`/dist${path}`, `/download/releases${path}`]; - } else if (bucketPath.startsWith(API_PATH_PREFIX)) { + } else if ( + bucketPath.startsWith(API_PATH_PREFIX) || + bucketPath.startsWith('nodejs/docs/latest/api') + ) { const path = bucketPath.substring(22); return [`/api${path}`, `/docs/latest/api${path}`]; } else if (bucketPath.startsWith(DOCS_PATH_PREFIX)) { diff --git a/tests/e2e/directory.test.ts b/tests/e2e/directory.test.ts index 37ef884..e5ef56a 100644 --- a/tests/e2e/directory.test.ts +++ b/tests/e2e/directory.test.ts @@ -63,34 +63,6 @@ describe('Directory Tests (Restricted Directory Listing)', () => { assert.strictEqual(res.status, 200); }); - it('redirects `/docs` to `/docs/`', async () => { - const originalRes = await mf.dispatchFetch(`${url}docs`, { - redirect: 'manual', - }); - assert.strictEqual(originalRes.status, 301); - const res = await mf.dispatchFetch(originalRes.headers.get('location')!); - assert.strictEqual(res.status, 200); - }); - - it('allows `/docs/`', async () => { - const res = await mf.dispatchFetch(`${url}docs/`); - assert.strictEqual(res.status, 200); - }); - - it('redirects `/api` to `/api/`', async () => { - const originalRes = await mf.dispatchFetch(`${url}api`, { - redirect: 'manual', - }); - assert.strictEqual(originalRes.status, 301); - const res = await mf.dispatchFetch(originalRes.headers.get('location')!); - assert.strictEqual(res.status, 200); - }); - - it('allows `/api/`', async () => { - const res = await mf.dispatchFetch(`${url}api/`); - assert.strictEqual(res.status, 200); - }); - it('redirects `/metrics` to `/metrics/`', async () => { const originalRes = await mf.dispatchFetch(`${url}metrics`, { redirect: 'manual', diff --git a/tests/unit/util.test.ts b/tests/unit/util.test.ts index fabfb9d..8a92a8d 100644 --- a/tests/unit/util.test.ts +++ b/tests/unit/util.test.ts @@ -6,8 +6,20 @@ import { mapUrlPathToBucketPath, niceBytes, } from '../../src/util'; +import { REDIRECT_MAP } from '../../src/constants/r2Prefixes'; describe('mapUrlPathToBucketPath', () => { + it('expects all items in REDIRECT_MAP to be pathes in the length of 3', () => { + // If this test breaks, the code will and we'll need to fix the code + REDIRECT_MAP.forEach((val, key) => { + assert.strictEqual( + key.split('/').length, + 3, + `expected ${key} to be a path with 3 slashes` + ); + }); + }); + it('converts `/unknown-base-path` to undefined when DIRECTORY_LISTING=restricted', () => { const result = mapUrlPathToBucketPath( new URL('http://localhost/unknown-base-path'), @@ -65,38 +77,37 @@ describe('mapUrlPathToBucketPath', () => { assert.strictEqual(result, 'nodejs/releases'); }); - it('converts `/docs` to `nodejs/docs`', () => { - const result = mapUrlPathToBucketPath(new URL('http://localhost/docs'), { - DIRECTORY_LISTING: 'restricted', - }); - assert.strictEqual(result, 'nodejs/docs'); - }); - - it('converts `/docs/latest` to `nodejs/docs/latest`', () => { + it('converts `/docs/latest` to `nodejs/release/v.X.X.X/docs/`', () => { const result = mapUrlPathToBucketPath( new URL('http://localhost/docs/latest'), { DIRECTORY_LISTING: 'restricted', } ); - assert.strictEqual(result, 'nodejs/docs/latest'); + assert.match(result ?? '', /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/$/); }); - it('converts `/api` to `nodejs/docs`', () => { + it('converts `/api` to `nodejs/release/v.X.X.X/docs/api/`', () => { const result = mapUrlPathToBucketPath(new URL('http://localhost/api'), { DIRECTORY_LISTING: 'restricted', }); - assert.strictEqual(result, 'nodejs/docs/latest/api'); + assert.match( + result ?? '', + /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/api\/$/ + ); }); - it('converts `/api/assert.html` to `nodejs/docs/latest/api/assert.html`', () => { + it('converts `/api/assert.html` to `nodejs/release/v.X.X.X/docs/api/assert.html`', () => { const result = mapUrlPathToBucketPath( new URL('http://localhost/api/assert.html'), { DIRECTORY_LISTING: 'restricted', } ); - assert.strictEqual(result, 'nodejs/docs/latest/api/assert.html'); + assert.match( + result ?? '', + /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/api\/assert\.html$/ + ); }); });