Skip to content

Commit a8b4fbe

Browse files
authored
Merge pull request #53 from charlenetcy/develop
fix(collab): Add route handling for unauthorised access and improve chat UI
2 parents 9307ad2 + 728b376 commit a8b4fbe

File tree

6 files changed

+240
-148
lines changed

6 files changed

+240
-148
lines changed
Lines changed: 81 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type * as Party from 'partykit/server';
22
import {onConnect as y_onConnect} from 'y-partykit';
3-
43
import {Buffer} from 'node:buffer';
54

65
import * as Y from 'yjs';
@@ -9,55 +8,43 @@ import {verifyToken} from '../utils/jwt.js';
98
import {getDocument, upsertDocument, checkRoomExists, checkUserVerified} from '../storage/db.js';
109

1110
const CHAT_HISTORY_LIMIT = 500;
12-
const CHAT_COLLECTION_KEY = 'chatMessages';
1311
const EXECUTION_STATE_KEY = 'executionState';
1412

1513
function ensureSharedStructures(doc: Y.Doc) {
1614
doc.getText('codemirror');
1715
doc.getMap<string>('config');
18-
doc.getArray(CHAT_COLLECTION_KEY);
16+
doc.getArray('chat');
1917
doc.getMap(EXECUTION_STATE_KEY);
2018
}
2119

2220
function pruneChatHistory(doc: Y.Doc) {
23-
const chatArray = doc.getArray(CHAT_COLLECTION_KEY);
21+
const chatArray = doc.getArray('chat');
2422
if (chatArray.length <= CHAT_HISTORY_LIMIT) {
2523
return;
2624
}
2725
const excess = chatArray.length - CHAT_HISTORY_LIMIT;
2826
chatArray.delete(0, excess);
2927
}
30-
// import {roomRouter} from '../api/roomRoutes.js';
3128

