diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js index 08deea7..cd617cc 100644 --- a/backend/middleware/upload.js +++ b/backend/middleware/upload.js @@ -35,6 +35,29 @@ const uploadProfileImage = multer({ } }).single('profileImage'); +// Configure storage for message images +const messageImageStorage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, path.join(__dirname, '../uploads/messages')); + }, + filename: function (req, file, cb) { + // Generate unique filename: uuid + original extension + const uniqueId = uuidv4(); + const ext = path.extname(file.originalname); + cb(null, `${uniqueId}${ext}`); + } +}); + +// Create multer upload middleware for message images +const uploadMessageImage = multer({ + storage: messageImageStorage, + fileFilter: imageFileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + } +}).single('image'); + module.exports = { - uploadProfileImage + uploadProfileImage, + uploadMessageImage }; \ No newline at end of file diff --git a/backend/models/Message.js b/backend/models/Message.js index 056c600..7269131 100644 --- a/backend/models/Message.js +++ b/backend/models/Message.js @@ -42,6 +42,10 @@ const Message = sequelize.define('Message', { model: 'Messages', key: 'id' } + }, + imagePath: { + type: DataTypes.STRING, + allowNull: true } }, { timestamps: true diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 54cf384..6739faa 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -1,10 +1,14 @@ const express = require('express'); +const helmet = require('helmet'); const { Message, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); +const { uploadMessageImage } = require('../middleware/upload'); const logger = require('../utils/logger'); const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket'); const { Op } = require('sequelize'); const emailService = require('../services/emailService'); +const fs = require('fs'); +const path = require('path'); const router = express.Router(); // Get all messages for the current user (inbox) @@ -242,7 +246,7 @@ router.get('/:id', authenticateToken, async (req, res) => { }); // Send a new message -router.post('/', authenticateToken, async (req, res) => { +router.post('/', authenticateToken, uploadMessageImage, async (req, res) => { try { const { receiverId, subject, content, parentMessageId } = req.body; @@ -257,12 +261,16 @@ router.post('/', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'Cannot send messages to yourself' }); } + // Extract image filename if uploaded + const imagePath = req.file ? req.file.filename : null; + const message = await Message.create({ senderId: req.user.id, receiverId, subject, content, - parentMessageId + parentMessageId, + imagePath }); const messageWithSender = await Message.findByPk(message.id, { @@ -389,4 +397,51 @@ router.get('/unread/count', authenticateToken, async (req, res) => { } }); +// Get message image (authorized) +router.get('/images/:filename', + authenticateToken, + // Override Helmet's CORP header for cross-origin image loading + helmet.crossOriginResourcePolicy({ policy: "cross-origin" }), + async (req, res) => { + try { + const { filename } = req.params; + + // Verify user is sender or receiver of a message with this image + const message = await Message.findOne({ + where: { + imagePath: filename, + [Op.or]: [ + { senderId: req.user.id }, + { receiverId: req.user.id } + ] + } + }); + + if (!message) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.warn('Unauthorized image access attempt', { + userId: req.user.id, + filename + }); + return res.status(403).json({ error: 'Access denied' }); + } + + // Serve the image + const filePath = path.join(__dirname, '../uploads/messages', filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'Image not found' }); + } + + res.sendFile(filePath); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error('Image serve failed', { + error: error.message, + filename: req.params.filename + }); + res.status(500).json({ error: 'Failed to load image' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 61c0a79..2ac2fac 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; -import { messageAPI } from '../services/api'; +import { messageAPI, getMessageImageUrl } from '../services/api'; import { User, Message } from '../types'; import { useAuth } from '../contexts/AuthContext'; import { useSocket } from '../contexts/SocketContext'; @@ -23,11 +23,14 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState>(new Set()); const [isAtBottom, setIsAtBottom] = useState(true); const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messageRefs = useRef>(new Map()); const typingTimeoutRef = useRef(null); const inputRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { if (show) { @@ -258,9 +261,47 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes handleTyping(); }; + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + // Validate file type + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + alert('Image size must be less than 5MB'); + return; + } + + setSelectedImage(file); + + // Create preview URL + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleRemoveImage = () => { + setSelectedImage(null); + setImagePreview(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleImageButtonClick = () => { + fileInputRef.current?.click(); + }; + const handleSend = async (e: React.FormEvent) => { e.preventDefault(); - if (!newMessage.trim()) return; + if (!newMessage.trim() && !selectedImage) return; // Stop typing indicator if (isConnected) { @@ -269,14 +310,25 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes setSending(true); const messageContent = newMessage; + const imageToSend = selectedImage; setNewMessage(''); // Clear input immediately for better UX + setSelectedImage(null); + setImagePreview(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } try { - const response = await messageAPI.sendMessage({ - receiverId: recipient.id, - subject: `Message from ${currentUser?.firstName}`, - content: messageContent - }); + // Build FormData for message (with or without image) + const formData = new FormData(); + formData.append('receiverId', recipient.id); + formData.append('subject', `Message from ${currentUser?.firstName}`); + formData.append('content', messageContent || ' '); // Send space if only image + if (imageToSend) { + formData.append('image', imageToSend); + } + + const response = await messageAPI.sendMessage(formData); // Add message to sender's chat immediately for instant feedback // Socket will handle updating the receiver's chat @@ -290,6 +342,14 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes } catch (error) { console.error('Failed to send message:', error); setNewMessage(messageContent); // Restore message on error + setSelectedImage(imageToSend); + if (imageToSend) { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(imageToSend); + } } finally { setSending(false); // Defer focus until after all DOM updates and scroll operations complete @@ -413,21 +473,39 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes )}
-
-

- {message.content} -

- + Shared image window.open(getMessageImageUrl(message.imagePath!), '_blank')} + /> +
+ )} + {message.content.trim() && ( +

+ {message.content} +

+ )} + @@ -452,7 +530,46 @@ const ChatWindow: React.FC = ({ show, onClose, recipient, onMes {/* Input Area */}
+ {/* Image Preview */} + {imagePreview && ( +
+ Preview + +
+ )}
+ + = ({ show, onClose, recipient, onMes onChange={handleInputChange} disabled={sending} /> -