Skip to content

Custom access control for files in R2 using D1 and Workers

Last reviewed: 6 months ago

Developer Spotlight community contribution

Written by: Dominik Fuerst

Profile: GitHub

This tutorial gives you an overview on how to create a TypeScript-based Cloudflare Worker which allows you to control file access based on a simple username and password authentication. To achieve this, we will use a D1 database for user management and an R2 bucket for file storage.

The following sections will guide you through the process of creating a Worker using the Cloudflare CLI, creating and setting up a D1 database and R2 bucket, and then implementing the functionality to securely upload and fetch files from the created R2 bucket.

Prerequisites

  1. Sign up for a Cloudflare account.
  2. Install Node.js.

Node.js version manager

Use a Node version manager like Volta or nvm to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.

1. Create a new Worker application

To get started developing your Worker you will use the create-cloudflare CLI. To do this, open a terminal window and run the following command:

Terminal window
npm create cloudflare@latest -- custom-access-control

For setup, select the following options:

  • For What would you like to start with?, choose Hello World example.
  • For Which template would you like to use?, choose Hello World Worker.
  • For Which language do you want to use?, choose TypeScript.
  • For Do you want to use git for version control?, choose Yes.
  • For Do you want to deploy your application?, choose No (we will be making some changes before deploying).

Then, move into your newly created Worker:

Terminal window
cd custom-access-control

2. Create a new D1 database and binding

Now that you have created your Worker, next you will need to create a D1 database. This can be done through the Cloudflare Portal or the Wrangler CLI. For this tutorial, we will use the Wrangler CLI for simplicity.

To create a D1 database, just run the following command. If you get asked to install wrangler, just confirm by pressing y and then press Enter.

Terminal window
npx wrangler d1 create <YOUR_DATABASE_NAME>

Replace <YOUR_DATABASE_NAME> with the name you want to use for your database. Keep in mind that this name can't be changed later on.

After the database is successfully created, you will see the data for the binding displayed as an output. The binding declaration will start with [[d1_databases]] and contain the binding name, database name and ID. To use the database in your worker, you will need to add the binding to your wrangler.toml file, by copying the declaration and pasting it into the wrangler file, as shown in the example below.

[[d1_databases]]
binding = "DB"
database_name = "<YOUR_DATABASE_NAME>"
database_id = "<YOUR_DATABASE_ID>"

3. Create R2 bucket and binding

Now that the D1 database is created, you also need to create an R2 bucket which will be used to store the uploaded files. This step can also be done through the Cloudflare Portal, but as before, we will use the Wrangler CLI for this tutorial. To create an R2 bucket, run the following command:

Terminal window
npx wrangler r2 bucket create <YOUR_BUCKET_NAME>

This works similar to the D1 database creation, where you will need to replace <YOUR_BUCKET_NAME> with the name you want to use for your bucket. To do this, go to the wrangler.toml file again and then add the following lines:

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "<YOUR_BUCKET_NAME>"

Now that you have prepared the Wrangler configuration, you should update the worker-configuration.d.ts file to include the new bindings. This file will then provide TypeScript with the correct type definitions for the bindings, which allows for type checking and code completion in your editor. You could either update it manually or run the following command in the directory of your project to update it automatically based on the wrangler configuration file (recommended).

Terminal window
npm run cf-typegen

4. Database preparation

Before you can start developing the Worker, you need to prepare the D1 database.

For this you need to

  1. Create a table in the database which will then be used to store the user data
  2. Create a unique index on the username column, which will speed up database queries and ensure that the username is unique
  3. Insert a test user into the table, so you can test your code later on

As this operation only needs to be done once, this will be done through the Wrangler CLI and not in the Worker's code. Copy the commands listed below, replace the placeholders and then run them in order to prepare the database. For this tutorial you can replace the <YOUR_USERNAME> and <YOUR_HASHED_PASSWORD> placeholders with admin and 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 respecively. And <YOUR_DATABASE_NAME> should be replaced with the name you used to create the database.

Terminal window
npx wrangler d1 execute <YOUR_DATABASE_NAME> --command "CREATE TABLE user (id INTEGER PRIMARY KEY NOT NULL, username STRING NOT NULL, password STRING NOT NULL)" --remote
npx wrangler d1 execute <YOUR_DATABASE_NAME> --command "CREATE UNIQUE INDEX user_username ON user (username)" --remote
npx wrangler d1 execute <YOUR_DATABASE_NAME> --command "INSERT INTO user (username, password) VALUES ('<YOUR_USERNAME>', '<YOUR_HASHED_PASSWORD>')" --remote

5. Implement authentication in the Worker

Now that the database and bucket are all set up, you can start to develop the Worker application. The first thing you will need to do is to implement the authentication for the requests.

This tutorial will use a simple username and password authentication, where the username and password (hashed) are stored in the D1 database. The requests will contain the username and password as a base64 encoded string, which is also called Basic Authentication. Depending on the request method, this string will be retrieved from the Authorization header for POST requests or the Authorization search parameter for GET requests.

