feat(chat): add 2000 character limit for messages

Added message length validation to prevent spam and improve UX with
character counter feedback.

Backend:
- Added MESSAGE_MAX_LENGTH constant (2000 characters)
- Validation in send_event_message handler:
  - Check if content is string
  - Check if not empty after trim
  - Check if not exceeding max length
- Validation in send_match_message handler (same checks)
- Returns error message if validation fails

Frontend:
- Added MESSAGE_MAX_LENGTH constant (2000 characters)
- ChatInput component enhancements:
  - maxLength attribute on input (hard limit)
  - Character counter shows when >80% of limit
  - Counter turns red when at limit
  - Submit button disabled when at limit
  - Counter format: "X/2000"

UX:
- User sees counter at 1600+ characters (80% of limit)
- Hard limit prevents typing beyond 2000 chars
- Clear visual feedback (red text) when at limit
- Consistent validation on both event and match chats

Security:
- Prevents spam with extremely long messages
- Protects against potential DoS via message size
- Database already uses TEXT type (supports limit)
This commit is contained in:
Radosław Gierwiało
2025-12-02 23:46:54 +01:00
parent dd3176196e
commit 4a91a10aff
4 changed files with 71 additions and 20 deletions

View File

@@ -1,10 +1,14 @@
const { MATCH_STATUS, SUGGESTION_STATUS } = require('./statuses'); const { MATCH_STATUS, SUGGESTION_STATUS } = require('./statuses');
const { ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('./tiers'); const { ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('./tiers');
// Message validation
const MESSAGE_MAX_LENGTH = 2000;
module.exports = { module.exports = {
MATCH_STATUS, MATCH_STATUS,
SUGGESTION_STATUS, SUGGESTION_STATUS,
ACCOUNT_TIER, ACCOUNT_TIER,
FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_SUPPORTER_PENALTY,
FAIRNESS_COMFORT_PENALTY, FAIRNESS_COMFORT_PENALTY,
MESSAGE_MAX_LENGTH,
}; };

View File

@@ -2,6 +2,7 @@ const { Server } = require('socket.io');
const { verifyToken } = require('../utils/auth'); const { verifyToken } = require('../utils/auth');
const { prisma } = require('../utils/db'); const { prisma } = require('../utils/db');
const { ACTIONS, log: activityLog } = require('../services/activityLog'); const { ACTIONS, log: activityLog } = require('../services/activityLog');
const { MESSAGE_MAX_LENGTH } = require('../constants');
// Track active users in each event room // Track active users in each event room
const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar } const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar }
@@ -310,6 +311,19 @@ function initializeSocket(httpServer) {
return socket.emit('error', { message: 'Not in an event room' }); return socket.emit('error', { message: 'Not in an event room' });
} }
// Validate message content
if (!content || typeof content !== 'string') {
return socket.emit('error', { message: 'Invalid message content' });
}
if (content.trim().length === 0) {
return socket.emit('error', { message: 'Message cannot be empty' });
}
if (content.length > MESSAGE_MAX_LENGTH) {
return socket.emit('error', { message: `Message too long. Maximum ${MESSAGE_MAX_LENGTH} characters allowed.` });
}
const eventId = socket.currentEventId; const eventId = socket.currentEventId;
const roomName = socket.currentEventRoom; const roomName = socket.currentEventRoom;
@@ -434,6 +448,19 @@ function initializeSocket(httpServer) {
// Send message to match room // Send message to match room
socket.on('send_match_message', async ({ matchId, content }) => { socket.on('send_match_message', async ({ matchId, content }) => {
try { try {
// Validate message content
if (!content || typeof content !== 'string') {
return socket.emit('error', { message: 'Invalid message content' });
}
if (content.trim().length === 0) {
return socket.emit('error', { message: 'Message cannot be empty' });
}
if (content.length > MESSAGE_MAX_LENGTH) {
return socket.emit('error', { message: `Message too long. Maximum ${MESSAGE_MAX_LENGTH} characters allowed.` });
}
const roomName = `match_${matchId}`; const roomName = `match_${matchId}`;
// Get match and its room // Get match and its room

View File

@@ -1,7 +1,8 @@
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { MESSAGE_MAX_LENGTH } from '../../constants';
/** /**
* Chat Input component with send button * Chat Input component with send button and character counter
* *
* @param {string} value - Input value * @param {string} value - Input value
* @param {function} onChange - Change handler for input * @param {function} onChange - Change handler for input
@@ -26,26 +27,42 @@ const ChatInput = ({
onSubmit(e); onSubmit(e);
}; };
const charCount = value.length;
const isNearLimit = charCount > MESSAGE_MAX_LENGTH * 0.8; // 80% of limit
const isOverLimit = charCount > MESSAGE_MAX_LENGTH;
return ( return (
<form onSubmit={handleSubmit} className={`flex space-x-2 ${className}`}> <div className="space-y-1">
<input <form onSubmit={handleSubmit} className={`flex space-x-2 ${className}`}>
type="text" <input
value={value} type="text"
onChange={onChange} value={value}
placeholder={placeholder} onChange={onChange}
disabled={disabled} placeholder={placeholder}
autoFocus={autoFocus} disabled={disabled}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" autoFocus={autoFocus}
/> maxLength={MESSAGE_MAX_LENGTH}
<button className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
type="submit" />
disabled={disabled || !value.trim()} <button
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" type="submit"
aria-label="Send message" disabled={disabled || !value.trim() || isOverLimit}
> className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
<Send className="w-5 h-5" /> aria-label="Send message"
</button> >
</form> <Send className="w-5 h-5" />
</button>
</form>
{/* Character counter - show when approaching limit */}
{isNearLimit && (
<div className="text-right">
<span className={`text-xs ${isOverLimit ? 'text-red-600' : 'text-gray-500'}`}>
{charCount}/{MESSAGE_MAX_LENGTH}
</span>
</div>
)}
</div>
); );
}; };

View File

@@ -5,3 +5,6 @@ export {
CONNECTION_STATE, CONNECTION_STATE,
SUGGESTION_TYPE, SUGGESTION_TYPE,
} from './statuses'; } from './statuses';
// Message validation
export const MESSAGE_MAX_LENGTH = 2000;