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
bun add cloudinarySet CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET in .env, with the values from your Cloudinary dashboard.
Configure Cloudinary
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.
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
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.
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; } };}The route
this.bob.spongPut( "/api/profiles", this.controller.update, this.authMiddleware.middleware,);Try it
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.