diff --git a/backend/src/controllers/scheduledMessage.controller.js b/backend/src/controllers/scheduledMessage.controller.js index 3cd3fc1..78bef5d 100644 --- a/backend/src/controllers/scheduledMessage.controller.js +++ b/backend/src/controllers/scheduledMessage.controller.js @@ -1,6 +1,9 @@ import mongoose from "mongoose"; import ScheduledMessage from "../models/scheduledMessage.model.js"; import User from "../models/user.model.js"; +import xss from "xss"; +import cloudinary from "../lib/cloudinary.js"; +import { validateImageAttachment, validateAudioAttachment } from "../lib/attachmentValidator.js"; // ── POST /messages/schedule ────────────────────────────────────── /** @@ -13,47 +16,63 @@ export async function scheduleMessage(req, res) { const { receiverId, message, image, audio, scheduledFor, replyTo } = req.body; const senderId = req.userId; - // Validation + // Validation of receiver if (!receiverId || !mongoose.Types.ObjectId.isValid(receiverId)) { return res.status(400).json({ message: "Valid receiver ID is required." }); } - + // Validate scheduled time if (!scheduledFor) { return res.status(400).json({ message: "Scheduled timestamp is required." }); } - const scheduledDate = new Date(scheduledFor); const now = new Date(); - if (scheduledDate <= now) { return res.status(400).json({ message: "Scheduled time must be in the future." }); } - + // At least one content if (!message && !image && !audio) { return res.status(400).json({ message: "Message, image, or audio is required." }); } - // Check receiver exists const receiver = await User.findById(receiverId); if (!receiver) { return res.status(404).json({ message: "Receiver user not found." }); } - + // Sanitize message text if present + const sanitizedMessage = message ? xss(message.trim()) : ""; + // Validate and upload image + let imageUrl = ""; + if (image) { + const validation = validateImageAttachment(image); + if (!validation.isValid) { + return res.status(400).json({ message: validation.error }); + } + const result = await cloudinary.uploader.upload(image); + imageUrl = result.secure_url; + } + // Validate and upload audio + let audioUrl = ""; + if (audio) { + const validation = validateAudioAttachment(audio); + if (!validation.isValid) { + return res.status(400).json({ message: validation.error }); + } + const result = await cloudinary.uploader.upload(audio, { resource_type: "auto" }); + audioUrl = result.secure_url; + } // Create scheduled message const scheduledMessage = new ScheduledMessage({ senderId, receiverId, - message: message || "", - image: image || "", - audio: audio || "", + message: sanitizedMessage, + image: imageUrl, + audio: audioUrl, scheduledFor: scheduledDate, replyTo: replyTo || null, status: "pending", }); - await scheduledMessage.save(); await scheduledMessage.populate("senderId receiverId", "name profilePicture"); - res.status(201).json({ message: "Message scheduled successfully.", data: scheduledMessage, @@ -169,9 +188,34 @@ export async function updateScheduledMessage(req, res) { } // Update fields if provided - if (message !== undefined) scheduledMessage.message = message; - if (image !== undefined) scheduledMessage.image = image; - if (audio !== undefined) scheduledMessage.audio = audio; + if (message !== undefined) scheduledMessage.message = message ? xss(message.trim()) : ""; + + if (image !== undefined) { + if (image) { + const validation = validateImageAttachment(image); + if (!validation.isValid) { + return res.status(400).json({ message: validation.error }); + } + const result = await cloudinary.uploader.upload(image); + scheduledMessage.image = result.secure_url; + } else { + scheduledMessage.image = ""; + } + } + + if (audio !== undefined) { + if (audio) { + const validation = validateAudioAttachment(audio); + if (!validation.isValid) { + return res.status(400).json({ message: validation.error }); + } + const result = await cloudinary.uploader.upload(audio, { resource_type: "auto" }); + scheduledMessage.audio = result.secure_url; + } else { + scheduledMessage.audio = ""; + } + } + if (replyTo !== undefined) scheduledMessage.replyTo = replyTo; if (scheduledFor) { diff --git a/backend/src/lib/attachmentValidator.js b/backend/src/lib/attachmentValidator.js new file mode 100644 index 0000000..31731ae --- /dev/null +++ b/backend/src/lib/attachmentValidator.js @@ -0,0 +1,54 @@ +/* + * Attachment validation utilities shared between live message handling and scheduled messages. + * These functions validate Base64 data URLs for images and audio files, enforce MIME allow‑lists, + * and ensure size limits (5 MB for images, 10 MB for audio). They return an object + * { isValid: boolean, error?: string }. + */ + +export const validateImageAttachment = (base64Str, maxSizeBytes = 5 * 1024 * 1024) => { + if (typeof base64Str !== 'string' || !base64Str) return { isValid: false, error: "Invalid file format structure or corrupt payload." }; + // Verify Data URL format + const match = base64Str.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + return { isValid: false, error: "Invalid file format structure or corrupt payload." }; + } + const mimeType = match[1]; + const rawData = match[2]; + const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; + if (!ALLOWED_MIME_TYPES.includes(mimeType.toLowerCase())) { + return { isValid: false, error: "Unsupported image signature type. Allowed formats: JPEG, PNG, WEBP, GIF." }; + } + // Estimate binary size + const binarySizeEstimate = Math.floor((rawData.length * 3) / 4) - (rawData.endsWith("==") ? 2 : rawData.endsWith("=") ? 1 : 0); + if (binarySizeEstimate > maxSizeBytes) { + return { isValid: false, error: "File boundary limit exceeded. Image size must be under 5MB." }; + } + return { isValid: true }; +}; + +export const validateAudioAttachment = (base64Str, maxSizeBytes = 10 * 1024 * 1024) => { + if (typeof base64Str !== 'string' || !base64Str) return { isValid: false, error: "Invalid audio format structure or corrupt payload." }; + const match = base64Str.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + return { isValid: false, error: "Invalid audio format structure or corrupt payload." }; + } + const mimeType = match[1]; + const rawData = match[2]; + const ALLOWED_MIME_TYPES = [ + "audio/webm", + "audio/mp3", + "audio/wav", + "audio/mpeg", + "audio/ogg", + "audio/x-m4a", + "audio/m4a", + ]; + if (!ALLOWED_MIME_TYPES.includes(mimeType.toLowerCase())) { + return { isValid: false, error: "Unsupported audio format. Allowed formats: WEBM, MP3, WAV, OGG, M4A." }; + } + const binarySizeEstimate = Math.floor((rawData.length * 3) / 4) - (rawData.endsWith("==") ? 2 : rawData.endsWith("=") ? 1 : 0); + if (binarySizeEstimate > maxSizeBytes) { + return { isValid: false, error: "Audio size exceeds the 10MB limit." }; + } + return { isValid: true }; +}; diff --git a/backend/src/lib/messageScheduler.js b/backend/src/lib/messageScheduler.js index f60231c..7bebd33 100644 --- a/backend/src/lib/messageScheduler.js +++ b/backend/src/lib/messageScheduler.js @@ -2,6 +2,9 @@ import ScheduledMessage from "../models/scheduledMessage.model.js"; import Message from "../models/message.model.js"; import { io, getReceiverSocketIds } from "./socket.js"; import webpush from "./webpush.js"; +import xss from "xss"; +import cloudinary from "./cloudinary.js"; +import { validateImageAttachment, validateAudioAttachment } from "./attachmentValidator.js"; let schedulerInterval = null; @@ -13,13 +16,54 @@ const SCHEDULER_INTERVAL = 30000; // Check every 30 seconds */ async function processScheduledMessage(scheduledMsg) { try { + // Sanitize message text + const sanitizedMessage = scheduledMsg.message ? xss(scheduledMsg.message.trim()) : ""; + + // Handle image attachment + let imageUrl = ""; + if (scheduledMsg.image) { + if (scheduledMsg.image.startsWith('data:')) { + const validation = validateImageAttachment(scheduledMsg.image); + if (!validation.isValid) { + console.error(`[Scheduler] Image validation failed for scheduled message ${scheduledMsg._id}:`, validation.error); + scheduledMsg.status = "failed"; + scheduledMsg.failureReason = validation.error; + await scheduledMsg.save(); + return; // Skip processing this message + } + const uploadResult = await cloudinary.uploader.upload(scheduledMsg.image); + imageUrl = uploadResult.secure_url; + } else { + imageUrl = scheduledMsg.image; // Assume already a URL + } + } + + // Handle audio attachment + let audioUrl = ""; + if (scheduledMsg.audio) { + if (scheduledMsg.audio.startsWith('data:')) { + const validation = validateAudioAttachment(scheduledMsg.audio); + if (!validation.isValid) { + console.error(`[Scheduler] Audio validation failed for scheduled message ${scheduledMsg._id}:`, validation.error); + scheduledMsg.status = "failed"; + scheduledMsg.failureReason = validation.error; + await scheduledMsg.save(); + return; // Skip processing this message + } + const uploadResult = await cloudinary.uploader.upload(scheduledMsg.audio, { resource_type: "auto" }); + audioUrl = uploadResult.secure_url; + } else { + audioUrl = scheduledMsg.audio; // Assume already a URL + } + } + // Create the actual message const newMessage = new Message({ senderId: scheduledMsg.senderId, receiverId: scheduledMsg.receiverId, - message: scheduledMsg.message, - image: scheduledMsg.image, - audio: scheduledMsg.audio, + message: sanitizedMessage, + image: imageUrl, + audio: audioUrl, replyTo: scheduledMsg.replyTo, status: "sent", }); @@ -46,7 +90,7 @@ async function processScheduledMessage(scheduledMsg) { // For now, this is a placeholder for push notification logic const notificationPayload = { title: "New message", - body: scheduledMsg.message || "(Message with attachment)", + body: sanitizedMessage || "(Message with attachment)", icon: "/icon.png", }; diff --git a/backend/src/models/scheduledMessage.model.js b/backend/src/models/scheduledMessage.model.js index 4db072a..6c79e2b 100644 --- a/backend/src/models/scheduledMessage.model.js +++ b/backend/src/models/scheduledMessage.model.js @@ -8,7 +8,8 @@ const scheduledMessageSchema = new mongoose.Schema( image: { type: String, default: "" }, audio: { type: String, default: "" }, scheduledFor: { type: Date, required: true }, - status: { type: String, enum: ["pending", "sent", "cancelled"], default: "pending" }, + status: { type: String, enum: ["pending", "sent", "cancelled", "failed"], default: "pending" }, + failureReason: { type: String, default: null }, sentAt: { type: Date, default: null }, cancelledAt: { type: Date, default: null }, replyTo: {