3229
export default class YjsServer implements Party.Server {
3330
constructor(public room: Party.Room) {}
3431

3532
static async onBeforeConnect(request: Party.Request, _lobby: Party.Lobby) {
3633
try {
3734
const cookieHeader = request.headers.get('cookie');
38-
if (!cookieHeader) {
39-
console.error('No cookie header found');
40-
return new Response('Unauthorized: No cookies', {status: 401});
41-
}
4235

43-
const cookies = parseCookies(cookieHeader);
36+
const cookies = cookieHeader ? parseCookies(cookieHeader) : {};
4437
const accessToken = cookies['accessToken'];
45-
4638
if (!accessToken) {
4739
console.error('No accessToken cookie found');
48-
return new Response('Unauthorized: No access token', {status: 401});
40+
return new Response('Bad Request: No access token', {status: 400});
4941
}
50-
const {valid, payload} = await verifyToken(accessToken);
5142

52-
if (!valid || !payload) {
53-
console.error('Token verification failed:', payload);
54-
return new Response('Unauthorized: Invalid token or payload', {status: 401});
55-
}
5643
const roomId = new URL(request.url).pathname.split('/').pop();
5744

5845
if (!roomId) {
59-
console.error('Room Id is undefined:');
60-
return new Response('Room Id is undefined', {status: 400});
46+
console.error('Room ID is undefined');
47+
return new Response('Bad Request: Room ID missing', {status: 400});
6148
}
6249
const roomExists = await checkRoomExists(roomId);
6350

@@ -66,97 +53,97 @@ export default class YjsServer implements Party.Server {
6653
return new Response('Room not found', {status: 404});
6754
}
6855

69-
// const isUserVerified = await checkUserVerified(payload.userId.toString(), roomId);
70-
71-
// if (!isUserVerified) {
72-
// console.error(`User not authorised to enter this room`);
73-
// return new Response('Unauthorised : User not authorised to enter this room', {status: 401});
74-
// }
75-
76-
request.headers.set('X-User-ID', payload.userId.toString());
77-
78-
if (payload.sessionId) {
79-
try {
80-
request.headers.set('X-Session-ID', payload.sessionId.toString());
81-
} catch (sessionIdError) {
82-
console.warn(
83-
'Session ID missing or malformed on token payload',
84-
sessionIdError,
85-
payload.sessionId
86-
);
87-
}
88-
} else {
89-
console.info('Session ID not present on token payload; continuing without it');
90-
}
56+
request.headers.set('X-Access-Token', accessToken);
9157

9258
return request;
9359
} catch (e) {
9460
console.error('Authentication error:', e);
95-
return new Response('Unauthorized', {status: 401});
61+
return new Response('Internal Server Error', {status: 500});
9662
}
9763
}
9864

99-
async onConnect(connection: Party.Connection) {
100-
const room = this.room;
101-
await y_onConnect(connection, this.room, {
102-
async load() {
103-
// This is called once per "room" when the first user connects
65+
async onConnect(connection: Party.Connection, ctx: Party.ConnectionContext) {
66+
try {
67+
const accessToken = ctx.request.headers.get('X-Access-Token');
68+
const roomId = this.room.id;
69+
if (!accessToken || !roomId) {
70+
console.error('Missing auth data in onConnect');
71+
connection.close(4000, 'Internal error: Missing authentication data');
72+
return;
73+
}
74+
const {valid, payload} = await verifyToken(accessToken);
10475

105-
// Creates the backend Yjs document
106-
const doc = new Y.Doc();
76+
if (!valid || !payload) {
77+
console.error('Token verification failed:', payload);
78+
connection.close(4001, 'Unauthorised: Invalid or expired token');
79+
return;
80+
}
10781

108-
// Load the document from the database
109-
try {
110-
const {data, error} = await getDocument(room.id);
111-
if (error) {
112-
throw new Error(error.message);
113-
}
82+
const isUserVerified = await checkUserVerified(payload.userId.toString(), roomId);
11483

115-
if (data) {
116-
// If the document exists on the database,
117-
// apply it to the Yjs document
118-
try {
119-
const buffer = Buffer.from(data.document, 'base64');
120-
Y.applyUpdate(doc, new Uint8Array(buffer));
121-
ensureSharedStructures(doc);
122-
pruneChatHistory(doc);
123-
} catch (parseErr) {
124-
console.warn(`[${room.id}] Data corrupted, creating new document`);
125-
}
126-
} else {
127-
console.log(`[${room.id}] No existing document found, creating new document`);
128-
ensureSharedStructures(doc);
129-
}
84+
if (!isUserVerified) {
85+
console.error(`User ${payload.userId} not authorised to enter room ${roomId}`);
86+
connection.close(4003, 'Forbidden: You are not authorised for this room');
87+
return;
88+
}
13089

131-
// Return the Yjs document to y-partykit to manage
132-
ensureSharedStructures(doc);
133-
pruneChatHistory(doc);
134-
return doc;
135-
} catch (err) {
136-
console.error(`[${room.id}] Load failed:`, err);
137-
throw err;
138-
}
139-
},
140-
callback: {
141-
handler: async doc => {
142-
// This is called every few seconds if the document has changed
143-
144-
// convert the Yjs document to a Uint8Array
145-
try {
146-
pruneChatHistory(doc);
147-
const content = Y.encodeStateAsUpdate(doc);
90+
await y_onConnect(connection, this.room, {
91+
async load() {
92+
// This is called once per "room" when the first user connects
14893

149-
// Save the document to the database
150-
const {data: _data, error} = await upsertDocument(room.id, content);
94+
// Creates the backend Yjs document
95+
const doc = new Y.Doc();
96+
97+
// Load the document from the database
98+
try {
99+
const {data, error} = await getDocument(roomId);
151100
if (error) {
152-
console.error(`[${room.id}] Failed to save:`, error);
153-
throw new Error(`Failed to save into database: ${error.message}`);
101+
throw new Error(error.message);
102+
}
103+
104+
if (data) {
105+
try {
106+
const buffer = Buffer.from(data.document, 'base64');
107+
Y.applyUpdate(doc, new Uint8Array(buffer));
108+
ensureSharedStructures(doc);
109+
pruneChatHistory(doc);
110+
} catch (parseErr) {
111+
console.warn(`[${roomId}] Data corrupted, creating new document`);
112+
}
113+
} else {
114+
console.log(`[${roomId}] No existing document found, creating new document`);
115+
ensureSharedStructures(doc);
154116
}
117+
118+
// Return the Yjs document to y-partykit to manage
119+
ensureSharedStructures(doc);
120+
pruneChatHistory(doc);
121+
return doc;
155122
} catch (err) {
156-
console.error(`[${room.id}] Save error: `, err);
123+
console.error(`[${roomId}] Load failed:`, err);
124+
throw err;
157125
}
158126
},
159-
},
160-
});
127+
callback: {
128+
handler: async doc => {
129+
try {
130+
pruneChatHistory(doc);
131+
const content = Y.encodeStateAsUpdate(doc);
132+
133+
const {data: _data, error} = await upsertDocument(roomId, content);
134+
if (error) {
135+
console.error(`[${roomId}] Failed to save:`, error);
136+
throw new Error(`Failed to save into database: ${error.message}`);
137+
}
138+
} catch (err) {
139+
console.error(`[${roomId}] Save error: `, err);
140+
}
141+
},
142+
},
143+
});
144+
} catch (e) {
145+
console.error('Connection error:', e);
146+
connection.close(4000, 'Internal server error');
147+
}
161148
}
162149
}

frontend/src/collaboration/components/ChatPanel.tsx

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import {Send} from 'lucide-react';
44
import {useChat, type ChatMessage} from '../hooks/useChat';
55

66
export function ChatPanel({provider}: {provider: YPartyKitProvider | null}) {
7-
const {messages, sendMessage, isReady} = useChat({provider});
7+
const {messages, sendMessage, isReady, username} = useChat({provider});
88
const [newMessage, setNewMessage] = useState('');
99
const messagesEndRef = useRef<HTMLDivElement>(null);
1010

1111
const handleSend = (e: React.FormEvent) => {
1212
e.preventDefault();
13+
if (!newMessage.trim()) return;
1314
sendMessage(newMessage);
1415
setNewMessage('');
1516
};
1617

17-
// Auto-scroll to the bottom when new messages arrive
1818
useEffect(() => {
1919
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
2020
}, [messages]);
@@ -29,10 +29,9 @@ export function ChatPanel({provider}: {provider: YPartyKitProvider | null}) {
2929
<h2 className="font-semibold text-gray-800">Session Chat</h2>
3030
</div>
3131

32-
{/* Message List */}
3332
<div className="flex-1 overflow-y-auto p-4 space-y-4">
3433
{messages.map(msg => (
35-
<MessageItem key={msg.id} msg={msg} />
34+
<MessageItem key={msg.id} msg={msg} isCurrentUser={msg.username === username} />
3635
))}
3736
<div ref={messagesEndRef} />
3837
</div>
@@ -50,7 +49,7 @@ export function ChatPanel({provider}: {provider: YPartyKitProvider | null}) {
5049
/>
5150
<button
5251
type="submit"
53-
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-400"
52+
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
5453
disabled={!newMessage.trim()}
5554
>
5655
<Send size={16} />
@@ -61,17 +60,40 @@ export function ChatPanel({provider}: {provider: YPartyKitProvider | null}) {
6160
);
6261
}
6362

64-
function MessageItem({msg}: {msg: ChatMessage}) {
63+
function MessageItem({msg, isCurrentUser}: {msg: ChatMessage; isCurrentUser: boolean}) {
6564
const sentDate = new Date(msg.timestamp);
6665
const time = sentDate.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
6766

6867
return (
69-
<div className="flex flex-col">
70-
<div className="flex items-center space-x-2">
71-
<span className="font-semibold text-sm text-gray-800">{msg.username}</span>
72-
<span className="text-xs text-gray-400">{time}</span>
68+
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'}`}>
69+
<div className={`flex flex-col max-w-[70%] ${isCurrentUser ? 'items-end' : 'items-start'}`}>
70+
{/* Username and timestamp */}
71+
<div className="flex items-center space-x-2 mb-1 px-1">
72+
{!isCurrentUser && (
73+
<>
74+
<span className="font-semibold text-xs text-gray-700">{msg.username}</span>
75+
<span className="text-xs text-gray-400">{time}</span>
76+
</>
77+
)}
78+
{isCurrentUser && (
79+
<>
80+
<span className="text-xs text-gray-400">{time}</span>
81+
<span className="font-semibold text-xs text-gray-700">You</span>
82+
</>
83+
)}
84+
</div>
85+
86+
{/* Message bubble */}
87+
<div
88+
className={`px-4 py-2 rounded-2xl ${
89+
isCurrentUser
90+
? 'bg-blue-500 text-white rounded-tr-sm'
91+
: 'bg-gray-100 text-gray-800 rounded-tl-sm'
92+
}`}
93+
>
94+
<p className="text-sm whitespace-pre-wrap wrap-break-word">{msg.text}</p>
95+
</div>
7396
</div>
74-
<p className="text-sm text-gray-700">{msg.text}</p>
7597
</div>
7698
);
7799
}

frontend/src/collaboration/components/SubmissionPanel.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Send, Code, Image, Play, Eye, LogOut, Mic, Video} from 'lucide-react';
1+
import {Eye, LogOut} from 'lucide-react';
22
import {useEffect, useState} from 'react';
33
import ConfirmDialog from '../../components/ConfirmDialog';
44
import {ChatPanel} from './ChatPanel';
@@ -69,8 +69,6 @@ export default function SubmissionPanel({
6969
className="flex-1 rounded-lg p-4 text-white flex flex-col items-center justify-center"
7070
style={{backgroundColor: localUser.color}}
7171
>
72-
<Mic size={20} className="mb-1" />
73-
<Video size={20} className="mb-2" />
7472
<span className="font-semibold text-lg">{localUser.name} (You)</span>
7573
</div>
7674
)}

0 commit comments

Comments
 (0)