Skip to content

Commit

Permalink
src: automatically rewrite latests and docs links (#39)
Browse files Browse the repository at this point in the history
Co-authored-by: Claudio W <[email protected]>
  • Loading branch information
MoLow and ovflowd authored Oct 2, 2023
1 parent dadddfb commit 40fb82d
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 89 deletions.
14 changes: 13 additions & 1 deletion src/constants/r2Prefixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,23 @@
// 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';

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<string, Set<string>> = {
'docs/': new Set(
[...REDIRECT_MAP]
.filter(([key]) => key.startsWith('nodejs/docs/'))
.reverse()
.map(([key]) => key.substring(12) + '/')
),
};
30 changes: 20 additions & 10 deletions src/handlers/get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import responses from '../commonResponses';
import { VIRTUAL_DIRS } from '../constants/r2Prefixes';
import {
isCacheEnabled,
isDirectoryPath,
Expand All @@ -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) => {
Expand Down Expand Up @@ -45,22 +49,28 @@ 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 += '/';

return Response.redirect(requestUrl.toString(), 301);
}
}

// This returns a Promise that returns either a directory listing
// or a file response based on the requested URL
const responsePromise: Promise<Response> = 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) {
Expand Down
59 changes: 27 additions & 32 deletions src/handlers/strategies/directoryListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
objects: R2Object[]
): DirectoryListingResponse {
objects: R2Object[],
env: Env
): Response {
// Holds all the html for each directory and file we're listing
const tableElements = [];

Expand All @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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',
},
});
}

/**
Expand Down Expand Up @@ -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')
Expand All @@ -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;
Expand All @@ -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);
}
19 changes: 14 additions & 5 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DIST_PATH_PREFIX,
DOCS_PATH_PREFIX,
DOWNLOAD_PATH_PREFIX,
REDIRECT_MAP,
} from './constants/r2Prefixes';
import { Env } from './env';

Expand Down Expand Up @@ -52,19 +53,24 @@ export function mapUrlPathToBucketPath(
const urlToBucketPathMap: Record<string, string> = {
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 /
};

// Example: /docs/asd/123
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);
Expand All @@ -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)) {
Expand Down
28 changes: 0 additions & 28 deletions tests/e2e/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
37 changes: 24 additions & 13 deletions tests/unit/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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$/
);
});
});

Expand Down

0 comments on commit 40fb82d

Please sign in to comment.