DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on • Updated on

Building a full stack app with Remix & Drizzle ORM: Upload images to Cloudflare

Introduction

In this tutorial series, we'll explore building a full stack application using Remix and Drizzle ORM. In this tutorial, we will work on the user profile page by incorporating image uploading functionality to Cloudflare. To achieve this, we'll utilize the AWS S3 SDK to generate presigned URLs, allowing users to upload their profile images directly to a Cloudflare R2 bucket securely. Additionally, we will explore the process of deleting user profiles.

Credit for inspiring this tutorial series goes Sabin Adams, whose insightful tutorial series served as a valuable source of inspiration for this project.

Overview

Please note that this tutorial assumes a certain level of familiarity with React.js, Node.js, and working with ORMs. In this tutorial we will be -

  • Developing the Image Uploader component
  • Building the user profile page
  • Setting up a Cloudflare R2 bucket
  • Uploading user profile images to the Cloudflare R2 bucket
  • Implementing the delete user functionality

All the code for this tutorial can be found here.

profile-page

Step 1: Image Uploader Component

Under components/templates create a new file ImageUploader.tsx-

import { useRef, useState } from "react";

type ImageUploaderProps = {
  onChange: (file: File) => any;
  imageUrl?: string | null;
};

export function ImageUploader({ onChange, imageUrl }: ImageUploaderProps) {
  const [draggingOver, setDraggingOver] = useState(false);
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const dropRef = useRef(null);

  const preventDefaults = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    preventDefaults(e);
    if (e.dataTransfer.files && e.dataTransfer.files[0]) {
      onChange(e.dataTransfer.files[0]);
      e.dataTransfer.clearData();
    }
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.currentTarget.files && event.currentTarget.files[0]) {
      onChange(event.currentTarget.files[0]);
    }
  };

  return (
    <div
      ref={dropRef}
      className={`${
        draggingOver
          ? "border-4 border-dashed border-yellow-300 border-rounded"
          : ""
      } group rounded-full relative w-24 h-24 flex justify-center items-center bg-gray-400 transition duration-300 ease-in-out hover:bg-gray-500 cursor-pointer`}
      style={{
        backgroundSize: "cover",
        ...(imageUrl ? { backgroundImage: `url(${imageUrl})` } : {}),
      }}
      onDragEnter={() => setDraggingOver(true)}
      onDragLeave={() => setDraggingOver(false)}
      onDrag={preventDefaults}
      onDragStart={preventDefaults}
      onDragEnd={preventDefaults}
      onDragOver={preventDefaults}
      onDrop={handleDrop}
      onClick={() => fileInputRef.current?.click()}
    >
      {imageUrl && (
        <div className="absolute w-full h-full bg-blue-400 opacity-50 rounded-full transition duration-300 ease-in-out group-hover:opacity-0" />
      )}
      {
        <p className="font-extrabold text-4xl text-gray-200 cursor-pointer select-none transition duration-300 ease-in-out group-hover:opacity-0 pointer-events-none z-10">
          +
        </p>
      }
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleChange}
        className="hidden"
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • It accepts an onChange function as a prop to handle the file selection or drop event.
  • The handleDrop function is triggered when a file is dropped onto the component, and it calls the onChange function with the dropped file.
  • The handleChange function is triggered when a file is selected using the file input element, and it also calls the onChange function with the selected file.
  • The component renders a styled div that serves as the drop area for files. It changes its appearance based on whether a file is being dragged over it. It also displays the selected image if an imageUrl prop is provided.

Step 2: User Profile Page

Lets create our database queries first, under services/user.server.ts -

export function updateUser(
  payload: Pick<User, "id" | "firstName" | "lastName">
) {
  return db
    .update(users)
    .set(payload)
    .where(eq(users.id, payload.id))
    .returning();
}

export function deleteUser(userId: string) {
  return db.delete(users).where(eq(users.id, userId));
}
Enter fullscreen mode Exit fullscreen mode

Now under routes create a new file home.profile.tsx -

import { json, redirect } from "@remix-run/node";
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { useState } from "react";
import { z } from "zod";
import { parse } from "@conform-to/zod";
import { conform, useForm } from "@conform-to/react";
import {
  Form,
  useActionData,
  useLoaderData,
  useNavigate,
  useNavigation,
} from "@remix-run/react";

import { requireUserLogin } from "~/services/sessions.server";
import { getUserById, updateUser } from "~/services/users.server";
import { ImageUploader, Modal } from "~/components/molecules";
import { Button, InputField } from "~/components/atoms";

const schema = z.object({
  id: z.string(),
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
});

export async function loader({ request }: LoaderArgs) {
  const userId = await requireUserLogin(request);
  const [user] = await getUserById(userId);
  return json({ user });
}

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const submission = parse(formData, { schema });

  if (!submission.value || submission.intent !== "submit") {
    return json(submission, { status: 400 });
  }

  await updateUser(submission.value);

  return redirect("/home");
}

