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
74 changes: 59 additions & 15 deletions backend/src/controllers/scheduledMessage.controller.js
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────
/**
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 54 additions & 0 deletions backend/src/lib/attachmentValidator.js
Original file line number Diff line number Diff line change
@@ -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 };
};
52 changes: 48 additions & 4 deletions backend/src/lib/messageScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
});
Expand All @@ -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",
};

Expand Down
3 changes: 2 additions & 1 deletion backend/src/models/scheduledMessage.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down