Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions frontend/lib/imageUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* imageUtils.js — Client-side image compression utilities (Fix #573)
*
* Uses the `browser-image-compression` library to reduce image payload size
* before uploading to Cloudinary. This prevents unnecessary bandwidth usage,
* speeds up uploads, and reduces Cloudinary storage costs.
*
* Previously, profile pictures were uploaded as raw, uncompressed base64 strings
* (sometimes 5–15 MB). This utility enforces a max size of 1 MB and 1920px dimension.
*/

import imageCompression from "browser-image-compression";

/**
* Compresses a File object and returns a Base64 data URL.
* Used for profile picture uploads.
*
* @param {File} file - The raw image File from an <input type="file">
* @param {Object} [options] - Optional override for compression settings
* @returns {Promise<string>} - Base64 data URL of the compressed image
*/
export const compressImageToBase64 = async (file, options = {}) => {
const defaultOptions = {
maxSizeMB: 1, // Max output size: 1 MB
maxWidthOrHeight: 1024, // Max dimension: 1024px (sufficient for avatars)
useWebWorker: true, // Non-blocking compression via Web Worker
fileType: "image/webp", // WebP yields best size/quality ratio
initialQuality: 0.8, // 80% quality — visually lossless for avatars
...options,
};

const compressedFile = await imageCompression(file, defaultOptions);
return await fileToBase64(compressedFile);
};

/**
* Compresses a File object and returns a preview Object URL + Base64 string.
* Used when you need both a local preview and an uploadable payload.
*
* @param {File} file - The raw image File
* @param {Object} [options] - Optional compression overrides
* @returns {Promise<{ previewUrl: string, base64: string }>}
*/
export const compressImageForPreviewAndUpload = async (file, options = {}) => {
const defaultOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920, // Higher res for chat images
useWebWorker: true,
...options,
};

const compressedFile = await imageCompression(file, defaultOptions);
const previewUrl = URL.createObjectURL(compressedFile);
const base64 = await fileToBase64(compressedFile);
return { previewUrl, base64 };
};

/**
* Reads a File object into a Base64 data URL string.
* @param {File} file
* @returns {Promise<string>}
*/
const fileToBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = () => reject(new Error("Failed to read image file"));
reader.readAsDataURL(file);
});

/**
* Validates that a file is an accepted image type and under the size limit.
* @param {File} file
* @param {number} [maxMB=5] - Max allowed size in MB before compression
* @returns {{ valid: boolean, error?: string }}
*/
export const validateImageFile = (file, maxMB = 5) => {
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!ALLOWED_TYPES.includes(file.type)) {
return { valid: false, error: "Unsupported format. Use JPEG, PNG, WebP, or GIF." };
}
if (file.size > maxMB * 1024 * 1024) {
return { valid: false, error: `Image must be under ${maxMB}MB` };
}
return { valid: true };
};
37 changes: 33 additions & 4 deletions frontend/src/store/useAuthStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { create } from "zustand";
import axiosInstance from "../../lib/axios";
import toast from "react-hot-toast";
import { connectSocket, disconnectSocket } from "../../lib/socket";
import { compressImageToBase64, validateImageFile } from "../../lib/imageUtils";

const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
Expand Down Expand Up @@ -131,14 +132,42 @@ const useAuthStore = create((set) => ({
}
},

updateProfilePicture: async (base64Image) => {
/**
* FIX (#573): Compresses the selected image client-side before uploading.
* Previously, the raw uncompressed base64 string (up to 15 MB) was sent
* directly to the server and then to Cloudinary — wasting bandwidth and
* storage. Now the image is compressed to ≤1 MB / 1024px before upload.
*
* @param {File} imageFile - The raw File object from the file input
*/
updateProfilePicture: async (imageFile) => {
set({ isLoading: true });
try {
const res = await axiosInstance.put("/auth/update-profile-picture", { profilePicture: base64Image });
// Validate file type and size before compression
const validation = validateImageFile(imageFile, 10);
if (!validation.valid) {
toast.error(validation.error);
return;
}

toast.loading("Compressing image...", { id: "pic-upload" });

// Compress client-side to ≤1 MB / 1024px (avatar quality)
const compressedBase64 = await compressImageToBase64(imageFile, {
maxSizeMB: 1,
maxWidthOrHeight: 1024,
fileType: "image/webp",
});

toast.loading("Uploading...", { id: "pic-upload" });

const res = await axiosInstance.put("/auth/update-profile-picture", {
profilePicture: compressedBase64,
});
set({ authUser: res.data });
toast.success("Profile picture updated!");
toast.success("Profile picture updated!", { id: "pic-upload" });
} catch (error) {
toast.error(error.response?.data?.message || "Picture update failed");
toast.error(error.response?.data?.message || "Picture update failed", { id: "pic-upload" });
throw error;
} finally {
set({ isLoading: false });
Expand Down