diff options
| author | Travis Fischer <[email protected]> | 2021-06-02 13:59:27 -0400 |
|---|---|---|
| committer | Travis Fischer <[email protected]> | 2021-06-02 13:59:27 -0400 |
| commit | f6321fc3249d83a0059ef47978ed101d3c75375f (patch) | |
| tree | 9da8b59a0c94c08f92506e57653b8c9f55fa85e8 /src | |
| download | cf-image-proxy-f6321fc3249d83a0059ef47978ed101d3c75375f.tar.xz cf-image-proxy-f6321fc3249d83a0059ef47978ed101d3c75375f.zip | |
feat: import from notion2site repo
Diffstat (limited to 'src')
| -rw-r--r-- | src/fetch-cache.js | 34 | ||||
| -rw-r--r-- | src/fetch-request.js | 64 | ||||
| -rw-r--r-- | src/get-request-cache-key.js | 60 | ||||
| -rw-r--r-- | src/global-res-headers.js | 5 | ||||
| -rw-r--r-- | src/handle-options.js | 29 | ||||
| -rw-r--r-- | src/index.js | 84 | ||||
| -rw-r--r-- | src/normalize-url.js | 58 | ||||
| -rw-r--r-- | src/normalize-url.test.js | 156 | ||||
| -rw-r--r-- | src/resolve-request.js | 40 |
9 files changed, 530 insertions, 0 deletions
diff --git a/src/fetch-cache.js b/src/fetch-cache.js new file mode 100644 index 0000000..3fb6830 --- /dev/null +++ b/src/fetch-cache.js @@ -0,0 +1,34 @@ +const cache = caches.default + +export async function fetchCache(opts) { + const { event, cacheKey, fetch: fetchResponse } = opts + + let response + + if (cacheKey) { + console.log('cacheKey', cacheKey.url) + response = await cache.match(cacheKey) + } + + if (!response) { + response = await fetchResponse() + response = new Response(response.body, response) + + if (cacheKey) { + if (response.headers.has('Cache-Control')) { + // cache will respect response headers + event.waitUntil( + cache.put(cacheKey, response.clone()).catch((err) => { + console.warn('cache put error', cacheKey, err) + }) + ) + } + + response.headers.set('cf-cache-status', 'MISS') + } else { + response.headers.set('cf-cache-status', 'BYPASS') + } + } + + return response +} diff --git a/src/fetch-request.js b/src/fetch-request.js new file mode 100644 index 0000000..4e6c6ff --- /dev/null +++ b/src/fetch-request.js @@ -0,0 +1,64 @@ +import * as globalHeaders from './global-res-headers' + +const headerWhitelist = new Set([ + 'connection', + 'content-disposition', + 'content-type', + 'content-length', + 'cf-polished', + 'date', + 'status', + 'transfer-encoding' +]) + +export async function fetchRequest(event, { originReq }) { + // const originRes = await fetch(originReq) + + // console.log( + // 'req', + // originReq.url, + // Object.fromEntries(originReq.headers.entries()) + // ) + + const originRes = await fetch(originReq, { + cf: { + polish: 'lossy', + cacheEverything: true + } + }) + + // Construct a new response so we can mutate its headers + const res = new Response(originRes.body, originRes) + // console.log('res0', res.status, Object.fromEntries(res.headers.entries())) + + // Stripe additional headers from the response that may impact cacheability + // like content security policy stuff + normalizeResponseHeaders(res) + + // Override cache-control + res.headers.set( + 'cache-control', + 'public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60' + ) + + // Set CORS headers + for (const header of globalHeaders.globalResHeadersKeys) { + res.headers.set(header, globalHeaders.globalResHeaders[header]) + } + + // console.log('res1', res.status, Object.fromEntries(res.headers.entries())) + return res +} + +function normalizeResponseHeaders(res) { + const headers = Object.fromEntries(res.headers.entries()) + const keys = Object.keys(headers) + + for (const key of keys) { + if (!headerWhitelist.has(key)) { + res.headers.delete(key) + } + } + + return res +} diff --git a/src/get-request-cache-key.js b/src/get-request-cache-key.js new file mode 100644 index 0000000..9c98cb5 --- /dev/null +++ b/src/get-request-cache-key.js @@ -0,0 +1,60 @@ +import { normalizeUrl } from './normalize-url' + +export async function getRequestCacheKey(request) { + try { + // Respect "pragma: no-cache" header + const pragma = request.headers.get('pragma') + if (pragma === 'no-cache') { + return null + } + + // Only cache readonly requests + if (request.method !== 'GET' && request.method !== 'HEAD') { + return null + } + + // Respect "cache-control" header directives + const cacheControl = request.headers.get('cache-control') + if (cacheControl) { + const directives = new Set(cacheControl.split(',').map((s) => s.trim())) + if (directives.has('no-store') || directives.has('no-cache')) { + return null + } + } + + const url = request.url + const normalizedUrl = normalizeUrl(url) + + if (url !== normalizedUrl) { + return normalizeRequestHeaders( + new Request(normalizedUrl, { method: request.method }) + ) + } + + return normalizeRequestHeaders(new Request(request)) + } catch (err) { + console.error('error computing cache key', request.method, request.url, err) + return null + } +} + +const requestHeaderWhitelist = new Set([ + 'cache-control', + 'accept', + 'accept-encoding', + 'accept-language', + 'user-agent' +]) + +function normalizeRequestHeaders(request) { + const headers = Object.fromEntries(request.headers.entries()) + const keys = Object.keys(headers) + + for (const key of keys) { + if (!requestHeaderWhitelist.has(key)) { + request.headers.delete(key) + } + } + + return request +} diff --git a/src/global-res-headers.js b/src/global-res-headers.js new file mode 100644 index 0000000..56ac6ca --- /dev/null +++ b/src/global-res-headers.js @@ -0,0 +1,5 @@ +export const globalResHeaders = { + 'access-control-allow-origin': '*' +} + +export const globalResHeadersKeys = Object.keys(globalResHeaders) diff --git a/src/handle-options.js b/src/handle-options.js new file mode 100644 index 0000000..62cb977 --- /dev/null +++ b/src/handle-options.js @@ -0,0 +1,29 @@ +const allowedMethods = 'GET, HEAD, POST, PUT, DELETE, TRACE, PATCH, OPTIONS' + +export function handleOptions(request) { + // Make sure the necessary headers are present for this to be a valid pre-flight request + if ( + request.headers.get('Origin') !== null && + request.headers.get('Access-Control-Request-Method') !== null && + request.headers.get('Access-Control-Request-Headers') !== null + ) { + // Handle CORS pre-flight request. + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': allowedMethods, + 'Access-Control-Allow-Headers': request.headers.get( + 'Access-Control-Request-Headers' + ) + } + }) + } else { + // Handle standard OPTIONS request. + // If you want to allow other HTTP Methods, you can do that here. + return new Response(null, { + headers: { + Allow: allowedMethods + } + }) + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4557062 --- /dev/null +++ b/src/index.js @@ -0,0 +1,84 @@ +import { fetchCache } from './fetch-cache' +import { getRequestCacheKey } from './get-request-cache-key' +import { handleOptions } from './handle-options' +import { fetchRequest } from './fetch-request' +import { resolveRequest } from './resolve-request' +import { globalResHeaders } from './global-res-headers' + +addEventListener('fetch', (event) => { + event.respondWith(handleFetchEvent(event)) +}) + +/** + * @param {*} event + */ +async function handleFetchEvent(event) { + const gatewayStartTime = Date.now() + let gatewayTimespan + let res + + function recordTimespans() { + const now = Date.now() + gatewayTimespan = now - gatewayStartTime + } + + try { + const { request } = event + const { method } = request + + if (method === 'OPTIONS') { + return handleOptions(request) + } + + const { originReq } = await resolveRequest(event, request) + + try { + const cacheKey = await getRequestCacheKey(originReq) + const originRes = await fetchCache({ + event, + cacheKey, + fetch: () => fetchRequest(event, { originReq }) + }) + + res = new Response(originRes.body, originRes) + recordTimespans() + + res.headers.set('x-proxy-response-time', `${gatewayTimespan}ms`) + + return res + } catch (err) { + console.error(err) + recordTimespans() + + res = new Response( + JSON.stringify({ + error: err.message, + type: err.type, + code: err.code + }), + { status: 500, headers: globalResHeaders } + ) + + return res + } + } catch (err) { + console.error(err) + + if (err.response) { + // TODO: make sure this response also has CORS globalResHeaders + return err.response + } else { + return new Response( + JSON.stringify({ + error: err.message, + type: err.type, + code: err.code + }), + { + status: 500, + headers: globalResHeaders + } + ) + } + } +} diff --git a/src/normalize-url.js b/src/normalize-url.js new file mode 100644 index 0000000..c22e243 --- /dev/null +++ b/src/normalize-url.js @@ -0,0 +1,58 @@ +/** + * Stripped down version of [normalize-url](https://github.com/sindresorhus/normalize-url) + * by sindresorhus + * + * - always converts http => https + * - removed unused options + * - removed dataURL support + */ +export const normalizeUrl = (urlString) => { + const urlObj = new URL(urlString) + + if (urlObj.protocol === 'http:') { + urlObj.protocol = 'https:' + } + + /* + // Remove auth + // TODO: Cloudflare Workers seems to have a subtle bug where if you set URL.username and + // URL.password at all, it will include the @ authentication prefix in the resulting URL. + // This does not repro in normal web or Node.js contexts. + if (options.stripAuthentication) { + urlObj.username = '' + urlObj.password = '' + } + */ + + // Remove duplicate slashes if not preceded by a protocol + if (urlObj.pathname) { + urlObj.pathname = urlObj.pathname.replace(/(?<!https?:)\/{2,}/g, '/') + } + + // Decode URI octets + if (urlObj.pathname) { + urlObj.pathname = decodeURI(urlObj.pathname) + } + + if (urlObj.hostname) { + // Remove trailing dot + urlObj.hostname = urlObj.hostname.replace(/\.$/, '') + } + + // Sort query parameters + urlObj.searchParams.sort() + + // Remove trailing `/` + urlObj.pathname = urlObj.pathname.replace(/\/$/, '') + + urlString = urlObj.toString() + + // Remove trailing `/` for real this time + if (urlObj.pathname === '/' && urlObj.hash === '') { + urlString = urlString.replace(/\/$/, '') + } + + urlString = urlString.replace(/https:%2F%2F/g, 'https%3A%2F%2F') + + return urlString +} diff --git a/src/normalize-url.test.js b/src/normalize-url.test.js new file mode 100644 index 0000000..0d7a9da --- /dev/null +++ b/src/normalize-url.test.js @@ -0,0 +1,156 @@ +import test from 'ava' +import { normalizeUrl } from './normalize-url' + +test('main', (t) => { + t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') + t.is(normalizeUrl('http://sindresorhus.com '), 'https://sindresorhus.com') + t.is(normalizeUrl('http://sindresorhus.com.'), 'https://sindresorhus.com') + t.is(normalizeUrl('http://SindreSorhus.com'), 'https://sindresorhus.com') + t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') + t.is(normalizeUrl('HTTP://sindresorhus.com'), 'https://sindresorhus.com') + + // TODO: why isn't this parsed correctly by Node.js URL? + // t.is(normalizeUrl('//sindresorhus.com'), 'https://sindresorhus.com') + + t.is(normalizeUrl('http://sindresorhus.com'), 'https://sindresorhus.com') + t.is(normalizeUrl('http://sindresorhus.com:80'), 'https://sindresorhus.com') + t.is(normalizeUrl('https://sindresorhus.com:443'), 'https://sindresorhus.com') + t.is(normalizeUrl('ftp://sindresorhus.com:21'), 'ftp://sindresorhus.com') + t.is( + normalizeUrl('https://sindresorhus.com/foo/'), + 'https://sindresorhus.com/foo' + ) + t.is( + normalizeUrl('http://sindresorhus.com/?foo=bar baz'), + 'https://sindresorhus.com/?foo=bar+baz' + ) + t.is( + normalizeUrl('https://foo.com/https://bar.com'), + 'https://foo.com/https://bar.com' + ) + t.is( + normalizeUrl('https://foo.com/https://bar.com/foo//bar'), + 'https://foo.com/https://bar.com/foo/bar' + ) + t.is( + normalizeUrl('https://foo.com/http://bar.com'), + 'https://foo.com/http://bar.com' + ) + t.is( + normalizeUrl('https://foo.com/http://bar.com/foo//bar'), + 'https://foo.com/http://bar.com/foo/bar' + ) + t.is( + normalizeUrl('https://sindresorhus.com/%7Efoo/'), + 'https://sindresorhus.com/~foo', + 'decode URI octets' + ) + t.is(normalizeUrl('https://sindresorhus.com/?'), 'https://sindresorhus.com') + t.is(normalizeUrl('https://êxample.com'), 'https://xn--xample-hva.com') + t.is( + normalizeUrl('https://sindresorhus.com/?b=bar&a=foo'), + 'https://sindresorhus.com/?a=foo&b=bar' + ) + t.is( + normalizeUrl('https://sindresorhus.com/?foo=bar*|<>:"'), + 'https://sindresorhus.com/?foo=bar*%7C%3C%3E%3A%22' + ) + t.is( + normalizeUrl('https://sindresorhus.com:5000'), + 'https://sindresorhus.com:5000' + ) + t.is( + normalizeUrl('https://sindresorhus.com/foo#bar'), + 'https://sindresorhus.com/foo#bar' + ) + t.is( + normalizeUrl('https://sindresorhus.com/foo/bar/../baz'), + 'https://sindresorhus.com/foo/baz' + ) + t.is( + normalizeUrl('https://sindresorhus.com/foo/bar/./baz'), + 'https://sindresorhus.com/foo/bar/baz' + ) + t.is( + normalizeUrl( + 'https://i.vimeocdn.com/filter/overlay?src0=https://i.vimeocdn.com/video/598160082_1280x720.jpg&src1=https://f.vimeocdn.com/images_v6/share/play_icon_overlay.png' + ), + 'https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F598160082_1280x720.jpg&src1=https%3A%2F%2Ff.vimeocdn.com%2Fimages_v6%2Fshare%2Fplay_icon_overlay.png' + ) +}) + +test('removeTrailingSlash and removeDirectoryIndex options)', (t) => { + t.is( + normalizeUrl('https://sindresorhus.com/path/'), + 'https://sindresorhus.com/path' + ) + t.is( + normalizeUrl('https://sindresorhus.com/#/path/'), + 'https://sindresorhus.com/#/path/' + ) + t.is( + normalizeUrl('https://sindresorhus.com/foo/#/bar/'), + 'https://sindresorhus.com/foo#/bar/' + ) +}) + +test('sortQueryParameters', (t) => { + t.is( + normalizeUrl('https://sindresorhus.com/?a=Z&b=Y&c=X&d=W'), + 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' + ) + t.is( + normalizeUrl('https://sindresorhus.com/?b=Y&c=X&a=Z&d=W'), + 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' + ) + t.is( + normalizeUrl('https://sindresorhus.com/?a=Z&d=W&b=Y&c=X'), + 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' + ) + t.is(normalizeUrl('https://sindresorhus.com/'), 'https://sindresorhus.com') +}) + +test('invalid urls', (t) => { + t.throws(() => { + normalizeUrl('http://') + }, 'Invalid URL: http://') + + t.throws(() => { + normalizeUrl('/') + }, 'Invalid URL: /') + + t.throws(() => { + normalizeUrl('/relative/path/') + }, 'Invalid URL: /relative/path/') +}) + +test('remove duplicate pathname slashes', (t) => { + t.is( + normalizeUrl('https://sindresorhus.com////foo/bar'), + 'https://sindresorhus.com/foo/bar' + ) + t.is( + normalizeUrl('https://sindresorhus.com////foo////bar'), + 'https://sindresorhus.com/foo/bar' + ) + t.is( + normalizeUrl('ftp://sindresorhus.com//foo'), + 'ftp://sindresorhus.com/foo' + ) + t.is( + normalizeUrl('https://sindresorhus.com:5000///foo'), + 'https://sindresorhus.com:5000/foo' + ) + t.is( + normalizeUrl('https://sindresorhus.com///foo'), + 'https://sindresorhus.com/foo' + ) + t.is( + normalizeUrl('https://sindresorhus.com:5000//foo'), + 'https://sindresorhus.com:5000/foo' + ) + t.is( + normalizeUrl('https://sindresorhus.com//foo'), + 'https://sindresorhus.com/foo' + ) +}) diff --git a/src/resolve-request.js b/src/resolve-request.js new file mode 100644 index 0000000..75f1f11 --- /dev/null +++ b/src/resolve-request.js @@ -0,0 +1,40 @@ +const notionImagePrefix = 'https://www.notion.so/image/' + +/** + * @param {*} event + * @param {*} request + */ +export async function resolveRequest(event, request) { + const requestUrl = new URL(request.url) + let originUri = decodeURIComponent(requestUrl.pathname.slice(1)) + + // special-case optimization for notion images coming from unsplash + if (originUri.startsWith(notionImagePrefix)) { + const imageUri = decodeURIComponent( + originUri.slice(notionImagePrefix.length) + ) + const imageUrl = new URL(imageUri) + + // adjust unsplash defaults to have a max width and intelligent format conversion + if (imageUrl.hostname === 'images.unsplash.com') { + const { searchParams } = imageUrl + + if (!searchParams.has('w') && !searchParams.has('fit')) { + imageUrl.searchParams.set('w', 1920) + imageUrl.searchParams.set('fit', 'max') + } + + if (!searchParams.has('auto')) { + imageUrl.searchParams.set('auto', 'format') + } + + originUri = `${notionImagePrefix}${encodeURIComponent( + imageUrl.toString() + )}` + } + } + + const originReq = new Request(originUri, request) + + return { originReq } +} |