feat: add event slugs to prevent ID enumeration attacks
Replace sequential event IDs in URLs with unique alphanumeric slugs to prevent enumeration attacks. Event URLs now use format /events/{slug}/chat instead of /events/{id}/chat.
Backend changes:
- Add slug field (VARCHAR 50, unique) to Event model
- Create migration with auto-generated 12-char MD5-based slugs for existing events
- Update GET /api/events/:slug endpoint (changed from :id)
- Update GET /api/events/:slug/messages endpoint (changed from :eventId)
- Modify Socket.IO join_event_room to accept slug parameter
- Update send_event_message to use stored event context instead of passing eventId
Frontend changes:
- Update eventsAPI.getBySlug() method (changed from getById)
- Update eventsAPI.getMessages() to use slug parameter
- Change route from /events/:eventId/chat to /events/:slug/chat
- Update EventsPage to navigate using event.slug
- Update EventChatPage to fetch event data via slug and use slug in socket events
Security impact: Prevents attackers from discovering all events by iterating sequential IDs.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "events" ADD COLUMN "slug" VARCHAR(50);
|
||||||
|
|
||||||
|
-- Generate unique slugs for existing events
|
||||||
|
UPDATE "events" SET "slug" = lower(
|
||||||
|
substring(md5(random()::text || clock_timestamp()::text) from 1 for 12)
|
||||||
|
) WHERE "slug" IS NULL;
|
||||||
|
|
||||||
|
-- Make slug NOT NULL and add unique constraint
|
||||||
|
ALTER TABLE "events" ALTER COLUMN "slug" SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "events_slug_key" ON "events"("slug");
|
||||||
@@ -61,6 +61,7 @@ model User {
|
|||||||
// Events table (dance events from worldsdc.com)
|
// Events table (dance events from worldsdc.com)
|
||||||
model Event {
|
model Event {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
slug String @unique @db.VarChar(50)
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
location String @db.VarChar(255)
|
location String @db.VarChar(255)
|
||||||
startDate DateTime @map("start_date") @db.Date
|
startDate DateTime @map("start_date") @db.Date
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ router.get('/', authenticate, async (req, res, next) => {
|
|||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
slug: true,
|
||||||
name: true,
|
name: true,
|
||||||
location: true,
|
location: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
@@ -35,6 +36,7 @@ router.get('/', authenticate, async (req, res, next) => {
|
|||||||
// Transform data and add isJoined flag
|
// Transform data and add isJoined flag
|
||||||
const eventsWithJoinedStatus = events.map(event => ({
|
const eventsWithJoinedStatus = events.map(event => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
slug: event.slug,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
startDate: event.startDate,
|
startDate: event.startDate,
|
||||||
@@ -67,14 +69,14 @@ router.get('/', authenticate, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/events/:id - Get event by ID
|
// GET /api/events/:slug - Get event by slug
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:slug', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { slug } = req.params;
|
||||||
|
|
||||||
const event = await prisma.event.findUnique({
|
const event = await prisma.event.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(id),
|
slug: slug,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
chatRooms: true,
|
chatRooms: true,
|
||||||
@@ -102,16 +104,29 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/events/:eventId/messages - Get event chat messages with pagination
|
// GET /api/events/:slug/messages - Get event chat messages with pagination
|
||||||
router.get('/:eventId/messages', authenticate, async (req, res, next) => {
|
router.get('/:slug/messages', authenticate, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { slug } = req.params;
|
||||||
const { before, limit = 20 } = req.query;
|
const { before, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
// Find event by slug
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Event not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Find event chat room
|
// Find event chat room
|
||||||
const chatRoom = await prisma.chatRoom.findFirst({
|
const chatRoom = await prisma.chatRoom.findFirst({
|
||||||
where: {
|
where: {
|
||||||
eventId: parseInt(eventId),
|
eventId: event.id,
|
||||||
type: 'event',
|
type: 'event',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,25 +54,38 @@ function initializeSocket(httpServer) {
|
|||||||
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
|
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
|
||||||
|
|
||||||
// Join event room
|
// Join event room
|
||||||
socket.on('join_event_room', async ({ eventId }) => {
|
socket.on('join_event_room', async ({ slug }) => {
|
||||||
try {
|
try {
|
||||||
|
// Find event by slug
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true, slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
socket.emit('error', { message: 'Event not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = event.id;
|
||||||
const roomName = `event_${eventId}`;
|
const roomName = `event_${eventId}`;
|
||||||
socket.join(roomName);
|
socket.join(roomName);
|
||||||
socket.currentEventRoom = roomName;
|
socket.currentEventRoom = roomName;
|
||||||
socket.currentEventId = eventId;
|
socket.currentEventId = eventId;
|
||||||
|
socket.currentEventSlug = slug;
|
||||||
|
|
||||||
// Record event participation in database
|
// Record event participation in database
|
||||||
await prisma.eventParticipant.upsert({
|
await prisma.eventParticipant.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_eventId: {
|
userId_eventId: {
|
||||||
userId: socket.user.id,
|
userId: socket.user.id,
|
||||||
eventId: parseInt(eventId),
|
eventId: eventId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {}, // Don't update anything if already exists
|
update: {}, // Don't update anything if already exists
|
||||||
create: {
|
create: {
|
||||||
userId: socket.user.id,
|
userId: socket.user.id,
|
||||||
eventId: parseInt(eventId),
|
eventId: eventId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +103,7 @@ function initializeSocket(httpServer) {
|
|||||||
|
|
||||||
activeUsers.get(eventId).add(JSON.stringify(userInfo));
|
activeUsers.get(eventId).add(JSON.stringify(userInfo));
|
||||||
|
|
||||||
console.log(`👤 ${socket.user.username} joined event room ${eventId}`);
|
console.log(`👤 ${socket.user.username} joined event room ${slug} (ID: ${eventId})`);
|
||||||
|
|
||||||
// Load last 20 messages from database
|
// Load last 20 messages from database
|
||||||
const chatRoom = await prisma.chatRoom.findFirst({
|
const chatRoom = await prisma.chatRoom.findFirst({
|
||||||
@@ -146,8 +159,13 @@ function initializeSocket(httpServer) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Leave event room
|
// Leave event room
|
||||||
socket.on('leave_event_room', ({ eventId }) => {
|
socket.on('leave_event_room', () => {
|
||||||
const roomName = `event_${eventId}`;
|
if (!socket.currentEventId || !socket.currentEventRoom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = socket.currentEventId;
|
||||||
|
const roomName = socket.currentEventRoom;
|
||||||
socket.leave(roomName);
|
socket.leave(roomName);
|
||||||
|
|
||||||
// Remove from active users
|
// Remove from active users
|
||||||
@@ -166,18 +184,28 @@ function initializeSocket(httpServer) {
|
|||||||
io.to(roomName).emit('active_users', updatedUsers);
|
io.to(roomName).emit('active_users', updatedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`👤 ${socket.user.username} left event room ${eventId}`);
|
console.log(`👤 ${socket.user.username} left event room ${socket.currentEventSlug}`);
|
||||||
|
|
||||||
|
// Clear current event data
|
||||||
|
socket.currentEventId = null;
|
||||||
|
socket.currentEventRoom = null;
|
||||||
|
socket.currentEventSlug = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send message to event room
|
// Send message to event room
|
||||||
socket.on('send_event_message', async ({ eventId, content }) => {
|
socket.on('send_event_message', async ({ content }) => {
|
||||||
try {
|
try {
|
||||||
const roomName = `event_${eventId}`;
|
if (!socket.currentEventId || !socket.currentEventRoom) {
|
||||||
|
return socket.emit('error', { message: 'Not in an event room' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = socket.currentEventId;
|
||||||
|
const roomName = socket.currentEventRoom;
|
||||||
|
|
||||||
// Save message to database
|
// Save message to database
|
||||||
const chatRoom = await prisma.chatRoom.findFirst({
|
const chatRoom = await prisma.chatRoom.findFirst({
|
||||||
where: {
|
where: {
|
||||||
eventId: parseInt(eventId),
|
eventId: eventId,
|
||||||
type: 'event',
|
type: 'event',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -216,7 +244,7 @@ function initializeSocket(httpServer) {
|
|||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`💬 Message in event ${eventId} from ${socket.user.username}`);
|
console.log(`💬 Message in event ${socket.currentEventSlug} from ${socket.user.username}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Send message error:', error);
|
console.error('Send message error:', error);
|
||||||
socket.emit('error', { message: 'Failed to send message' });
|
socket.emit('error', { message: 'Failed to send message' });
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/events/:eventId/chat"
|
path="/events/:slug/chat"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<EventChatPage />
|
<EventChatPage />
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { mockEvents } from '../mocks/events';
|
|
||||||
import { Send, UserPlus, Loader2 } from 'lucide-react';
|
import { Send, UserPlus, Loader2 } from 'lucide-react';
|
||||||
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
||||||
import { eventsAPI } from '../services/api';
|
import { eventsAPI } from '../services/api';
|
||||||
|
|
||||||
const EventChatPage = () => {
|
const EventChatPage = () => {
|
||||||
const { eventId } = useParams();
|
const { slug } = useParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
const [activeUsers, setActiveUsers] = useState([]);
|
const [activeUsers, setActiveUsers] = useState([]);
|
||||||
@@ -20,17 +21,35 @@ const EventChatPage = () => {
|
|||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesContainerRef = useRef(null);
|
const messagesContainerRef = useRef(null);
|
||||||
|
|
||||||
const event = mockEvents.find(e => e.id === parseInt(eventId));
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch event data
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEvent = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await eventsAPI.getBySlug(slug);
|
||||||
|
setEvent(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading event:', err);
|
||||||
|
setEvent(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEvent();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
// Connect to Socket.IO
|
// Connect to Socket.IO
|
||||||
const socket = connectSocket();
|
const socket = connectSocket();
|
||||||
|
|
||||||
@@ -43,7 +62,7 @@ const EventChatPage = () => {
|
|||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
// Join event room
|
// Join event room
|
||||||
socket.emit('join_event_room', { eventId: parseInt(eventId) });
|
socket.emit('join_event_room', { slug });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -84,7 +103,7 @@ const EventChatPage = () => {
|
|||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
socket.emit('leave_event_room', { eventId: parseInt(eventId) });
|
socket.emit('leave_event_room');
|
||||||
socket.off('connect');
|
socket.off('connect');
|
||||||
socket.off('disconnect');
|
socket.off('disconnect');
|
||||||
socket.off('message_history');
|
socket.off('message_history');
|
||||||
@@ -93,7 +112,7 @@ const EventChatPage = () => {
|
|||||||
socket.off('user_joined');
|
socket.off('user_joined');
|
||||||
socket.off('user_left');
|
socket.off('user_left');
|
||||||
};
|
};
|
||||||
}, [eventId, user.id]);
|
}, [event, slug, user.id]);
|
||||||
|
|
||||||
const handleSendMessage = (e) => {
|
const handleSendMessage = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -107,7 +126,6 @@ const EventChatPage = () => {
|
|||||||
|
|
||||||
// Send message via Socket.IO
|
// Send message via Socket.IO
|
||||||
socket.emit('send_event_message', {
|
socket.emit('send_event_message', {
|
||||||
eventId: parseInt(eventId),
|
|
||||||
content: newMessage,
|
content: newMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +138,7 @@ const EventChatPage = () => {
|
|||||||
setLoadingOlder(true);
|
setLoadingOlder(true);
|
||||||
try {
|
try {
|
||||||
const oldestMessageId = messages[0].id;
|
const oldestMessageId = messages[0].id;
|
||||||
const response = await eventsAPI.getMessages(eventId, oldestMessageId, 20);
|
const response = await eventsAPI.getMessages(slug, oldestMessageId, 20);
|
||||||
|
|
||||||
if (response.data.length > 0) {
|
if (response.data.length > 0) {
|
||||||
// Save current scroll position
|
// Save current scroll position
|
||||||
@@ -170,10 +188,27 @@ const EventChatPage = () => {
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [loadingOlder, hasMore, messages]);
|
}, [loadingOlder, hasMore, messages]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-primary-600" />
|
||||||
|
<p className="text-gray-600">Loading event...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="text-center">Event not found</div>
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-4 text-red-700">
|
||||||
|
Event not found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ const EventsPage = () => {
|
|||||||
fetchEvents();
|
fetchEvents();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleJoinEvent = (eventId) => {
|
const handleJoinEvent = (slug) => {
|
||||||
navigate(`/events/${eventId}/chat`);
|
navigate(`/events/${slug}/chat`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -102,7 +102,7 @@ const EventsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleJoinEvent(event.id)}
|
onClick={() => handleJoinEvent(event.slug)}
|
||||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||||
>
|
>
|
||||||
{event.isJoined ? 'Open chat' : 'Join chat'}
|
{event.isJoined ? 'Open chat' : 'Join chat'}
|
||||||
|
|||||||
@@ -186,17 +186,17 @@ export const eventsAPI = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(id) {
|
async getBySlug(slug) {
|
||||||
const data = await fetchAPI(`/events/${id}`);
|
const data = await fetchAPI(`/events/${slug}`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMessages(eventId, before = null, limit = 20) {
|
async getMessages(slug, before = null, limit = 20) {
|
||||||
const params = new URLSearchParams({ limit: limit.toString() });
|
const params = new URLSearchParams({ limit: limit.toString() });
|
||||||
if (before) {
|
if (before) {
|
||||||
params.append('before', before.toString());
|
params.append('before', before.toString());
|
||||||
}
|
}
|
||||||
const data = await fetchAPI(`/events/${eventId}/messages?${params}`);
|
const data = await fetchAPI(`/events/${slug}/messages?${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user