aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorTravis Fischer <[email protected]>2021-06-02 13:59:27 -0400
committerTravis Fischer <[email protected]>2021-06-02 13:59:27 -0400
commitf6321fc3249d83a0059ef47978ed101d3c75375f (patch)
tree9da8b59a0c94c08f92506e57653b8c9f55fa85e8 /src
downloadcf-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.js34
-rw-r--r--src/fetch-request.js64
-rw-r--r--src/get-request-cache-key.js60
-rw-r--r--src/global-res-headers.js5
-rw-r--r--src/handle-options.js29
-rw-r--r--src/index.js84
-rw-r--r--src/normalize-url.js58
-rw-r--r--src/normalize-url.test.js156
-rw-r--r--src/resolve-request.js40
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 }
+}