Search code examples
node.jsreactjsexpressfile-uploadcloudinary

Problem uploading photo from Ant Design React to Express to Cloudinary


Let me start by saying, I previously had a file upload work in a similar arrangement, but I am writing a new image upload component using Ant Design and their component handles the transaction; I am running into issues adapting my approach.

I have managed to get my server listening to the upload and responding when finished. But I can not seem to figure out how to correctly take the data and send it to Cloudinary. After different approaches, the one in this post still gives me this return from Cloudinary:

err: { message: 'Invalid image file', name: 'Error', http_code: 400 }

I can only assume the file I am attempting to turn into a base64 string is not what I expect.

Goal:

  1. Use Ant Design's upload component to select and send image file to server

  2. With Node and Express.js, receive that file, upload it to Cloudinary using their SDK

  3. With success from SDK, write details to appropriate Mongo documents

  4. return success and updated info to React client

In my React Client:

This is pretty much right out of Antd's docs

// ==> React
import React, { useState } from 'react';

// ==> Packages
import { PlusOutlined } from '@ant-design/icons';
import { Modal, Upload } from 'antd';
import type { RcFile, UploadProps } from 'antd/es/upload';
import type { UploadFile } from 'antd/es/upload/interface';

// ==> Project Imports
import { Card } from 'components';
import { SERVER_URL, API_USERS, API_USERS_UPLOAD_PHOTO } from 'routes';
import { TOKEN_LABEL } from 'config';

const getBase64 = (file: RcFile): Promise<string> =>
    new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsDataURL(file);
        reader.onload = () => resolve(reader.result as string);
        reader.onerror = (error) => reject(error);
    });

const ManagePhotos: React.FC = () => {
    const [previewOpen, setPreviewOpen] = useState(false);
    const [previewImage, setPreviewImage] = useState('');
    const [previewTitle, setPreviewTitle] = useState('');
    const [fileList, setFileList] = useState<UploadFile[]>([
    ]);

    const handleCancel = () => setPreviewOpen(false);

    const handlePreview = async (file: UploadFile) => {
        if (!file.url && !file.preview) {
            file.preview = await getBase64(file.originFileObj as RcFile);
        }

        setPreviewImage(file.url || (file.preview as string));
        setPreviewOpen(true);
        setPreviewTitle(file.name || file.url!.substring(file.url!.lastIndexOf('/') + 1));
    };

    const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, file, event }) => {
        console.log(event);
        if (file.status === 'done') {
            console.log(file.response);
        }
        setFileList(newFileList);
    };

    const uploadButton = (
        <div>
            <PlusOutlined />
            <div style={{ marginTop: 8 }}>Upload</div>
        </div>
    );
    return (
        <Card title='Manage Photos' primaryHeader>
            <Upload
                action={`${SERVER_URL}${API_USERS}${API_USERS_UPLOAD_PHOTO}`}
                listType='picture-card'
                headers={{
                    Authorization: `Bearer ${localStorage.getItem(TOKEN_LABEL)}`,
                }}
                fileList={fileList}
                onPreview={handlePreview}
                onChange={handleChange}>
                {fileList.length >= 8 ? null : uploadButton}
            </Upload>
            <Modal open={previewOpen} title={previewTitle} footer={null} onCancel={handleCancel}>
                <img alt='example' style={{ width: '100%' }} src={previewImage} />
            </Modal>
        </Card>
    );
};

export default ManagePhotos;

On my Express server:

util/cloudinary.js

const cloudinary = require('cloudinary').v2;

cloudinary.config({
    cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
    api_key: process.env.CLOUDINARY_API_KEY,
    api_secret: process.env.CLOUDINARY_SECRET_KEY,
});

module.exports = { cloudinary };

controllers/userController.js

const { User } = require('../models');
const { cloudinary } = require('../util/cloudinary');
const {
    MongoSaveErrorREST,
    MissingOperationDataREST,
    MissingDataErrorREST,
    CloudinarySDKError,
} = require('../routes/errors');

...

exports.upload_user_photo = async function (image, opts) {  
    const { userId, makeAvatar = false, makeBanner = false } = opts;

    if (!image || !userId)
        return {
            err: new MissingOperationDataREST(`Missing Photo Upload Input`, `userController >> upload_user_photo`),
        };

    const user = await _this.get_user(userId);

    if (!user)
        return {
            err: new MissingDataErrorREST(`User Not Found`, `userController >> upload_user_photo`),
        };

    let cloudinaryResponse;

    try {
        cloudinaryResponse = await cloudinary.uploader.upload(image, {
            upload_preset: [preset],
            folder: [folder],
        });
    } catch (err) {
        return { err };    <--- The error returning to me here
    }

    if (!cloudinaryResponse.public_id)
        return {
            err: new CloudinarySDKError(
                `Error Uploading User Photo`,
                `userController >> upload_user_photo >>`,
                cloudinaryResponse
            ),
        };

    user.info.pictures = await [...user.info.pictures, cloudinaryResponse.public_id];

    return user
        .save()
        .then(() => ({ newPhoto: cloudinaryResponse.public_id, makeAvatar, makeBanner }))
        .catch((saveError) => ({
            err: new MongoSaveErrorREST(
                `Error Saving Photo To User`,
                `userController >> upload_user_photo >> user.save().catch()`,
                saveError
            ),
        }));
};

