diff --git a/frontend/lib/imageUtils.js b/frontend/lib/imageUtils.js new file mode 100644 index 0000000..b83d71b --- /dev/null +++ b/frontend/lib/imageUtils.js @@ -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 + * @param {Object} [options] - Optional override for compression settings + * @returns {Promise} - 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} + */ +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 }; +}; diff --git a/frontend/src/store/useAuthStore.js b/frontend/src/store/useAuthStore.js index 4d66f98..24e6b01 100644 --- a/frontend/src/store/useAuthStore.js +++ b/frontend/src/store/useAuthStore.js @@ -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); @@ -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 });