One of the features I was very excited to implement was the blogs, which are written via the react-quill package for rich text editors. I figured out how to successfully send header images to my backend to be processed and sent to an S3 bucket. However, I struggled to ensure that the blog images chosen from the image option in the rich text editor remained. It seemed that in the preview, the images would appear in the correct places, but once processed, they would disappear. As you can imagine, this was a very frustrating bug, and I had to get to the bottom of it.
To keep safety and security at the top of mind, I ensure that the content I set in my preview for the rich text editor, as well as the actual HTML that is being sent to the backend, is sanitized to prevent malicious attacks such as XSS (injecting malicious code into a webpage). I achieved this by using DOMpurify on the frontend and sanitize-html on the backend.
After my first implementation of sanitizing the content, I found that I could not retain any images placed into my blog once processed on the backend. I knew the issue lay with the processing because when logging the blog content sent to the backend, I could still see my image tags in the console.logs. Therefore, I did some research and found that generally, the sanitize-html would remove image tags unless specified to keep. That led me to add the following helper function to my controller function for creating new blogs.
const sanitize = (content) => {
const sanitizedContent = sanitizeHTML(content, {
allowedTags: sanitizeHTML.defaults.allowedTags.concat(["img"]),
allowedAttributes: {
"*": ["class", "style"], // Allow 'class' and 'style' on any tag
...sanitizeHTML.defaults.allowedAttributes,
img: ["src", "alt", "title", "width", "height"],
},
});
return sanitizedContent;
};
Problem solved!... Right? Well, not quite.
When I sent the editor content to my backend with multiple images used from the editor tool, I noticed that the image tags were indeed in the string sent to the backend, but were now empty. This behavior did not apply to images that were pasted into the editor. Those images remained. Why? Well, when I used the image tool in the editor, it would strip the base64 images once sanitized, resulting in empty image tags. To combat this, I had to understand my issue and then formulate a step-by-step plan to overcome the bug.
I will start with the solution and then explain each step along the way. Below is the code for achieving my solution.
In BlogManager.jsx:
async function processEditorContent(content) {
const parser = new DOMParser();
const doc = parser.parseFromString(content, "text/html");
const images = doc.querySelectorAll("img");
const promises = Array.from(images).map(async (img) => {
const src = img.getAttribute("src");
if (src && src.startsWith("data:image")) {
// Upload the base64 image to the server
const formData = new FormData();
formData.append("file", src);
formData.append("title", title);
try {
const response = await axios.post(
`${BLOG_BASE_URL}/editor-image-upload`,
formData
);
const imageUrl = response.data.url;
// Replace the base64 src with the uploaded URL
img.setAttribute("src", imageUrl);
} catch (error) {
console.error("Error uploading image:", error);
}
}
});
return Promise.all(promises).then(() => {
return doc.body.innerHTML; // Return the updated HTML content
});
}
In routes/blogs (backend):
const multer = require("multer");
const upload = multer({
limits: { fieldSize: 25 * 1024 * 1024 },
});
router.post(
"/editor-image-upload",
upload.single("file"),
blogRouter.uploadImage
);
In controllers/blogs (backend):
const uploadImage = async (req, res) => {
const { title, file } = req.body; // `file` contains the base64 string
try {
// Extract the base64 data (e.g., remove `data:image/jpeg;base64,` prefix)
const base64Data = file.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Generate the S3 file path
const filePath = `portfolio/blog-images/${uuidv4()}-${title}.jpeg`;
const params = {
Bucket: process.env.BUCKET_NAME,
Key: filePath,
Body: buffer,
ContentType: "image/jpeg",
};
// Upload the file to S3
const command = new PutObjectCommand(params);
await s3Client.send(command);
// Respond with the S3 URL of the uploaded image
res.status(201).json({
url: `${process.env.CLOUDFRONT_PATH}/${filePath}`,
});
} catch (err) {
console.error("Error uploading image:", err);
res.status(500).json({ error: "Unable to upload image" });
}
};
In order to select and manipulate our image tags, we need to convert the html string into a document object. We do this by parsing the string with an instance of DOMparser. In that instance we use the method parseFromString and use the blog content as the first argument and an indicator of what we are parsing as the second.
Now that we are able to select and manipulate tags, we create a variable that is set to the query selection of all images in the doc -- which is the value of the parsed content. So now we have an array of all of the img tags. Next we create a variable named promises to store the value of an array from images, which we map over asynchronously. In here we select the src attribute of the img tag and check if it:
If both are true, then we create a FormData instance and append a file with the value of src and a title with the value of the blog title (for S3 path organization). This Form Data will be sent to our backend function to handle the S3 uploads.
Our Form Data is now properly processed and ready for our backend to work its magic. We send a post request to our backend and attach the FormData. Our backend route contains Multer middleware in order to ensure the image files are recognized from the FormData. In the upload constant, you will see I added a fieldSize limit. The reason is that the base64 images can be very long and oftentimes cause an error when receiving the file. After searching on StackOverflow, I found this issue was easily mitigated by applying the configurations seen in the multer argument.
I also included the single method for a file instead of the array. The reason for this is that with our loop in the frontend function, we are doing a new request for each image we find in the content, not batching them all together.
In the controller function, we now get to the magic. First, we create a regular expression that will target the prefix of the base64 src string and replace it with an empty string to ensure the actual image data is now left. Next, we must convert the string to a binary buffer, as S3 will expect that format. We use Buffer and instruct it to use our actual image data and that the content is in base64 form. After this, we simply upload it to S3 by generating the file path, configuring the params, uploading the file with the PutObjectCommand, and using the S3Client instance to upload the file path string to our S3 Bucket. If successful we send back a success code along with the new url that will be placed into our img src on the frontend.
We now return to the processEditorContent function. The result of the post request we made is stored in a variable called imageUrl. We then set the src attribute to the imageUrl. Remember, this is for each individual img tag found. At the very end of the function, we return a Promise that includes all of the updates we just made and then returns the doc.body.innerHTML.
In the handleSubmit function, we create a variable called processedContent, which is set to the value returned from our processEditorContent function. We eventually either include the processedContent in our updateBlogNoImg function (referring to the header image) or append it in the FormData for updateBlogWithImg. This blog now has all of the desired image tags with the S3 string replacing the original base64-encoded value.
This was a very interesting and rewarding bug to solve. From my experience, all pasted images transfer fine to the backend after sanitization, and my main issue was with base64-encoded images. But I am confident that I can improve my solution to work in case there are other edge cases that arise. As of now, I am able to get the functionality I want and am relieved to have found a solution to this issue!
As always, if you have any thoughts or critiques, I would love to hear them! Feel free to message me on LinkedIn or email. Happy coding!