...

routes/userRouter.js

userRouter
    .route(routes.USER_RTR_PHOTO_UPLOAD)
    .options(cors.cors, (_, res) => res.sendStatus(200))
    .post(cors.corsWithOptions, verifyUser, async function (req, res, next) {
        if (req.method === 'POST') {
            console.log('POST request');

            const bb = busboy({ headers: req.headers });
            bb.on('file', (name, file, info) => {
                const { filename, encoding, mimeType } = info;

                file.on('data', (data) => {
                    // login progress okay here
                }).on('close', async () => {

                    /** The upload should be complete here
                     'file' is a filestream, I am sure me trying to grab 
                      my image out of here is where I am doing something 
                     wrong. It is hacked out of partial solutions from other 
                     posts. Also, the req.body is empty, so i couldn't 
                     find the base64 I need to send to cloudinary there. */

                    let image = Buffer.from(JSON.stringify(file));
                    image = image.toString('base64');
                    image = `data:${mimeType};base64,${image}`;

                    // Controller shown previously should upload to Cloudinary and write details to User in DB after success, then return err, or newPhoto's public_id

                    let { err, newPhoto } = await user_controller.upload_user_photo(image, {userId: req.user._id,});

                    if (err) return jsonRESPONSE(500, res, { errors: err });

                    if (newPhoto) return jsonRESPONSE(200, res, { newPhoto });
                    
            });
            bb.on('field', (name, val, info) => {
                console.log(`Field [${name}]: value: %j`, val);
            });
            req.pipe(bb);

        }
    });

The approach that worked fine in another app used Axios to post the previewSource from the client as part of the request body. But I can't seem to figure out how even though Ant Design is uploading the same base64 source I have in the past, it is not showing up on the server the same way. The body on the request is empty. I feel as though something simple or fundamental is going over my head and need somebody to help me realize how to make this work. Thank you very much.


Solution

  • PROBLEM SOLVED:

    Okay, in research I realized the issue and wanted to leave the a solution here for anybody else using this arrangement. In my research I found posts around other sites having the same problem dating back to 2017 with no solutions detailed... other than "I stopped using Cloudinary". But I kinda like Cloudinary so for those who want to stick with Ant Design & Cloudinary together, here you go! 🙂

    What was wrong?

    In the old upload forms I mentioned, I was sending a multipart form and when I got that on the server could find the DataURI attached to my request body; the complete URI in base64 that was being used in my preview; and I could pass that directly to Cloudinary through the SDK expecting base64.

    While that was doable before, this is not how Ant Design is sending the data. Our access to it on the server is not the base64 URI from the preview, it is a FileStream sending the actual file to the server in chunks.

    Solution

    1. At the endpoint where we are managing the request with busboy, we want to create an empty buffer
    2. As chunks of the data show up add them to the complete buffer until the file upload is complete.
    3. When completed, our buffer will contain all the correct data.
    4. We can then take that complete buffer and put it into a variable as base64 string,
    5. then format that string as a DataURI
    6. Cloudinary SDK will upload the image no problem.

    I hope this helps somebody out!

    Code

    userRouter
        .route(routes.USER_RTR_PHOTO_UPLOAD)
        .options(cors.cors, (_, res) => res.sendStatus(200))
        .post(cors.corsWithOptions, verifyUser, async function (req, res, next) {
    
            /** CREATE NEW BUFFER FOR INCOMING DATA */
            let buffer = new Buffer.from('', 'base64');
    
            if (req.method === 'POST') {
                console.log('POST request');
                const bb = busboy({ headers: req.headers });
                
                bb.on('file', (name, file, info) => {
                    const { filename, encoding, mimeType } = info;
                    
                    file.on('data', (data) => {
                        
                        /** PUT EXISTING AND NEW BUFFER IN ARRAY */
                        let bufferArr = [buffer, data];
    
                        /** UPDATE BUFFER TO CONTAIN NEW DATA */
                        buffer = Buffer.concat(bufferArr);                  
                    }).on('close', async () => {
                        
                        /** PUT BUFFER IN VAR AS BASE64 STRING */
                        let image = buffer.toString('base64');
    
                        /** FORMAT AS DATA URI */
                        image = `data:${mimeType};base64,${image}`;
    
                        /** CONTROLLER NOW UPLOADS WITHOUT ERROR! */
                        let { err, newPhoto } = await user_controller.upload_user_photo(image, {
                            userId: req.user._id,
                        });
    
                        console.log({ err, newPhoto });
                        if (err) return jsonRESPONSE(500, res, { errors: err });
                        if (newPhoto) return jsonRESPONSE(200, res, { newPhoto });
                        
                    });
                });
                bb.on('field', (name, val, info) => {
                    console.log(`Field [${name}]: value: %j`, val);
                });
                req.pipe(bb);
            }
        });