From dd8c0b576199c71a75ee95a8411d2e683bd18371 Mon Sep 17 00:00:00 2001 From: Luke Stanley <306671+lukestanley@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:30:26 +0000 Subject: [PATCH 1/4] Adds Backblaze B2 proxy function for Cloudflare workers --- README.md | 2 + functions/README.md | 2 + functions/cloudflare_b2_proxy.js | 113 +++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 functions/README.md create mode 100644 functions/cloudflare_b2_proxy.js diff --git a/README.md b/README.md index 32b38bb..4751969 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ The url to proxy is taken from the "target-url" header. The proxy does not put any restrictions on the http methods or headers, except for cookies. Requesting [user credentials](http://www.w3.org/TR/cors/#user-credentials) is disallowed. +## There is also a seperate function directory for small, single file isolated scripts. + ## Documentation ### Kendraio App proxy default diff --git a/functions/README.md b/functions/README.md new file mode 100644 index 0000000..2d768d5 --- /dev/null +++ b/functions/README.md @@ -0,0 +1,2 @@ +This directory has self contained proxy functions, including a proxy for Backblaze's B2 service. +They can be ran as isolated Cloudflare Workers. \ No newline at end of file diff --git a/functions/cloudflare_b2_proxy.js b/functions/cloudflare_b2_proxy.js new file mode 100644 index 0000000..8bd8045 --- /dev/null +++ b/functions/cloudflare_b2_proxy.js @@ -0,0 +1,113 @@ +/** + * Cloudflare Workers script to proxy requests to Backblaze B2 with + * CORS headers and key from environment variables, to sign URLs + * + * Learn more at https://developers.cloudflare.com/workers/ + */ + +export default { + async fetch(request, env, ctx) { + const corsHeaders = { + 'Access-Control-Allow-Origin': 'https://app.kendra.io', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; + + // Handle OPTIONS request + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + const url = new URL(request.url); + + // Return hello world if no path specified + if (url.pathname === "/" || !url.pathname) { + return new Response("Hello World!", { + headers: { + "content-type": "text/plain", + ...corsHeaders + }, + }); + } + + const fileName = url.pathname.split("/").pop(); + + // Get configuration from environment variables + const B2_APPLICATION_KEY_ID = env.B2_APPLICATION_KEY_ID; + const B2_APPLICATION_KEY = env.B2_APPLICATION_KEY; + const B2_BUCKET_ID = env.B2_BUCKET_ID; + const B2_BUCKET_NAME = env.B2_BUCKET_NAME; + + console.log(`Processing request for file: ${fileName}`); + console.log(`Using bucket: ${B2_BUCKET_NAME} (${B2_BUCKET_ID})`); + + // Step 1: Authorise with Backblaze B2 + const authResponse = await fetch('https://api.backblazeb2.com/b2api/v2/b2_authorize_account', { + headers: { + Authorization: 'Basic ' + btoa(B2_APPLICATION_KEY_ID + ':' + B2_APPLICATION_KEY), + } + }); + + if (!authResponse.ok) { + console.log(`B2 authorisation failed with status: ${authResponse.status}`); + return new Response('Error authorising B2 account', { + status: 500, + headers: corsHeaders + }); + } + console.log('B2 authorisation successful'); + + const authData = await authResponse.json(); + const apiUrl = authData.apiUrl; + + // Step 2: Get Download Authorisation + const downloadAuthorisation = await fetch(`${apiUrl}/b2api/v2/b2_get_download_authorization`, { + method: 'POST', + headers: { + Authorization: authData.authorizationToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bucketId: B2_BUCKET_ID, + fileNamePrefix: fileName, + validDurationInSeconds: 360000, // 100 hour expiry + }), + }); + + if (!downloadAuthorisation.ok) { + console.error(`Download authorisation failed with status: ${downloadAuthorisation.status}`); + return new Response('Error generating signed URL', { + status: 500, + headers: corsHeaders + }); + } + console.log('Download authorisation successful'); + + const signedUrlData = await downloadAuthorisation.json(); + const downloadUrl = `${authData.downloadUrl}/file/${B2_BUCKET_NAME}/${fileName}?Authorization=${signedUrlData.authorizationToken}`; + + // Step 3: Fetch the content from signed URL + console.log(`Fetching content from signed URL for: ${fileName}`); + const fileResponse = await fetch(downloadUrl); + + if (!fileResponse.ok) { + console.error(`File fetch failed with status: ${fileResponse.status}`); + return new Response('Error fetching file content', { + status: 500, + headers: corsHeaders + }); + } + + // Return the file content with original headers plus CORS + const responseHeaders = new Headers(fileResponse.headers); + Object.entries(corsHeaders).forEach(([key, value]) => { + responseHeaders.set(key, value); + }); + + return new Response(fileResponse.body, { + status: 200, + headers: responseHeaders + }); + } + }; + \ No newline at end of file From 294f6b552c93f3e200e763644fa0c5e99d61b7cf Mon Sep 17 00:00:00 2001 From: Luke Stanley Date: Mon, 16 Dec 2024 16:41:32 +0000 Subject: [PATCH 2/4] Renames B2 proxy filename, improves comments, provides more clear HTTP root path response --- functions/{cloudflare_b2_proxy.js => b2_proxy.js} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename functions/{cloudflare_b2_proxy.js => b2_proxy.js} (89%) diff --git a/functions/cloudflare_b2_proxy.js b/functions/b2_proxy.js similarity index 89% rename from functions/cloudflare_b2_proxy.js rename to functions/b2_proxy.js index 8bd8045..0bdcb2f 100644 --- a/functions/cloudflare_b2_proxy.js +++ b/functions/b2_proxy.js @@ -1,8 +1,8 @@ /** - * Cloudflare Workers script to proxy requests to Backblaze B2 with - * CORS headers and key from environment variables, to sign URLs - * - * Learn more at https://developers.cloudflare.com/workers/ + * Worker script to proxy requests to Backblaze B2 with CORS + * headers and key from environment variables, to sign URLs. + * Compatible with Cloudflare Workers. + * See https://developers.cloudflare.com/workers/ */ export default { @@ -20,9 +20,9 @@ export default { const url = new URL(request.url); - // Return hello world if no path specified + // Return response if no path is specified if (url.pathname === "/" || !url.pathname) { - return new Response("Hello World!", { + return new Response("The proxy server is active. This is the root path. Note: To access a file resource, append the filename to the path.", { headers: { "content-type": "text/plain", ...corsHeaders From 35945c46bc7a9e58f6e2f07824b436f3f70fca3b Mon Sep 17 00:00:00 2001 From: Luke Stanley Date: Mon, 16 Dec 2024 16:47:09 +0000 Subject: [PATCH 3/4] B2 proxy: Improve readability of URL construction --- functions/b2_proxy.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/functions/b2_proxy.js b/functions/b2_proxy.js index 0bdcb2f..84b08b3 100644 --- a/functions/b2_proxy.js +++ b/functions/b2_proxy.js @@ -84,8 +84,11 @@ export default { console.log('Download authorisation successful'); const signedUrlData = await downloadAuthorisation.json(); - const downloadUrl = `${authData.downloadUrl}/file/${B2_BUCKET_NAME}/${fileName}?Authorization=${signedUrlData.authorizationToken}`; - + + const b2DownloadEndpoint = authData.downloadUrl; + const downloadAuthToken = signedUrlData.authorizationToken; + const downloadUrl = `${b2DownloadEndpoint}/file/${B2_BUCKET_NAME}/${fileName}?Authorization=${downloadAuthToken}`; + // Step 3: Fetch the content from signed URL console.log(`Fetching content from signed URL for: ${fileName}`); const fileResponse = await fetch(downloadUrl); From ac9ec8528525c588831c716a81bc3e815c9e8e1d Mon Sep 17 00:00:00 2001 From: Luke Stanley Date: Tue, 17 Dec 2024 10:38:33 +0000 Subject: [PATCH 4/4] B2 proxy: Fetch logic error handling --- functions/b2_proxy.js | 184 +++++++++++++++++++++++------------------- 1 file changed, 100 insertions(+), 84 deletions(-) diff --git a/functions/b2_proxy.js b/functions/b2_proxy.js index 84b08b3..dd9acf7 100644 --- a/functions/b2_proxy.js +++ b/functions/b2_proxy.js @@ -1,88 +1,104 @@ /** - * Worker script to proxy requests to Backblaze B2 with CORS + * Worker script to proxy requests to Backblaze B2 with CORS * headers and key from environment variables, to sign URLs. * Compatible with Cloudflare Workers. * See https://developers.cloudflare.com/workers/ */ +const handleFetchError = (error, message, corsHeaders) => { + console.error(`${message}: ${error.message}`); + return new Response(`${message}`, { + status: 500, + headers: corsHeaders + }); +}; + +const safeFetch = async (url, options, errorMessage, corsHeaders) => { + try { + const response = await fetch(url, options); + if (!response.ok) { + console.error(`${errorMessage} failed with status: ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}`); + } + return response; + } catch (error) { + throw new Error(`${errorMessage}: ${error.message}`); + } +}; + export default { - async fetch(request, env, ctx) { - const corsHeaders = { - 'Access-Control-Allow-Origin': 'https://app.kendra.io', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; - - // Handle OPTIONS request - if (request.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - const url = new URL(request.url); - - // Return response if no path is specified - if (url.pathname === "/" || !url.pathname) { - return new Response("The proxy server is active. This is the root path. Note: To access a file resource, append the filename to the path.", { - headers: { - "content-type": "text/plain", - ...corsHeaders - }, - }); - } - - const fileName = url.pathname.split("/").pop(); - - // Get configuration from environment variables - const B2_APPLICATION_KEY_ID = env.B2_APPLICATION_KEY_ID; - const B2_APPLICATION_KEY = env.B2_APPLICATION_KEY; - const B2_BUCKET_ID = env.B2_BUCKET_ID; - const B2_BUCKET_NAME = env.B2_BUCKET_NAME; - - console.log(`Processing request for file: ${fileName}`); - console.log(`Using bucket: ${B2_BUCKET_NAME} (${B2_BUCKET_ID})`); - - // Step 1: Authorise with Backblaze B2 - const authResponse = await fetch('https://api.backblazeb2.com/b2api/v2/b2_authorize_account', { - headers: { - Authorization: 'Basic ' + btoa(B2_APPLICATION_KEY_ID + ':' + B2_APPLICATION_KEY), - } + async fetch(request, env, ctx) { + const corsHeaders = { + 'Access-Control-Allow-Origin': 'https://app.kendra.io', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; + + // Handle OPTIONS request + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + const url = new URL(request.url); + + // Return response if no path is specified + if (url.pathname === "/" || !url.pathname) { + return new Response("The proxy server is active. This is the root path. Note: To access a file resource, append the filename to the path.", { + headers: { + "content-type": "text/plain", + ...corsHeaders + }, }); - - if (!authResponse.ok) { - console.log(`B2 authorisation failed with status: ${authResponse.status}`); - return new Response('Error authorising B2 account', { - status: 500, - headers: corsHeaders - }); - } + } + + const fileName = url.pathname.split("/").pop(); + + // Get configuration from environment variables + const B2_APPLICATION_KEY_ID = env.B2_APPLICATION_KEY_ID; + const B2_APPLICATION_KEY = env.B2_APPLICATION_KEY; + const B2_BUCKET_ID = env.B2_BUCKET_ID; + const B2_BUCKET_NAME = env.B2_BUCKET_NAME; + + console.log(`Processing request for file: ${fileName}`); + console.log(`Using bucket: ${B2_BUCKET_NAME} (${B2_BUCKET_ID})`); + + try { + // Step 1: Authorise with Backblaze B2 + const authResponse = await safeFetch( + 'https://api.backblazeb2.com/b2api/v2/b2_authorize_account', + { + headers: { + Authorization: 'Basic ' + btoa(B2_APPLICATION_KEY_ID + ':' + B2_APPLICATION_KEY), + } + }, + 'B2 authorization', + corsHeaders + ); console.log('B2 authorisation successful'); - + const authData = await authResponse.json(); const apiUrl = authData.apiUrl; - + // Step 2: Get Download Authorisation - const downloadAuthorisation = await fetch(`${apiUrl}/b2api/v2/b2_get_download_authorization`, { - method: 'POST', - headers: { - Authorization: authData.authorizationToken, - 'Content-Type': 'application/json', + const downloadAuthorisation = await safeFetch( + `${apiUrl}/b2api/v2/b2_get_download_authorization`, + { + method: 'POST', + headers: { + Authorization: authData.authorizationToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bucketId: B2_BUCKET_ID, + fileNamePrefix: fileName, + validDurationInSeconds: 360000, + }), }, - body: JSON.stringify({ - bucketId: B2_BUCKET_ID, - fileNamePrefix: fileName, - validDurationInSeconds: 360000, // 100 hour expiry - }), - }); - - if (!downloadAuthorisation.ok) { - console.error(`Download authorisation failed with status: ${downloadAuthorisation.status}`); - return new Response('Error generating signed URL', { - status: 500, - headers: corsHeaders - }); - } + 'Download authorization', + corsHeaders + ); console.log('Download authorisation successful'); - + const signedUrlData = await downloadAuthorisation.json(); const b2DownloadEndpoint = authData.downloadUrl; @@ -91,26 +107,26 @@ export default { // Step 3: Fetch the content from signed URL console.log(`Fetching content from signed URL for: ${fileName}`); - const fileResponse = await fetch(downloadUrl); - - if (!fileResponse.ok) { - console.error(`File fetch failed with status: ${fileResponse.status}`); - return new Response('Error fetching file content', { - status: 500, - headers: corsHeaders - }); - } - + const fileResponse = await safeFetch( + downloadUrl, + {}, + 'File content fetch', + corsHeaders + ); + // Return the file content with original headers plus CORS const responseHeaders = new Headers(fileResponse.headers); Object.entries(corsHeaders).forEach(([key, value]) => { responseHeaders.set(key, value); }); - + return new Response(fileResponse.body, { status: 200, headers: responseHeaders }); + + } catch (error) { + return handleFetchError(error, error.message || 'Error processing request', corsHeaders); } - }; - \ No newline at end of file + } +};