export default function ProfileSettings() {
  const { user } = useLoaderData<typeof loader>();
  const lastSubmission = useActionData<typeof action>();
  const navigation = useNavigation();
  const navigate = useNavigate();

  const [form, { firstName, id, lastName }] = useForm({
    id: "profile",
    lastSubmission,
    defaultValue: {
      id: user.id,
      firstName: user.firstName,
      lastName: user.lastName,
    },
    onValidate({ formData }) {
      return parse(formData, { schema });
    },
  });

  const [profileUrl, setProfileUrl] = useState(user.profileUrl);

  const handleFileUpload = async (file: File) => {
    const inputFormData = new FormData();

    inputFormData.append("profile-pic", file);

    const response = await fetch("/avatar", {
      method: "POST",
      body: inputFormData,
    });

    const { imageUrl } = await response.json();

    setProfileUrl(imageUrl);
  };

  return (
    <Modal onOutsideClick={() => navigate("/home")} isOpen className="w-1/3">
      <div className="p-3">
        <h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">
          Your Profile
        </h2>
        <div className="flex">
          <div className="w-1/3">
            <ImageUploader onChange={handleFileUpload} imageUrl={profileUrl} />
          </div>
          <div className="flex-1">
            <Form method="post" {...form.props}>
              <input
                {...conform.input(id, {
                  hidden: true,
                })}
              />
              <InputField
                {...conform.input(firstName, { type: "text" })}
                label="First Name"
                error={firstName.error}
                errorId={firstName.errorId}
              />
              <InputField
                {...conform.input(lastName, { type: "text" })}
                label="Last Name"
                error={lastName.error}
                errorId={lastName.errorId}
              />
              <div className="mt-4 flex-col gap-1">
                <Button
                  className="w-full "
                  disabled={
                    navigation.state === "submitting" ||
                    navigation.state === "loading"
                  }
                  type="submit"
                >
                  Save
                </Button>
              </div>
            </Form>
          </div>
        </div>
      </div>
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The loader function retrieves the user data from the server and returns it as JSON.
  • The action function handles the form submission. It parses the form data using the provided schema and updates the user's information using the updateUser function. It then redirects the user to the home page.
  • When a file is uploaded using the image uploader, the handleFileUpload function is called. It creates a FormData object, appends the uploaded file to it, and sends a POST request to the "/avatar" endpoint (we will create it shortly).
  • The response contains the image URL, which is then set as the profileUrl state variable which is passed to the ImageUploader component.

Under templates/SearchPanel.tsx -

 <Avatar
   className="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"
   userProfile={getUserProfile(props.user)}
   onClick={() => navigate("profile")}
/>
Enter fullscreen mode Exit fullscreen mode

Step 3: Cloudflare Bucket & Credentials

First head over to Cloudflare dashboard and navigate to R2 and create a Bucket-
create-bucket
To grant public access to your Cloudflare R2 bucket, follow these steps:

  • Click on the bucket name to open it.
  • Navigate to the settings tab.
  • Scroll down until you find the option to allow access.
  • Click on "Allow Access" to enable public access to your bucket.
  • By enabling public access, anyone will be able to view the images stored in the bucket

To upload images to Cloudflare R2, we will utilize the AWS SDK. Despite the peculiar combination, Cloudflare is compatible with the AWS SDK. To establish a connection with R2, we require the following credentials:

  • Access Key ID: This is a unique identifier for authentication purposes. Click on the Manage R2 API Tokens on the R2 Page to generate the Access Key Id & Secret Access Key.
  • Secret Access Key: This key serves as the secret password corresponding to the Access Key ID.
  • Account ID: Your Cloudflare account ID. You can copy the account Id from your R2 page, it is right next to create bucket.
  • Bucket Name: The name of your R2 bucket.
  • Public URL: Obtained by enabling public access in the bucket settings. Check this video tutorial to know more.

Step 4: Upload file with AWS SDK

Even though our buckets have public access, it only enables viewing files, not uploading. To securely upload files, we utilize presigned URLs, which provide a secure method for file uploads. Presigned URLs ensure that only authorized users can upload files to our buckets. Presigned URLs are temporary URLs generated by AWS that grant time-limited access to upload files to a specific bucket. To upload files securely we do -

  • Generate a presigned URL using AWS SDK for the desired R2 bucket.
  • Provide the presigned URL to the client-side application.
  • The client-side application can then use the presigned URL to directly upload the file to the bucket. First install the necessary dependencies -
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner cuid
Enter fullscreen mode Exit fullscreen mode

Now under services create a new file r2.server.ts -

import { S3, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { fetch, unstable_parseMultipartFormData } from "@remix-run/node";
import type { UploadHandler } from "@remix-run/node";
import cuid from "cuid";

if (
  !process.env.CLOUDFLARE_PUBLIC_URL ||
  !process.env.CLOUDFLARE_R2_BUCKET_NAME ||
  !process.env.CLOUDFLARE_ACCOUNT_ID ||
  !process.env.CLOUDFLARE_R2_ACCESS_KEY_ID ||
  !process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY
) {
  throw new Error("Cloudflare Bucket not Setup");
}

const s3Client = new S3({
  region: "auto",
  endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
  },
});

export const uploadHandler: UploadHandler = async ({
  name,
  filename = "",
  data,
  contentType,
}) => {
  if (name !== "profile-pic") return;

  const objectName = `${cuid()}.${filename.split(".").slice(-1)}`;
  const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;

  const signedUrl = await getSignedUrl(
    s3Client,
    new PutObjectCommand({
      Bucket: bucketName,
      Key: objectName,
      ContentType: contentType,
    }),
    { expiresIn: 60 * 10 } // 600 seconds
  );

  const fileData = await convertToBuffer(data);

  try {
    await fetch(signedUrl, {
      method: "PUT",
      headers: {
        "Content-type": contentType,
      },
      body: fileData,
    });
  } catch (error) {
    console.log("Error Uploading file to cloudflare", error);
  }

  return `${process.env.CLOUDFLARE_PUBLIC_URL}/${objectName}`;
};

async function convertToBuffer(buffer: AsyncIterable<Uint8Array>) {
  const result = [];
  for await (const chunk of buffer) {
    result.push(chunk);
  }

  return Buffer.concat(result);
}

export async function uploadAvatar(request: Request) {
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  );

  const file = formData.get("profile-pic")?.toString() || "";

  return file;
}
Enter fullscreen mode Exit fullscreen mode
  • Create an S3 client using the AWS SDK with the provided credentials and endpoint, set region to auto.
  • Define an upload handler function that receives the file data and information.
  • Generate a unique object name for the uploaded file using cuid and retrieve the bucket name from environment variables.
  • Generate a presigned URL using the S3 client PutObjectCommand for uploading the file.
  • Use the presigned URL to upload the file to the Cloudflare R2 bucket using a PUT request and return the public URL of the uploaded file. Finally, under the routes create avatar.ts file -
import type { ActionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { eq } from "drizzle-orm";
import { db } from "~/drizzle/config.db.server";
import { users } from "~/drizzle/schemas/users.db.server";

import { uploadAvatar } from "~/services/r2.server";
import { requireUserLogin } from "~/services/sessions.server";

export async function action({ request }: ActionArgs) {
  const userId = await requireUserLogin(request);

  const imageUrl = await uploadAvatar(request);

  await db
    .update(users)
    .set({
      profileUrl: imageUrl,
    })
    .where(eq(users.id, userId));

  return json({ imageUrl });
}
Enter fullscreen mode Exit fullscreen mode

Let me re-iterate the file upload flow -

  • User submits the file from the profile page.
  • The file is sent to the "/avatar" endpoint. The action function handles the uploading the file and updating the user's profileUrl column in the database table.
  • The uploadAvatar function is used to upload the file to the R2 bucket.
  • After successful file upload, the imageUrl is returned as the response. The imageUrl is displayed in the ImageUploader component.

From your terminal run npm run dev and try uploading profile images. You can also update the user information without uploading profile images.

Step 5: Delete user

Under routes/home.profile.tsx add the following button and the necessary attributes -

<div className="mt-4 flex-col gap-1">
  <Button
    className="w-full"
    name="_action"
    value="save"
    disabled={navigation.state === "submitting" || navigation.state === "loading"}
    type="submit"
  >
    Save
  </Button>
  <Button
    className="w-full bg-red-300 hover:bg-red-400"
    name="_action"
    value="delete"
    disabled={navigation.state === "submitting" || navigation.state === "loading"}
    type="submit"
  >
    Delete
  </Button>
</div>
Enter fullscreen mode Exit fullscreen mode
  • The name attribute is used to specify the name of the input field when the form is submitted. In this case, the name is set as "_action" for both buttons.
  • The "_action" value is used to indicate the specific action that will be triggered when the form is submitted. It distinguishes between different actions that can be performed, such as "save" or "delete". The value of _action is set as "save" for the first button and "delete" for the second button. Finally update the action function for this file -
export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const action = formData.get("_action");
  const submission = parse(formData, { schema });

  if (action === "save") {
    if (!submission.value || submission.intent !== "submit") {
      return json(submission, { status: 400 });
    }

    await updateUser(submission.value);
    return redirect("/home");
  }

  if (action === "delete" && submission.value?.id) {
    await deleteUser(submission.value.id);
    return logout(request);
  }

  return redirect("/home");
}
Enter fullscreen mode Exit fullscreen mode
  • If the action is "save", it checks if the submission is valid and updates the user with the submitted values. It then redirects to the "/home" page.
  • If the action is "delete", it deletes the user and logs out the current user.
  • When we delete a user, the kudos table has a cascade relationship with the users table based on the authorId and recipientId. This means that when a user is deleted, all the kudos associated with them, both authored and received, will also be deleted automatically.

Conclusion

In this post, we covered important functionalities for user management. We implemented the ability to update user information, upload profile images to Cloudflare R2, and delete user accounts. In the next tutorial we will implement use drizzle relationships for querying and deploy our app to Vercel. All the code for this tutorial can be found here. Until next time PEACE!

Top comments (0)