To handle the authentication, you will need to replace the current code within index.ts file with the following code:

export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
try {
const url = new URL(request.url);
let authBase64;
if (request.method === "POST") {
authBase64 = request.headers.get("Authorization");
} else if (request.method === "GET") {
authBase64 = url.searchParams.get("Authorization");
} else {
return new Response("Method Not Allowed!", { status: 405 });
}
if (!authBase64 || authBase64.substring(0, 6) !== "Basic ") {
return new Response("Unauthorized!", { status: 401 });
}
const authString = atob(authBase64.substring(6));
const [username, password] = authString.split(":");
if (!username || !password) {
return new Response("Unauthorized!", { status: 401 });
}
// TODO: Check if the username and password are correct
} catch (error) {
console.error("An error occurred!", error);
return new Response("Internal Server Error!", { status: 500 });
}
},
};

The code above currently extracts the username and password from the request, but does not yet check if the username and password are correct.

To check the username and password, you will need to hash the password and then query the D1 database table user with the given username and hashed password. If the username and password are correct, you will retrieve a record from D1. If the username or password is incorrect, undefined will be returned and a 401 Unauthorized response will be sent. To add this functionality, you will need to add the following code to the fetch function by replacing the TODO comment from the last code snippet:

const passwordHashBuffer = await crypto.subtle.digest(
{ name: "SHA-256" },
new TextEncoder().encode(password),
);
const passwordHashArray = Array.from(new Uint8Array(passwordHashBuffer));
const passwordHashString = passwordHashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const user = await env.DB.prepare(
"SELECT id FROM user WHERE username = ? AND password = ? LIMIT 1",
)
.bind(username, passwordHashString)
.first<{ id: number }>();
if (!user) {
return new Response("Unauthorized!", { status: 401 });
}
// TODO: Implement upload functionality

This code will now ensure that every request is authenticated before it can be processed further.

6. Upload a file through the Worker

Now that the authentication is set up, you can start to implement the functionality for uploading a file through the Worker. To do this, you will need to add a new code path that handles HTTP POST requests. Then within it, you will need to get the data from the request, which is sent within the body of the request, by using the request.blob() function. After that, you can upload the data to the R2 bucket by using the env.BUCKET.put function. And finally, you will return a 200 OK response to the client.

To implement this functionality, you will need to replace the TODO comment from the last code snippet with the following code:

if (request.method === "POST") {
// Upload the file to the R2 bucket with the user id followed by a slash as the prefix and then the path of the URL
await env.BUCKET.put(`${user.id}/${url.pathname}`, request.body);
return new Response("OK", { status: 200 });
}
// TODO: Implement GET request handling

This code will now allow you to upload a file through the Worker, which will be stored in your R2 bucket.

7. Fetch from the R2 bucket

To round up the Worker application, you will need to implement the functionality to fetch files from the R2 bucket. This can be done by adding a new code path that handles GET requests. Within this code path, you will need to extract the URL pathname and then retrieve the asset from the R2 bucket by using the env.BUCKET.get function.

To finalize the code, just replace the TODO comment for handling GET requests from the last code snippet with the following code:

if (request.method === "GET") {
const file = await env.BUCKET.get(`${user.id}/${url.pathname.slice(1)}`);
if (!file) {
return new Response("Not Found!", { status: 404 });
}
const headers = new Headers();
file.writeHttpMetadata(headers);
return new Response(file.body, { headers });
}
return new Response("Method Not Allowed!", { status: 405 });

This code now allows you to fetch and return data from the R2 bucket when a GET request is made to the Worker application.

8. Deploy your Worker

After completing the code for this Cloudflare Worker tutorial, you will need to deploy it to Cloudflare. To do this open the terminal in the directory created for your application, and then run:

Terminal window
npx wrangler deploy

You might get asked to authenticate (if not logged in already) and select an account. After that, the Worker will be deployed to Cloudflare. When the deployment finished successfully, you will see a success message with the URL where your Worker is now accessible.

9. Test your Worker (optional)

To finish this tutorial, you should test your Worker application by sending a POST request to upload a file and after that a GET request to fetch the file. This can be done by using a tool like curl or Postman, but for simplicity, this will describe the usage of curl.

Copy the following command which can be used to upload a simple JSON file with the content {"Hello": "Worker!"}. Replace <YOUR_API_SECRET> with the base64 encoded username and password combination and then run the command. For this example you can use YWRtaW46cGFzc3dvcmQ=, which can be decoded to admin and test, for the api secret placeholder.

Terminal window
curl --location '<YOUR_WORKER_URL>/myFile.json' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic <YOUR_API_SECRET>' \
--data '{
"Hello": "Worker!"
}'

Then run the next command, or simply open the URL in your browser, to fetch the file you just uploaded:

Terminal window
curl --location '<YOUR_WORKER_URL>/myFile.json?Authorization=Basic%20YWRtaW46cGFzc3dvcmQ%3D'

Next steps

If you want to learn more about Cloudflare Workers, R2, or D1 you can check out the following documentation: