diff --git a/src/commonResponses.ts b/src/commonResponses.ts deleted file mode 100644 index 4e8280c..0000000 --- a/src/commonResponses.ts +++ /dev/null @@ -1,19 +0,0 @@ -export default { - METHOD_NOT_ALLOWED: new Response(undefined, { - status: 405, - headers: { Allow: 'GET, HEAD, OPTIONS' }, - }), - BAD_REQUEST: new Response(undefined, { status: 400 }), - FILE_NOT_FOUND: (request: Request): Response => { - return new Response( - request.method !== 'HEAD' ? 'File not found' : undefined, - { status: 404 } - ); - }, - DIRECTORY_NOT_FOUND: (request: Request): Response => { - return new Response( - request.method !== 'HEAD' ? 'Directory not found' : undefined, - { status: 404 } - ); - }, -}; diff --git a/src/constants/cache.ts b/src/constants/cache.ts new file mode 100644 index 0000000..de9897c --- /dev/null +++ b/src/constants/cache.ts @@ -0,0 +1,6 @@ +export const CACHE = caches.default; + +export const CACHE_HEADERS = { + success: 'public, max-age=3600, s-maxage=14400', + failure: 'private, no-cache, no-store, max-age=0, must-revalidate', +}; diff --git a/src/constants/commonResponses.ts b/src/constants/commonResponses.ts new file mode 100644 index 0000000..640116e --- /dev/null +++ b/src/constants/commonResponses.ts @@ -0,0 +1,40 @@ +import { CACHE_HEADERS } from './cache'; + +export const METHOD_NOT_ALLOWED = new Response(undefined, { + status: 405, + headers: { + allow: 'GET, HEAD, OPTIONS', + 'cache-control': CACHE_HEADERS.failure, + }, +}); + +export const BAD_REQUEST = new Response(undefined, { + status: 400, + headers: { + 'cache-control': CACHE_HEADERS.failure, + }, +}); + +export const FILE_NOT_FOUND = (request: Request): Response => { + return new Response( + request.method !== 'HEAD' ? 'File not found' : undefined, + { + status: 404, + headers: { + 'cache-control': CACHE_HEADERS.failure, + }, + } + ); +}; + +export const DIRECTORY_NOT_FOUND = (request: Request): Response => { + return new Response( + request.method !== 'HEAD' ? 'Directory not found' : undefined, + { + status: 404, + headers: { + 'cache-control': CACHE_HEADERS.failure, + }, + } + ); +}; diff --git a/src/constants/r2Prefixes.ts b/src/constants/r2Prefixes.ts index d9a4c1d..b9c40cf 100644 --- a/src/constants/r2Prefixes.ts +++ b/src/constants/r2Prefixes.ts @@ -23,3 +23,15 @@ export const VIRTUAL_DIRS: Record> = { .map(([key]) => key.substring(12) + '/') ), }; + +export const URL_TO_BUCKET_PATH_MAP: Record string> = { + dist: (url): string => + DIST_PATH_PREFIX + (url.pathname.substring('/dist'.length) || '/'), + download: (url): string => + DOWNLOAD_PATH_PREFIX + (url.pathname.substring('/download'.length) || '/'), + docs: (url): string => + DOCS_PATH_PREFIX + (url.pathname.substring('/docs'.length) || '/'), + api: (url): string => + API_PATH_PREFIX + (url.pathname.substring('/api'.length) || '/'), + metrics: (url): string => url.pathname.substring(1), // substring to cut off the / +}; diff --git a/src/env.ts b/src/env.ts index 4fcee04..60888be 100644 --- a/src/env.ts +++ b/src/env.ts @@ -2,7 +2,7 @@ export interface Env { /** * Environment the worker is running in */ - ENVIRONMENT: 'dev' | 'staging' | 'prod'; + ENVIRONMENT: 'dev' | 'staging' | 'prod' | 'e2e-tests'; /** * R2 bucket we read from */ @@ -36,14 +36,6 @@ export interface Env { * Api key for /_cf/cache-purge. If undefined, the endpoint is disabled. */ CACHE_PURGE_API_KEY?: string; - /** - * Cache control header for files - */ - FILE_CACHE_CONTROL: string; - /** - * Cache control header for directory listing - */ - DIRECTORY_CACHE_CONTROL: string; /** * Sentry DSN, used for error monitoring * If missing, Sentry isn't used diff --git a/src/handlers/get.ts b/src/handlers/get.ts index b9c4f19..4f56d9b 100644 --- a/src/handlers/get.ts +++ b/src/handlers/get.ts @@ -1,4 +1,5 @@ -import responses from '../commonResponses'; +import { CACHE } from '../constants/cache'; +import { BAD_REQUEST, FILE_NOT_FOUND } from '../constants/commonResponses'; import { VIRTUAL_DIRS } from '../constants/r2Prefixes'; import { isCacheEnabled, @@ -14,12 +15,12 @@ import { } from './strategies/directoryListing'; import { getFile } from './strategies/serveFile'; -const getHandler: Handler = async (request, env, ctx, cache) => { +const getHandler: Handler = async (request, env, ctx) => { const shouldServeCache = isCacheEnabled(env); if (shouldServeCache) { // Caching is enabled, let's see if the request is cached - const response = await cache.match(request); + const response = await CACHE.match(request); if (typeof response !== 'undefined') { return response; @@ -29,7 +30,7 @@ const getHandler: Handler = async (request, env, ctx, cache) => { const requestUrl = parseUrl(request); if (requestUrl === undefined) { - return responses.BAD_REQUEST; + return BAD_REQUEST; } const bucketPath = mapUrlPathToBucketPath(requestUrl, env); @@ -46,7 +47,7 @@ const getHandler: Handler = async (request, env, ctx, cache) => { if (env.DIRECTORY_LISTING === 'off') { // File not found since we should only be allowing // file paths if directory listing is off - return responses.FILE_NOT_FOUND(request); + return FILE_NOT_FOUND(request); } if (bucketPath && !hasTrailingSlash(requestUrl.pathname)) { @@ -65,8 +66,7 @@ const getHandler: Handler = async (request, env, ctx, cache) => { requestUrl, request, VIRTUAL_DIRS[bucketPath], - [], - env + [] ); } else if (isPathADirectory) { // List the directory @@ -86,7 +86,7 @@ const getHandler: Handler = async (request, env, ctx, cache) => { cachedResponse.headers.append('x-cache-status', 'hit'); - ctx.waitUntil(cache.put(request, cachedResponse)); + ctx.waitUntil(CACHE.put(request, cachedResponse)); } response.headers.append('x-cache-status', 'miss'); diff --git a/src/handlers/handler.ts b/src/handlers/handler.ts index 85ba1d0..f81c1de 100644 --- a/src/handlers/handler.ts +++ b/src/handlers/handler.ts @@ -9,6 +9,5 @@ import { Env } from '../env'; export type Handler = ( request: Request, env: Env, - ctx: ExecutionContext, - cache: Cache + ctx: ExecutionContext ) => Promise; diff --git a/src/handlers/post.ts b/src/handlers/post.ts index 4cbcccb..36baa24 100644 --- a/src/handlers/post.ts +++ b/src/handlers/post.ts @@ -1,20 +1,20 @@ -import responses from '../commonResponses'; -import { isCacheEnabled, parseUrl } from '../util'; +import { BAD_REQUEST } from '../constants/commonResponses'; +import { parseUrl } from '../util'; import { Handler } from './handler'; import { cachePurge } from './strategies/cachePurge'; -const postHandler: Handler = async (request, env, _, cache) => { +const postHandler: Handler = async (request, env) => { const url = parseUrl(request); if (url === undefined) { - return responses.BAD_REQUEST; + return BAD_REQUEST; } // This endpoint is called from the sync script to purge // directories that are commonly updated so we don't need to // wait for the cache to expire - if (isCacheEnabled(env) && url.pathname === '/_cf/cache-purge') { - return cachePurge(url, request, cache, env); + if (url.pathname === '/_cf/cache-purge') { + return cachePurge(url, request, env); } return new Response(url.pathname, { status: 404 }); diff --git a/src/handlers/strategies/cachePurge.ts b/src/handlers/strategies/cachePurge.ts index 4ddbf1e..d84c063 100644 --- a/src/handlers/strategies/cachePurge.ts +++ b/src/handlers/strategies/cachePurge.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import { Env } from '../../env'; -import responses from '../../commonResponses'; import { mapBucketPathToUrlPath } from '../../util'; +import { CACHE } from '../../constants/cache'; +import { BAD_REQUEST } from '../../constants/commonResponses'; const CachePurgeBodySchema = z.object({ paths: z.array(z.string()), @@ -28,14 +29,14 @@ async function parseBody(request: Request): Promise { bodyObject = await request.json(); } catch (e) { // content-type header lied to us - return responses.BAD_REQUEST; + return BAD_REQUEST; } // Validate the body's contents const parseResult = CachePurgeBodySchema.safeParse(bodyObject); if (!parseResult.success) { - return responses.BAD_REQUEST; + return BAD_REQUEST; } return parseResult.data; @@ -52,7 +53,6 @@ async function parseBody(request: Request): Promise { export async function cachePurge( url: URL, request: Request, - cache: Cache, env: Env ): Promise { const providedApiKey = request.headers.get('x-api-key'); @@ -82,7 +82,7 @@ export async function cachePurge( } for (const urlPath of urlPaths) { - promises.push(cache.delete(new Request(`${baseUrl}/${urlPath}`))); + promises.push(CACHE.delete(new Request(`${baseUrl}/${urlPath}`))); } } diff --git a/src/handlers/strategies/directoryListing.ts b/src/handlers/strategies/directoryListing.ts index 34a7933..95a52ab 100644 --- a/src/handlers/strategies/directoryListing.ts +++ b/src/handlers/strategies/directoryListing.ts @@ -6,13 +6,14 @@ import { } from '@aws-sdk/client-s3'; import Handlebars from 'handlebars'; import { Env } from '../../env'; -import responses from '../../commonResponses'; import { niceBytes } from '../../util'; import { getFile } from './serveFile'; // Imports the Precompiled Handlebars Template import htmlTemplate from '../../templates/directoryListing.out.js'; import { S3_MAX_KEYS, S3_RETRY_LIMIT } from '../../constants/limits'; +import { CACHE_HEADERS } from '../../constants/cache'; +import { DIRECTORY_NOT_FOUND } from '../../constants/commonResponses'; // Applies the Template into a Handlebars Template Function const handleBarsTemplate = Handlebars.template(htmlTemplate); @@ -31,8 +32,7 @@ export function renderDirectoryListing( url: URL, request: Request, delimitedPrefixes: Set, - objects: _Object[], - env: Env + objects: _Object[] ): Response { // Holds the contents of the listing (directories and files) const tableElements = []; @@ -101,7 +101,7 @@ export function renderDirectoryListing( headers: { 'last-modified': lastModifiedUTC, 'content-type': 'text/html', - 'cache-control': env.DIRECTORY_CACHE_CONTROL || 'no-store', + 'cache-control': CACHE_HEADERS.success, }, }); } @@ -222,12 +222,12 @@ export async function listDirectory( // Directory needs either subdirectories or files in it cannot be empty if (delimitedPrefixes.size === 0 && objects.length === 0) { - return responses.DIRECTORY_NOT_FOUND(request); + return DIRECTORY_NOT_FOUND(request); } if (request.method === 'HEAD') { return new Response(undefined, { status: 200 }); } - return renderDirectoryListing(url, request, delimitedPrefixes, objects, env); + return renderDirectoryListing(url, request, delimitedPrefixes, objects); } diff --git a/src/handlers/strategies/serveFile.ts b/src/handlers/strategies/serveFile.ts index 6c10244..80b339d 100644 --- a/src/handlers/strategies/serveFile.ts +++ b/src/handlers/strategies/serveFile.ts @@ -1,6 +1,11 @@ import { Env } from '../../env'; import { objectHasBody } from '../../util'; -import responses from '../../commonResponses'; +import { CACHE_HEADERS } from '../../constants/cache'; +import { + BAD_REQUEST, + FILE_NOT_FOUND, + METHOD_NOT_ALLOWED, +} from '../../constants/commonResponses'; /** * Decides on what status code to return to @@ -68,28 +73,27 @@ export async function getFile( break; } catch (e) { // Unquoted etags make R2 api throw an error - return responses.BAD_REQUEST; + return BAD_REQUEST; } case 'HEAD': file = await env.R2_BUCKET.head(bucketPath); break; default: - return responses.METHOD_NOT_ALLOWED; + return METHOD_NOT_ALLOWED; } if (file === null) { - return responses.FILE_NOT_FOUND(request); + return FILE_NOT_FOUND(request); } const hasBody = objectHasBody(file); - const cacheControl = - file.httpMetadata?.cacheControl ?? (env.FILE_CACHE_CONTROL || 'no-store'); + const statusCode = getStatusCode(request, hasBody); return new Response( hasBody && file.size != 0 ? (file as R2ObjectBody).body : null, { - status: getStatusCode(request, hasBody), + status: statusCode, headers: { etag: file.httpEtag, 'accept-range': 'bytes', @@ -97,7 +101,10 @@ export async function getFile( 'access-control-allow-origin': url.pathname.endsWith('.json') ? '*' : '', - 'cache-control': cacheControl, + 'cache-control': + statusCode === 200 + ? file.httpMetadata?.cacheControl ?? CACHE_HEADERS.success + : CACHE_HEADERS.failure, expires: file.httpMetadata?.cacheExpiry?.toUTCString() ?? '', 'last-modified': file.uploaded.toUTCString(), 'content-encoding': file.httpMetadata?.contentEncoding ?? '', diff --git a/src/util.ts b/src/util.ts index fed2322..5ee30fc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,9 +1,9 @@ import { - API_PATH_PREFIX, DIST_PATH_PREFIX, DOCS_PATH_PREFIX, DOWNLOAD_PATH_PREFIX, REDIRECT_MAP, + URL_TO_BUCKET_PATH_MAP, } from './constants/r2Prefixes'; import { Env } from './env'; @@ -15,10 +15,7 @@ const units = ['B', 'KB', 'MB', 'GB', 'TB']; * directory listings */ export function isCacheEnabled(env: Env): boolean { - return ( - env.FILE_CACHE_CONTROL !== 'no-store' || - env.DIRECTORY_CACHE_CONTROL !== 'no-store' - ); + return env.ENVIRONMENT !== 'e2e-tests'; } /** @@ -50,16 +47,6 @@ export function mapUrlPathToBucketPath( url: URL, env: Pick ): string | undefined { - const urlToBucketPathMap: Record = { - dist: DIST_PATH_PREFIX + (url.pathname.substring('/dist'.length) || '/'), - download: - DOWNLOAD_PATH_PREFIX + - (url.pathname.substring('/download'.length) || '/'), - docs: DOCS_PATH_PREFIX + (url.pathname.substring('/docs'.length) || '/'), - api: API_PATH_PREFIX + (url.pathname.substring('/api'.length) || '/'), - metrics: url.pathname.substring(1), // substring to cut off the / - }; - // Example: /docs/asd/123 let bucketPath: string | undefined; @@ -70,12 +57,12 @@ export function mapUrlPathToBucketPath( const mappedRelease = `${DIST_PATH_PREFIX}/${splitPath[3]}`; const mappedDist = `${DIST_PATH_PREFIX}/${splitPath[2]}`; - if (splitPath[1] === 'dist' && REDIRECT_MAP.has(mappedDist)) { + if (basePath === 'dist' && REDIRECT_MAP.has(mappedDist)) { // All items in REDIRECT_MAP are three levels deep, that is asserted in tests bucketPath = `${REDIRECT_MAP.get(mappedDist)}/${splitPath .slice(3) .join('/')}`; - } else if (splitPath[1] === 'docs' && REDIRECT_MAP.has(mappedDocs)) { + } else if (basePath === 'docs' && REDIRECT_MAP.has(mappedDocs)) { // All items in REDIRECT_MAP are three levels deep, that is asserted in tests bucketPath = `${REDIRECT_MAP.get(mappedDocs)}/${splitPath .slice(3) @@ -84,8 +71,8 @@ export function mapUrlPathToBucketPath( bucketPath = `${REDIRECT_MAP.get(mappedRelease)}/${splitPath .slice(4) .join('/')}`; - } else if (basePath in urlToBucketPathMap) { - bucketPath = urlToBucketPathMap[basePath]; + } else if (basePath in URL_TO_BUCKET_PATH_MAP) { + bucketPath = URL_TO_BUCKET_PATH_MAP[basePath](url); } else if (env.DIRECTORY_LISTING !== 'restricted') { bucketPath = url.pathname.substring(1); } diff --git a/src/worker.ts b/src/worker.ts index 7cbc349..fbbcd02 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,8 @@ import { Env } from './env'; -import responses from './commonResponses'; import handlers from './handlers'; import { Toucan } from 'toucan-js'; +import { CACHE_HEADERS } from './constants/cache'; +import { METHOD_NOT_ALLOWED } from './constants/commonResponses'; interface Worker { /** @@ -13,8 +14,6 @@ interface Worker { const cloudflareWorker: Worker = { fetch: async (request, env, ctx) => { - const cache = caches.default; - const sentry = new Toucan({ dsn: env.SENTRY_DSN, request, @@ -29,13 +28,13 @@ const cloudflareWorker: Worker = { switch (request.method) { case 'HEAD': case 'GET': - return await handlers.get(request, env, ctx, cache); + return await handlers.get(request, env, ctx); case 'POST': - return await handlers.post(request, env, ctx, cache); + return await handlers.post(request, env, ctx); case 'OPTIONS': - return await handlers.options(request, env, ctx, cache); + return await handlers.options(request, env, ctx); default: - return responses.METHOD_NOT_ALLOWED; + return METHOD_NOT_ALLOWED; } } catch (e) { // Send to sentry, if it's disabled this will just noop @@ -49,7 +48,7 @@ const cloudflareWorker: Worker = { return new Response(responseBody, { status: 500, - headers: { 'cache-control': 'no-store' }, + headers: { 'cache-control': CACHE_HEADERS.failure }, }); } }, diff --git a/tests/e2e/cachePurge.test.ts b/tests/e2e/cachePurge.test.ts index 52ca662..6464f26 100644 --- a/tests/e2e/cachePurge.test.ts +++ b/tests/e2e/cachePurge.test.ts @@ -13,8 +13,6 @@ describe('Cache Purge Tests', () => { modules: true, bindings: { DIRECTORY_LISTING: 'restricted', - FILE_CACHE_CONTROL: 'public', - DIRECTORY_CACHE_CONTROL: 'public', CACHE_PURGE_API_KEY: API_KEY, }, }); diff --git a/tests/e2e/directory.test.ts b/tests/e2e/directory.test.ts index 71889aa..f7ffb44 100644 --- a/tests/e2e/directory.test.ts +++ b/tests/e2e/directory.test.ts @@ -58,6 +58,7 @@ describe('Directory Tests (Restricted Directory Listing)', () => { scriptPath: './dist/worker.js', modules: true, bindings: { + ENVIRONMENT: 'e2e-tests', BUCKET_NAME: 'dist-prod', // S3_ENDPOINT needs to be an ip here otherwise s3 sdk will try to hit // the bucket's subdomain (e.g. http://dist-prod.localhost) @@ -66,8 +67,6 @@ describe('Directory Tests (Restricted Directory Listing)', () => { S3_ACCESS_KEY_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', DIRECTORY_LISTING: 'restricted', - FILE_CACHE_CONTROL: 'no-store', - DIRECTORY_CACHE_CONTROL: 'no-store', }, r2Persist: './tests/e2e/test-data', r2Buckets: ['R2_BUCKET'], @@ -87,6 +86,11 @@ describe('Directory Tests (Restricted Directory Listing)', () => { assert.strictEqual(originalRes.status, 301); const res = await mf.dispatchFetch(originalRes.headers.get('location')!); + assert.strictEqual(res.status, 200); + assert.strictEqual( + res.headers.get('cache-control'), + 'public, max-age=3600, s-maxage=14400' + ); // Assert that the html matches what we're expecting // to be returned. If this passes, we can assume @@ -166,7 +170,11 @@ describe('Directory Tests (Restricted Directory Listing)', () => { assert.strictEqual(res.status, 404); const body = await res.text(); - assert.strict(body, 'Directory not found'); + assert.strictEqual(body, 'Directory not found'); + assert.strictEqual( + res.headers.get('cache-control'), + 'private, no-cache, no-store, max-age=0, must-revalidate' + ); }); // Cleanup Miniflare diff --git a/tests/e2e/file.test.ts b/tests/e2e/file.test.ts index 98e1888..a61b624 100644 --- a/tests/e2e/file.test.ts +++ b/tests/e2e/file.test.ts @@ -5,16 +5,14 @@ import { Miniflare } from 'miniflare'; describe('File Tests', () => { let mf: Miniflare; let url: URL; - const cacheControl = 'no-store'; before(async () => { // Setup miniflare mf = new Miniflare({ scriptPath: './dist/worker.js', modules: true, bindings: { + ENVIRONMENT: 'e2e-tests', DIRECTORY_LISTING: 'restricted', - FILE_CACHE_CONTROL: cacheControl, - DIRECTORY_CACHE_CONTROL: 'no-store', }, r2Persist: './tests/e2e/test-data', r2Buckets: ['R2_BUCKET'], @@ -28,7 +26,10 @@ describe('File Tests', () => { const res = await mf.dispatchFetch(`${url}dist/index.json`); assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'application/json'); - assert.strictEqual(res.headers.get('cache-control'), cacheControl); + assert.strictEqual( + res.headers.get('cache-control'), + 'public, max-age=3600, s-maxage=14400' + ); assert.strictEqual(res.headers.has('etag'), true); assert.strictEqual(res.headers.has('last-modified'), true); assert.strictEqual(res.headers.has('content-type'), true); @@ -43,10 +44,14 @@ describe('File Tests', () => { }); assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'application/json'); - assert.strictEqual(res.headers.get('cache-control'), cacheControl); + assert.strictEqual( + res.headers.get('cache-control'), + 'public, max-age=3600, s-maxage=14400' + ); assert.strictEqual(res.headers.has('etag'), true); assert.strictEqual(res.headers.has('last-modified'), true); assert.strictEqual(res.headers.has('content-type'), true); + assert.strictEqual(res.headers.has('x-cache-status'), false); const body = await res.text(); assert.strictEqual(body.length, 0); @@ -58,6 +63,10 @@ describe('File Tests', () => { const body = await res.text(); assert.strictEqual(body, 'File not found'); + assert.strictEqual( + res.headers.get('cache-control'), + 'private, no-cache, no-store, max-age=0, must-revalidate' + ); }); /** @@ -96,6 +105,10 @@ describe('File Tests', () => { }, }); assert.strictEqual(res.status, 412); + assert.strictEqual( + res.headers.get('cache-control'), + 'private, no-cache, no-store, max-age=0, must-revalidate' + ); }); it('handles if-match correctly', async () => { @@ -111,6 +124,10 @@ describe('File Tests', () => { }, }); assert.strictEqual(res.status, 412); + assert.strictEqual( + res.headers.get('cache-control'), + 'private, no-cache, no-store, max-age=0, must-revalidate' + ); // If-Match w/ valid etag returns 200 res = await mf.dispatchFetch(`${url}dist/index.json`, {