RiserBob logoRiserBobv1.0.0
HomeDocsCLIRelease notes

Getting started

  • Introduction
  • Installation
  • Quick start
  • Project structure

Core concepts

  • Routing
  • Request
  • Responses
  • Error handling

Middleware

  • Middleware
  • Authentication
  • Logger and CORS

Data

  • Validation

Structure and tooling

  • Modules
  • CLI

Examples

  • Build a project
  • Mongoose
  • Drizzle ORM
  • Sequelize
  • JWT and cookies
  • File uploads

Packages

  • Cookies

Resources

  • Troubleshooting
  • Release notes
  • License
  • Security
  • Contributing
Examples

File uploads

Accept multipart form data and store images in Cloudinary, with validation and cleanup.

This page shows how to accept an image upload, check it, store it in Cloudinary and keep its address in your database. The example is a profile update that takes a bio and two optional images, avatar and cover.

Install

Terminal
bun add cloudinary

Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET in .env, with the values from your Cloudinary dashboard.

Configure Cloudinary

src/config/cloudinary/cloudinary-config.ts
import { v2 as cloudinary } from "cloudinary"; export class CloudinaryConfig {  static configure(): void {    const cloudName = process.env.CLOUDINARY_CLOUD_NAME;    const apiKey = process.env.CLOUDINARY_API_KEY;    const apiSecret = process.env.CLOUDINARY_API_SECRET;     if (!cloudName || !apiKey || !apiSecret) {      throw new Error("Missing Cloudinary environment variables");    }     cloudinary.config({      cloud_name: cloudName,      api_key: apiKey,      api_secret: apiSecret,      secure: true,    });  }}

The upload manager

A File from formData() becomes a buffer, which is streamed to Cloudinary. The manager returns the public URL and the publicId. Keep the publicId, because it is the only way to delete the image later.

src/config/cloudinary/cloudinary.ts
import { v2 as cloudinary } from "cloudinary";import { Readable } from "node:stream";import { CloudinaryConfig } from "./cloudinary-config"; export interface UploadedImage {  url: string;  publicId: string;} CloudinaryConfig.configure(); export class CloudinaryManager {  async upload(file: File, folder: string): Promise<UploadedImage> {    const buffer = Buffer.from(await file.arrayBuffer());     return new Promise((resolve, reject) => {      const stream = cloudinary.uploader.upload_stream(        { folder, resource_type: "image" },        (error, result) => {          if (error) {            reject(error);            return;          }           if (!result) {            reject(new Error("Cloudinary upload failed"));            return;          }           resolve({ url: result.secure_url, publicId: result.public_id });        },      );       Readable.from(buffer).pipe(stream);    });  }   async destroy(publicId: string): Promise<void> {    const result = await cloudinary.uploader.destroy(publicId);     if (result.result !== "ok" && result.result !== "not found") {      throw new Error("Cloudinary image deletion failed");    }  }}

The rule

src/modules/profiles/rules/update.rule.ts
import type { ValidationSchema } from "riser-bob"; export const UpdateProfileRule = {  bio: ["nullable", "string", "minLength:3", "maxLength:255"],} satisfies ValidationSchema;

The controller

The handler reads spongReq.formData(), validates the text field with the Validator, and checks every file before it uploads anything, so a bad cover never leaves an orphaned avatar behind. If saving fails after the uploads, the new images are removed again.

src/modules/profiles/profiles.controller.ts
import type { Model } from "mongoose";import { Validator } from "riser-bob";import type { SpongHandler } from "riser-bob";import type { CloudinaryManager } from "../../config/cloudinary/cloudinary";import { UpdateProfileRule } from "./rules/update.rule"; export interface IProfile {  _id: string;  userId: string;  avatar: { url: string; publicId: string };  cover: { url: string; publicId: string };  bio: string;} const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB function checkImage(file: unknown, label: string): string | null {  if (!(file instanceof File) || file.size === 0) return null; // not sent   if (!file.type.startsWith("image/")) return label + " must be an image";  if (file.size > MAX_IMAGE_SIZE) return label + " must not exceed 5 MB";   return null;} export class ProfilesController {  constructor(    private readonly profileModel: Model<IProfile>,    private readonly cloudinary: CloudinaryManager,  ) {}   readonly update: SpongHandler = async (spongReq, spongRes) => {    const user = spongReq.user;     if (!user) {      return spongRes.SpongUnauthorized({        success: false,        message: "You are not authenticated",      });    }     const formData = await spongReq.formData();     const bio = formData.get("bio");    const avatar = formData.get("avatar");    const cover = formData.get("cover");     const result = Validator.validate<{ bio: string }>(      { bio: typeof bio === "string" ? bio : "" },      UpdateProfileRule,    );     if (!result.valid) {      return spongRes.SpongUnprocessableContent({        success: false,        message: "Validation failed",        errors: result.errors,      });    }     // Check every file first, then upload.    const problem = checkImage(avatar, "Avatar") ?? checkImage(cover, "Cover");     if (problem) {      return spongRes.SpongBadRequest({ success: false, message: problem });    }     const profile = await this.profileModel.findOne({ userId: user.id });     if (!profile) {      return spongRes.SpongNotFound({        success: false,        message: "Profile not found",      });    }     const uploaded: string[] = [];     try {      if (avatar instanceof File && avatar.size > 0) {        const image = await this.cloudinary.upload(avatar, "riserbob/avatars");        uploaded.push(image.publicId);        profile.avatar = image;      }       if (cover instanceof File && cover.size > 0) {        const image = await this.cloudinary.upload(cover, "riserbob/covers");        uploaded.push(image.publicId);        profile.cover = image;      }       if (result.data.bio) profile.bio = result.data.bio;       await profile.save();       return spongRes.SpongOK({        success: true,        message: "Profile updated successfully",        data: profile,      });    } catch (error) {      // Saving failed: remove the images that were just uploaded.      await Promise.allSettled(        uploaded.map((publicId) => this.cloudinary.destroy(publicId)),      );       throw error;    }  };}
When you replace an image, also delete the old one with destroy(oldPublicId) once the save succeeded. Otherwise the old files pile up in your Cloudinary account.

The route

src/modules/profiles/profiles.route.ts
this.bob.spongPut(  "/api/profiles",  this.controller.update,  this.authMiddleware.middleware,);

Try it

Terminal
curl -b cookies.txt -X PUT http://localhost:4000/api/profiles \  -F "bio=Hello from Bikini Bottom" \  -F "avatar=@./avatar.png" \  -F "cover=@./cover.jpg"

Do not set the Content-Type header yourself in a browser. When you send a FormData object with fetch, the browser adds the correct multipart header, including the boundary.

The whole request is limited to 10 MiB. The 5 MB check per image keeps a single file well under it. See Request.
PreviousJWT and cookiesNextCookies

On this page

  • Install
  • Configure Cloudinary
  • The upload manager
  • The rule
  • The controller
  • The route
  • Try it