mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-18 23:26:17 +00:00
fix(chat): harden client state and messaging
This commit is contained in:
+182
-85
@@ -6,6 +6,7 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
@@ -82,7 +83,26 @@ let hostPeerId = null; // peerId of the room host (creator /
|
||||
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
||||
let serverCapabilities = [];
|
||||
let chatSecretGuard = '';
|
||||
let chatSessionGeneration = 0;
|
||||
let chatReceiveQueue = Promise.resolve();
|
||||
const chatSendLimiter = createChatSendLimiter();
|
||||
const webJoinCoordinator = createLatestTaskQueue();
|
||||
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
||||
|
||||
function invalidateChatSession() {
|
||||
chatSessionGeneration++;
|
||||
chatReceiveQueue = Promise.resolve();
|
||||
chatSendLimiter.reset();
|
||||
clearChatKeyCache();
|
||||
}
|
||||
|
||||
async function clearFailedJoinCredentials() {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
}
|
||||
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
|
||||
// in heartbeats so the host's popup UI can show "Solo" instead of silently
|
||||
// appearing un-ACK'd.
|
||||
@@ -377,10 +397,12 @@ async function getSettings() {
|
||||
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
||||
const chatKey = validateChatSecret(data.chatKey);
|
||||
chatSecretGuard = chatKey;
|
||||
const roomId = normalizeRoomId(data.roomId);
|
||||
if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {});
|
||||
return {
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
roomId,
|
||||
password: data.password || '',
|
||||
chatKey,
|
||||
username,
|
||||
@@ -474,6 +496,7 @@ function forceDisconnect() {
|
||||
currentServerUrl = null;
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
invalidateChatSession();
|
||||
isForceSyncInitiator = false;
|
||||
expectedAcksCount = 0;
|
||||
roomIdleSince = null;
|
||||
@@ -567,7 +590,7 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
hcmDesynced: false
|
||||
}).catch(() => {});
|
||||
chatSecretGuard = '';
|
||||
clearChatKeyCache();
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -709,6 +732,7 @@ async function connect() {
|
||||
socket.onclose = () => {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
invalidateChatSession();
|
||||
stopPing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
|
||||
@@ -790,6 +814,7 @@ function broadcastConnectionStatus(status) {
|
||||
status = 'idle';
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
@@ -1045,6 +1070,7 @@ async function handleServerEvent(event, data) {
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
if (currentRoom?.roomId !== data.roomId) invalidateChatSession();
|
||||
currentRoom = data;
|
||||
// Host Control Mode: adopt room role/mode on (re)join.
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
@@ -1131,42 +1157,54 @@ async function handleServerEvent(event, data) {
|
||||
break;
|
||||
case EVENTS.CHAT_MESSAGE: {
|
||||
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatKey) break;
|
||||
try {
|
||||
const text = await decryptChatMessage({
|
||||
ciphertext: data.ciphertext,
|
||||
roomId: currentRoom.roomId,
|
||||
senderId: data.senderId,
|
||||
secret: settings.chatKey
|
||||
});
|
||||
const senderPeer = currentRoom.peers?.find(candidate =>
|
||||
(typeof candidate === 'object' ? candidate.peerId : candidate) === data.senderId
|
||||
);
|
||||
const tabId = Number(currentTabId);
|
||||
if (Number.isInteger(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'CHAT_MESSAGE',
|
||||
message: {
|
||||
id: data.id,
|
||||
senderId: data.senderId,
|
||||
username: typeof senderPeer === 'object' ? senderPeer.username : null,
|
||||
timestamp: data.timestamp,
|
||||
text
|
||||
}
|
||||
}).catch(() => {});
|
||||
const generation = chatSessionGeneration;
|
||||
const roomId = currentRoom.roomId;
|
||||
const tabId = Number(currentTabId);
|
||||
const received = { ...data };
|
||||
const isCurrentSession = () => generation === chatSessionGeneration &&
|
||||
currentRoom?.roomId === roomId && Number(currentTabId) === tabId;
|
||||
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
|
||||
if (!isCurrentSession()) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
|
||||
const chatKey = settings.chatKey;
|
||||
try {
|
||||
const text = await decryptChatMessage({
|
||||
ciphertext: received.ciphertext,
|
||||
roomId,
|
||||
senderId: received.senderId,
|
||||
secret: chatKey
|
||||
});
|
||||
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
|
||||
const senderPeer = currentRoom.peers?.find(candidate =>
|
||||
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
|
||||
);
|
||||
if (Number.isInteger(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'CHAT_MESSAGE',
|
||||
message: {
|
||||
id: received.id,
|
||||
senderId: received.senderId,
|
||||
username: typeof senderPeer === 'object' ? senderPeer.username : null,
|
||||
timestamp: received.timestamp,
|
||||
text
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {
|
||||
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
||||
}
|
||||
} catch (_) {
|
||||
addLog('Discarded chat message that failed authentication', 'warn');
|
||||
}
|
||||
});
|
||||
await chatReceiveQueue;
|
||||
break;
|
||||
}
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear connectIntent to prevent an infinite reconnect loop.
|
||||
if (!currentRoom) {
|
||||
connectIntent = false;
|
||||
// clear persisted credentials as well, otherwise service-worker
|
||||
// restart would immediately retry the rejected room.
|
||||
if (!currentRoom && connectIntent) {
|
||||
await clearFailedJoinCredentials();
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
reconnectAttempts = 0;
|
||||
reconnectFailed = false;
|
||||
@@ -1910,7 +1948,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
clearChatKeyCache();
|
||||
invalidateChatSession();
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup so they drop any guest-side HCM state from the
|
||||
// previous room (badge/dialog/desync) — H-2/H-3.
|
||||
@@ -1952,21 +1990,33 @@ async function applyAudioSettingsToTab(tabId) {
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
handleAsyncMessage(message, sender, sendResponse);
|
||||
handleAsyncMessage(message, sender, sendResponse).catch(error => {
|
||||
addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error');
|
||||
try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ }
|
||||
});
|
||||
return true; // Keep channel open for async responses
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return;
|
||||
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||
invalidateChatSession();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
});
|
||||
|
||||
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (!message) return;
|
||||
await ensureState();
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
webJoinCoordinator.invalidate();
|
||||
const settings = await getSettings();
|
||||
connectIntent = !!settings.roomId;
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
@@ -2053,6 +2103,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({
|
||||
supported: serverSupports(CAPABILITIES.CHAT),
|
||||
hasKey: !!settings.chatKey,
|
||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||
peerId,
|
||||
roomId: currentRoom.roomId,
|
||||
strings: {
|
||||
@@ -2082,18 +2133,39 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'unsupported' });
|
||||
return;
|
||||
}
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
sendResponse({ status: 'disconnected' });
|
||||
return;
|
||||
}
|
||||
const generation = chatSessionGeneration;
|
||||
const roomId = currentRoom.roomId;
|
||||
const tabId = Number(currentTabId);
|
||||
const socketSnapshot = socket;
|
||||
const isCurrentSession = () => generation === chatSessionGeneration &&
|
||||
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
|
||||
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatKey) {
|
||||
sendResponse({ status: 'missing_key' });
|
||||
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
|
||||
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
|
||||
return;
|
||||
}
|
||||
const chatKey = settings.chatKey;
|
||||
const rateLimit = chatSendLimiter.take();
|
||||
if (!rateLimit.allowed) {
|
||||
sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ciphertext = await encryptChatMessage({
|
||||
text: message.text,
|
||||
roomId: currentRoom.roomId,
|
||||
roomId,
|
||||
senderId: peerId,
|
||||
secret: settings.chatKey
|
||||
secret: chatKey
|
||||
});
|
||||
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
|
||||
sendResponse({ status: 'session_changed' });
|
||||
return;
|
||||
}
|
||||
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
|
||||
sendResponse({ status: sent ? 'ok' : 'disconnected' });
|
||||
} catch (err) {
|
||||
@@ -2102,7 +2174,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'CREATE_CHAT_KEY') {
|
||||
const chatKey = generateChatSecret();
|
||||
chatSecretGuard = chatKey;
|
||||
clearChatKeyCache();
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ chatKey });
|
||||
sendResponse({ status: 'ok', chatKey });
|
||||
} else if (message.type === 'SET_CONTROL_MODE') {
|
||||
@@ -2174,6 +2246,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
@@ -2219,7 +2292,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
hcmDesynced: false
|
||||
});
|
||||
chatSecretGuard = '';
|
||||
clearChatKeyCache();
|
||||
invalidateChatSession();
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -2237,7 +2310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||
const roomId = normalizeRoomId(rawRoomId);
|
||||
const chatKey = validateChatSecret(rawChatKey);
|
||||
if (!roomId) {
|
||||
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
||||
@@ -2248,53 +2321,77 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'invalid_room_id' });
|
||||
return;
|
||||
}
|
||||
connectIntent = true;
|
||||
chrome.storage.local.set({
|
||||
roomId,
|
||||
password,
|
||||
chatKey,
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: serverUrl || ''
|
||||
}, async () => {
|
||||
chatSecretGuard = chatKey;
|
||||
clearChatKeyCache();
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return;
|
||||
await webJoinCoordinator.run(async isCurrentJoin => {
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else if (roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
try {
|
||||
connectIntent = true;
|
||||
await chrome.storage.local.set({
|
||||
roomId,
|
||||
password: typeof password === 'string' ? password : '',
|
||||
chatKey,
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: typeof serverUrl === 'string' ? serverUrl : ''
|
||||
});
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
chatSecretGuard = chatKey;
|
||||
invalidateChatSession();
|
||||
const settings = await getSettings();
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return { status: 'already_joined' };
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else if (roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
addLog(`Joining room via link: ${roomId}`, 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
return { status: 'ok' };
|
||||
} catch (_) {
|
||||
if (isCurrentJoin()) await clearFailedJoinCredentials();
|
||||
sendResponse({ status: 'storage_error' });
|
||||
return { status: 'storage_error' };
|
||||
}
|
||||
addLog(`Joining room via link: ${roomId}`, 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'REGENERATE_ID') {
|
||||
// Match getPeerId()'s 16-hex-char generation — see comment there.
|
||||
|
||||
+26
-16
@@ -4,9 +4,10 @@ const IV_BYTES = 12;
|
||||
const MIN_ENCRYPTED_BYTES = 29;
|
||||
const MAX_ENCRYPTED_BYTES = 2028;
|
||||
|
||||
let cachedKey = null;
|
||||
let cachedKeyPromise = null;
|
||||
let cachedRoomId = '';
|
||||
let cachedSecret = '';
|
||||
let cacheGeneration = 0;
|
||||
|
||||
function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
@@ -50,7 +51,8 @@ export function normalizeOutgoingChatText(value) {
|
||||
}
|
||||
|
||||
export function clearChatKeyCache() {
|
||||
cachedKey = null;
|
||||
cacheGeneration++;
|
||||
cachedKeyPromise = null;
|
||||
cachedRoomId = '';
|
||||
cachedSecret = '';
|
||||
}
|
||||
@@ -58,21 +60,30 @@ export function clearChatKeyCache() {
|
||||
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
|
||||
const validSecret = validateChatSecret(secret);
|
||||
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
|
||||
if (cachedKey && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKey;
|
||||
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
|
||||
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const material = await cryptoImpl.subtle.importKey(
|
||||
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
|
||||
);
|
||||
cachedKey = await cryptoImpl.subtle.deriveKey({
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode(roomId),
|
||||
info: encoder.encode(CHAT_INFO)
|
||||
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
const generation = cacheGeneration;
|
||||
const keyPromise = (async () => {
|
||||
const material = await cryptoImpl.subtle.importKey(
|
||||
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
|
||||
);
|
||||
return cryptoImpl.subtle.deriveKey({
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode(roomId),
|
||||
info: encoder.encode(CHAT_INFO)
|
||||
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
})();
|
||||
cachedKeyPromise = keyPromise;
|
||||
cachedRoomId = roomId;
|
||||
cachedSecret = validSecret;
|
||||
return cachedKey;
|
||||
try {
|
||||
return await keyPromise;
|
||||
} catch (error) {
|
||||
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||
@@ -106,7 +117,6 @@ export async function decryptChatMessage({ ciphertext, roomId, senderId, secret
|
||||
iv: bytes.slice(0, IV_BYTES),
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, bytes.slice(IV_BYTES));
|
||||
const text = new globalThis.TextDecoder().decode(plaintext);
|
||||
if (!text || countCodePoints(text) > 500) throw new RangeError('Decrypted chat message exceeds client limits');
|
||||
return text;
|
||||
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
|
||||
return normalizeOutgoingChatText(text);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,33 @@ describe('chat crypto', () => {
|
||||
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
|
||||
});
|
||||
|
||||
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
let deriveCalls = 0;
|
||||
let releaseDerive;
|
||||
const delayedCrypto = {
|
||||
...webcrypto,
|
||||
subtle: {
|
||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||
deriveKey: async (...args) => {
|
||||
deriveCalls++;
|
||||
await new Promise(resolve => { releaseDerive = resolve; });
|
||||
return webcrypto.subtle.deriveKey(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
await Promise.resolve();
|
||||
expect(deriveCalls).toBe(1);
|
||||
clearChatKeyCache();
|
||||
releaseDerive();
|
||||
await Promise.all([first, second]);
|
||||
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
expect(fresh).not.toBe(first);
|
||||
await fresh;
|
||||
});
|
||||
|
||||
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
|
||||
@@ -61,4 +88,27 @@ describe('chat crypto', () => {
|
||||
const bytes = Buffer.from(ciphertext, 'base64url');
|
||||
expect(bytes).toHaveLength(2028);
|
||||
});
|
||||
|
||||
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const roomId = 'ROOM-1';
|
||||
const senderId = 'alice';
|
||||
const key = await deriveChatKey(roomId, secret, webcrypto);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
|
||||
async function encryptRaw(plaintext) {
|
||||
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, plaintext));
|
||||
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
|
||||
}
|
||||
|
||||
const whitespace = await encryptRaw(encoder.encode(' '));
|
||||
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
|
||||
const malformed = await encryptRaw(Uint8Array.of(0xff));
|
||||
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
||||
const extensionDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
|
||||
const localeDir = path.join(extensionDir, 'locales');
|
||||
const chatKeys = [
|
||||
'CHAT_TITLE',
|
||||
@@ -44,6 +45,33 @@ describe('chat overlay contract', () => {
|
||||
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
|
||||
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
|
||||
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
|
||||
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
|
||||
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
|
||||
});
|
||||
|
||||
it('guards async refresh/send work and clears all composer state on room reset', () => {
|
||||
expect(overlaySource).toContain('generation !== refreshGeneration');
|
||||
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
|
||||
expect(overlaySource).toContain('textarea.value === submittedValue');
|
||||
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
|
||||
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
|
||||
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
|
||||
expect(backgroundSource).toContain("status: 'rate_limited'");
|
||||
});
|
||||
|
||||
it('keeps unavailable chat controls discoverable to assistive technology', () => {
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
|
||||
expect(overlaySource).not.toContain('launcher.disabled =');
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
|
||||
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
|
||||
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
|
||||
});
|
||||
|
||||
it('creates a chat key for both generated-room entry points', () => {
|
||||
expect(popupSource).toContain('let pendingRoomCreation = false');
|
||||
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
|
||||
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
|
||||
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
|
||||
});
|
||||
|
||||
it('contains every chat string in all 15 extension locales', () => {
|
||||
|
||||
+90
-30
@@ -16,6 +16,9 @@
|
||||
let destroyed = false;
|
||||
let saveTimer = null;
|
||||
let applyingLayout = false;
|
||||
let refreshGeneration = 0;
|
||||
let sendGeneration = 0;
|
||||
let sending = false;
|
||||
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
|
||||
let themeMode = 'system';
|
||||
let themePalette = 'eucalyptus';
|
||||
@@ -85,11 +88,11 @@
|
||||
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
|
||||
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px;
|
||||
}
|
||||
.launcher:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.launcher:disabled { cursor: not-allowed; opacity: .58; }
|
||||
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
|
||||
.panel {
|
||||
position: fixed; display: none; flex-direction: column; overflow: hidden;
|
||||
min-width: ${MIN_WIDTH}px; min-height: ${MIN_HEIGHT}px; max-width: calc(100vw - 16px);
|
||||
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
|
||||
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
|
||||
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
||||
@@ -120,6 +123,7 @@
|
||||
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
|
||||
.send:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.send:disabled { opacity: .55; cursor: wait; }
|
||||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
`;
|
||||
@@ -135,6 +139,8 @@
|
||||
app.id = 'app';
|
||||
const launcher = element('button', 'launcher', '💬');
|
||||
launcher.type = 'button';
|
||||
const launcherHint = element('span', 'visually-hidden');
|
||||
launcherHint.id = 'chat-launcher-hint';
|
||||
const panel = element('section', 'panel');
|
||||
panel.setAttribute('role', 'dialog');
|
||||
const header = element('header', 'header');
|
||||
@@ -155,28 +161,43 @@
|
||||
const empty = element('div', 'empty');
|
||||
messages.append(empty);
|
||||
const composer = element('form', 'composer');
|
||||
const textareaLabel = element('label', 'visually-hidden');
|
||||
textareaLabel.htmlFor = 'chat-composer-input';
|
||||
const textarea = element('textarea');
|
||||
textarea.id = 'chat-composer-input';
|
||||
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
|
||||
const composerRow = element('div', 'composer-row');
|
||||
const count = element('span', 'count', '0/500');
|
||||
count.id = 'chat-composer-count';
|
||||
const sendButton = element('button', 'send');
|
||||
sendButton.type = 'submit';
|
||||
composerRow.append(count, sendButton);
|
||||
const status = element('div', 'status');
|
||||
composer.append(textarea, composerRow, status);
|
||||
status.id = 'chat-composer-status';
|
||||
status.setAttribute('role', 'status');
|
||||
composer.append(textareaLabel, textarea, composerRow, status);
|
||||
panel.append(header, messages, composer);
|
||||
app.append(launcher, panel);
|
||||
app.append(launcherHint, launcher, panel);
|
||||
shadow.append(style, app);
|
||||
document.documentElement.append(host);
|
||||
|
||||
function messageRuntime(payload) {
|
||||
function messageRuntime(payload, timeoutMs = 5000) {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = response => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(response || null);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(null), timeoutMs);
|
||||
try {
|
||||
chrome.runtime.sendMessage(payload, response => {
|
||||
if (chrome.runtime.lastError) resolve(null);
|
||||
else resolve(response || null);
|
||||
if (chrome.runtime.lastError) finish(null);
|
||||
else finish(response);
|
||||
});
|
||||
} catch (_) {
|
||||
resolve(null);
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -190,9 +211,16 @@
|
||||
title.textContent = text.title || '';
|
||||
subtitle.textContent = text.liveOnly || '';
|
||||
launcher.setAttribute('aria-label', text.open || '');
|
||||
launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (text.open || '');
|
||||
const unavailableHint = context?.supported && !context?.hasKey
|
||||
? (text.missingKey || '')
|
||||
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
|
||||
launcher.title = unavailableHint || text.open || '';
|
||||
launcherHint.textContent = unavailableHint;
|
||||
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
|
||||
else launcher.removeAttribute('aria-describedby');
|
||||
panel.setAttribute('aria-label', text.title || '');
|
||||
textarea.placeholder = text.placeholder || '';
|
||||
textareaLabel.textContent = text.placeholder || text.title || '';
|
||||
sendButton.textContent = text.send || '';
|
||||
empty.textContent = text.empty || '';
|
||||
leftButton.title = text.dockLeft || '';
|
||||
@@ -212,15 +240,19 @@
|
||||
}
|
||||
|
||||
function viewportBounds() {
|
||||
return { width: Math.max(320, window.innerWidth), height: Math.max(360, window.innerHeight) };
|
||||
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
|
||||
}
|
||||
|
||||
function clampDetached() {
|
||||
const viewport = viewportBounds();
|
||||
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16));
|
||||
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16);
|
||||
layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8));
|
||||
layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8));
|
||||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||||
const maxHeight = Math.max(1, viewport.height - 16);
|
||||
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
|
||||
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
|
||||
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
|
||||
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
|
||||
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
|
||||
}
|
||||
|
||||
function saveLayout() {
|
||||
@@ -252,13 +284,16 @@
|
||||
launcher.style.right = 'auto';
|
||||
launcher.style.top = `${layout.y}px`;
|
||||
} else {
|
||||
panel.style.top = '64px';
|
||||
panel.style.width = `${DEFAULT_WIDTH}px`;
|
||||
panel.style.height = 'min(560px, calc(100vh - 80px))';
|
||||
panel.style[layout.mode] = '16px';
|
||||
launcher.style.top = 'calc(50vh - 24px)';
|
||||
launcher.style.left = layout.mode === 'left' ? '16px' : 'auto';
|
||||
launcher.style.right = layout.mode === 'right' ? '16px' : 'auto';
|
||||
const viewport = viewportBounds();
|
||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
||||
panel.style.top = `${panelTop}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style[layout.mode] = `${gutter}px`;
|
||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
||||
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
|
||||
}
|
||||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||||
}
|
||||
@@ -290,18 +325,22 @@
|
||||
context = next || null;
|
||||
const supported = !!context?.supported;
|
||||
const hasKey = !!context?.hasKey;
|
||||
context = context ? { ...context, enabled: supported && hasKey } : null;
|
||||
const connected = !!context?.connected;
|
||||
context = context ? { ...context, enabled: supported && hasKey && connected } : null;
|
||||
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
|
||||
host.style.display = supported ? '' : 'none';
|
||||
launcher.disabled = !hasKey;
|
||||
if (!supported || !hasKey) setOpened(false);
|
||||
launcher.setAttribute('aria-disabled', String(!context?.enabled));
|
||||
if (!context?.enabled) setOpened(false);
|
||||
applyStrings();
|
||||
applyLayout();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (destroyed) return;
|
||||
applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' }));
|
||||
const generation = ++refreshGeneration;
|
||||
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
|
||||
if (destroyed || generation !== refreshGeneration) return;
|
||||
applyContext(next);
|
||||
}
|
||||
|
||||
function appendMessage(message) {
|
||||
@@ -332,6 +371,16 @@
|
||||
messages.replaceChildren(empty);
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
sendGeneration++;
|
||||
sending = false;
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
count.style.color = '';
|
||||
status.textContent = '';
|
||||
sendButton.disabled = false;
|
||||
}
|
||||
|
||||
launcher.addEventListener('click', () => setOpened(true));
|
||||
closeButton.addEventListener('click', () => setOpened(false));
|
||||
leftButton.addEventListener('click', () => setMode('left'));
|
||||
@@ -347,24 +396,32 @@
|
||||
textarea.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
composer.requestSubmit();
|
||||
if (!sending) composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
composer.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (sending || !context?.enabled) return;
|
||||
const submittedValue = textarea.value;
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
if ([...text].length > 500) {
|
||||
status.textContent = strings().tooLong || '';
|
||||
return;
|
||||
}
|
||||
const generation = ++sendGeneration;
|
||||
sending = true;
|
||||
sendButton.disabled = true;
|
||||
status.textContent = '';
|
||||
const response = await messageRuntime({ type: 'CHAT_SEND', text });
|
||||
if (destroyed || generation !== sendGeneration) return;
|
||||
sending = false;
|
||||
sendButton.disabled = false;
|
||||
if (response?.status === 'ok') {
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
if (textarea.value === submittedValue) {
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
}
|
||||
} else {
|
||||
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
|
||||
}
|
||||
@@ -428,9 +485,10 @@
|
||||
|
||||
function handleRuntime(message) {
|
||||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
|
||||
if (message?.type === 'CHAT_CONTEXT_UPDATE') refresh();
|
||||
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||||
if (message?.type === 'CHAT_RESET') {
|
||||
clearMessages();
|
||||
resetComposer();
|
||||
refresh();
|
||||
}
|
||||
if (message?.type === 'CHAT_DESTROY') destroy();
|
||||
@@ -439,6 +497,8 @@
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
refreshGeneration++;
|
||||
sendGeneration++;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const MAX_ROOM_ID_LENGTH = 64;
|
||||
export const CHAT_SEND_LIMIT = 9;
|
||||
export const CHAT_SEND_WINDOW_MS = 10000;
|
||||
|
||||
export function normalizeRoomId(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH);
|
||||
}
|
||||
|
||||
export function createChatSendLimiter({
|
||||
limit = CHAT_SEND_LIMIT,
|
||||
windowMs = CHAT_SEND_WINDOW_MS,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
let timestamps = [];
|
||||
|
||||
return {
|
||||
take() {
|
||||
const current = now();
|
||||
timestamps = timestamps.filter(timestamp => current - timestamp < windowMs);
|
||||
if (timestamps.length >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterMs: Math.max(1, windowMs - (current - timestamps[0]))
|
||||
};
|
||||
}
|
||||
timestamps.push(current);
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
},
|
||||
reset() {
|
||||
timestamps = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createLatestTaskQueue() {
|
||||
let generation = 0;
|
||||
let tail = Promise.resolve();
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
generation++;
|
||||
},
|
||||
async run(task) {
|
||||
const requestGeneration = ++generation;
|
||||
const previous = tail;
|
||||
let release;
|
||||
tail = new Promise(resolve => { release = resolve; });
|
||||
await previous.catch(() => {});
|
||||
const isCurrent = () => requestGeneration === generation;
|
||||
try {
|
||||
return await task(isCurrent);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_SEND_LIMIT,
|
||||
createChatSendLimiter,
|
||||
createLatestTaskQueue,
|
||||
MAX_ROOM_ID_LENGTH,
|
||||
normalizeRoomId
|
||||
} from './chat-session.js';
|
||||
|
||||
describe('chat session boundaries', () => {
|
||||
it('matches the relay room ID sanitizer and length limit', () => {
|
||||
expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42');
|
||||
expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH));
|
||||
expect(normalizeRoomId(null)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps client chat bursts below the relay disconnect threshold', () => {
|
||||
let current = 1000;
|
||||
const limiter = createChatSendLimiter({ now: () => current });
|
||||
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
}
|
||||
expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 });
|
||||
current += 10000;
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
limiter.reset();
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
});
|
||||
|
||||
it('serializes join work and prevents an older request from winning storage races', async () => {
|
||||
const queue = createLatestTaskQueue();
|
||||
let releaseFirst;
|
||||
const writes = [];
|
||||
const first = queue.run(async isCurrent => {
|
||||
await new Promise(resolve => { releaseFirst = resolve; });
|
||||
if (isCurrent()) writes.push('first');
|
||||
return { status: isCurrent() ? 'ok' : 'superseded' };
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const second = queue.run(async isCurrent => {
|
||||
if (isCurrent()) writes.push('second');
|
||||
return { status: 'ok' };
|
||||
});
|
||||
releaseFirst();
|
||||
await expect(first).resolves.toEqual({ status: 'superseded' });
|
||||
await expect(second).resolves.toEqual({ status: 'ok' });
|
||||
expect(writes).toEqual(['second']);
|
||||
});
|
||||
});
|
||||
@@ -1353,7 +1353,7 @@
|
||||
<!-- Room credentials — distinct group below the server picker. -->
|
||||
<div class="form-group">
|
||||
<label for="roomId" title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
|
||||
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
<input type="text" id="roomId" maxlength="64" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" title="Optional password to restrict room access" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
|
||||
|
||||
+20
-6
@@ -3,10 +3,12 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
import { normalizeRoomId } from './chat-session.js';
|
||||
import './shared/invite-links.js';
|
||||
|
||||
let pendingInviteRoomId = '';
|
||||
let pendingInviteChatKey = '';
|
||||
let pendingRoomCreation = false;
|
||||
|
||||
function normalizeChatKey(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||||
@@ -356,7 +358,7 @@ async function init() {
|
||||
}
|
||||
|
||||
elements.serverUrl.value = localData.serverUrl || '';
|
||||
elements.roomId.value = localData.roomId || '';
|
||||
elements.roomId.value = normalizeRoomId(localData.roomId);
|
||||
elements.password.value = localData.password || '';
|
||||
elements.username.value = username;
|
||||
syncDevToolsVisibility();
|
||||
@@ -1305,9 +1307,9 @@ function checkInviteLink() {
|
||||
try {
|
||||
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
|
||||
if (!invite) return;
|
||||
elements.roomId.value = invite.roomId;
|
||||
elements.roomId.value = normalizeRoomId(invite.roomId);
|
||||
elements.password.value = invite.password;
|
||||
pendingInviteRoomId = invite.roomId;
|
||||
pendingInviteRoomId = elements.roomId.value;
|
||||
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
|
||||
|
||||
elements.serverUrl.value = invite.serverUrl;
|
||||
@@ -1596,7 +1598,8 @@ function showError(msg) {
|
||||
|
||||
// --- Action Handlers ---
|
||||
elements.roomId.addEventListener('input', () => {
|
||||
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
|
||||
elements.roomId.value = normalizeRoomId(elements.roomId.value);
|
||||
pendingRoomCreation = false;
|
||||
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
|
||||
pendingInviteRoomId = '';
|
||||
pendingInviteChatKey = '';
|
||||
@@ -1611,8 +1614,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
const roomIdInput = normalizeRoomId(elements.roomId.value);
|
||||
const isCreating = pendingRoomCreation || !roomIdInput;
|
||||
|
||||
elements.joinBtn.disabled = true;
|
||||
elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING');
|
||||
@@ -1677,10 +1680,20 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
if (isCreating) {
|
||||
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
|
||||
chatKey = normalizeChatKey(created?.chatKey);
|
||||
if (!chatKey) {
|
||||
pendingRoomCreation = false;
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('CHAT_SEND_FAILED'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isCreating) window.justCreatedRoom = true;
|
||||
pendingInviteRoomId = '';
|
||||
pendingInviteChatKey = '';
|
||||
pendingRoomCreation = false;
|
||||
|
||||
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
|
||||
elements.roomId.value = roomId;
|
||||
@@ -1723,6 +1736,7 @@ function handleCreateRoom() {
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
pendingRoomCreation = true;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
|
||||
Reference in New Issue
Block a user