529 lines
16 KiB
JavaScript
529 lines
16 KiB
JavaScript
import { generateClientId, encryptMessage, decryptMessage, logEvent, isString, isObject, getTime } from './utils.js';
|
|
|
|
// 定义Content-Type映射
|
|
const contentTypeMap = {
|
|
'html': 'text/html',
|
|
'css': 'text/css',
|
|
'js': 'text/javascript',
|
|
'json': 'application/json',
|
|
'png': 'image/png',
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'gif': 'image/gif',
|
|
'svg': 'image/svg+xml',
|
|
'ico': 'image/x-icon'
|
|
};
|
|
|
|
// 获取文件的Content-Type
|
|
function getContentType(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
return contentTypeMap[ext] || 'text/plain';
|
|
}
|
|
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
|
|
// 处理WebSocket请求
|
|
const upgradeHeader = request.headers.get('Upgrade');
|
|
if (upgradeHeader && upgradeHeader === 'websocket') {
|
|
// Get or create a Durable Object instance
|
|
const id = env.CHAT_ROOM.idFromName('chat-room');
|
|
const stub = env.CHAT_ROOM.get(id);
|
|
|
|
return stub.fetch(request);
|
|
}
|
|
|
|
// 处理静态文件请求
|
|
try {
|
|
// 根路径重定向到index.html
|
|
if (path === '/' || path === '') {
|
|
return new Response('', {
|
|
status: 302,
|
|
headers: {
|
|
'Location': '/index.html'
|
|
}
|
|
});
|
|
}
|
|
|
|
// 从KV存储或Assets中获取静态文件
|
|
let contentPath = path;
|
|
if (contentPath.startsWith('/')) {
|
|
contentPath = contentPath.substring(1);
|
|
}
|
|
|
|
// 这里使用了Cloudflare Workers的__STATIC_CONTENT变量来获取静态资源
|
|
// 在实际部署时,您需要将静态文件上传到Cloudflare Workers的Assets中
|
|
const staticContent = await env.__STATIC_CONTENT.get(contentPath, 'stream');
|
|
|
|
if (staticContent === null) {
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
|
|
return new Response(staticContent, {
|
|
headers: {
|
|
'Content-Type': getContentType(contentPath)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
return new Response('Server Error: ' + error.message, { status: 500 });
|
|
}
|
|
}
|
|
};
|
|
|
|
export class ChatRoom { constructor(state, env) {
|
|
this.state = state;
|
|
|
|
// Use objects like original server.js instead of Maps
|
|
this.clients = {};
|
|
this.channels = {};
|
|
|
|
this.config = {
|
|
seenTimeout: 60000,
|
|
debug: false
|
|
};
|
|
|
|
// Initialize RSA key pair
|
|
this.initRSAKeyPair();
|
|
}
|
|
|
|
async initRSAKeyPair() {
|
|
try {
|
|
let stored = await this.state.storage.get('rsaKeyPair');
|
|
if (!stored) {
|
|
console.log('Generating new RSA keypair...');
|
|
const keyPair = await crypto.subtle.generateKey(
|
|
{
|
|
name: 'RSASSA-PKCS1-v1_5',
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: 'SHA-256'
|
|
},
|
|
true,
|
|
['sign', 'verify']
|
|
);
|
|
|
|
// 并行导出公钥和私钥以提高性能
|
|
const [publicKeyBuffer, privateKeyBuffer] = await Promise.all([
|
|
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
|
]);
|
|
|
|
stored = {
|
|
rsaPublic: btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer))),
|
|
rsaPrivateData: Array.from(new Uint8Array(privateKeyBuffer)),
|
|
createdAt: Date.now() // 记录密钥创建时间,用于后续判断是否需要轮换
|
|
};
|
|
|
|
await this.state.storage.put('rsaKeyPair', stored);
|
|
console.log('RSA key pair generated and stored');
|
|
}
|
|
|
|
// Reconstruct the private key
|
|
if (stored.rsaPrivateData) {
|
|
const privateKeyBuffer = new Uint8Array(stored.rsaPrivateData);
|
|
|
|
stored.rsaPrivate = await crypto.subtle.importKey(
|
|
'pkcs8',
|
|
privateKeyBuffer,
|
|
{
|
|
name: 'RSASSA-PKCS1-v1_5',
|
|
hash: 'SHA-256'
|
|
},
|
|
false,
|
|
['sign']
|
|
); }
|
|
this.keyPair = stored;
|
|
|
|
// 检查密钥是否需要轮换(如果已创建超过24小时)
|
|
if (stored.createdAt && (Date.now() - stored.createdAt > 24 * 60 * 60 * 1000)) {
|
|
// 如果没有任何客户端,则执行密钥轮换
|
|
if (Object.keys(this.clients).length === 0) {
|
|
console.log('密钥已使用24小时,进行轮换...');
|
|
await this.state.storage.delete('rsaKeyPair');
|
|
this.keyPair = null;
|
|
await this.initRSAKeyPair();
|
|
} else {
|
|
// 否则标记需要在客户端全部断开后进行轮换
|
|
await this.state.storage.put('pendingKeyRotation', true);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initializing RSA key pair:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async fetch(request) {
|
|
// Check for WebSocket upgrade
|
|
const upgradeHeader = request.headers.get('Upgrade');
|
|
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
return new Response('Expected WebSocket Upgrade', { status: 426 });
|
|
}
|
|
|
|
// Ensure RSA keys are initialized
|
|
if (!this.keyPair) {
|
|
await this.initRSAKeyPair();
|
|
}
|
|
|
|
const webSocketPair = new WebSocketPair();
|
|
const [client, server] = Object.values(webSocketPair);
|
|
|
|
// Accept the WebSocket connection
|
|
this.handleSession(server);
|
|
|
|
return new Response(null, {
|
|
status: 101,
|
|
webSocket: client,
|
|
});
|
|
} // WebSocket connection event handler
|
|
async handleSession(connection) { connection.accept();
|
|
|
|
// 清理旧连接
|
|
await this.cleanupOldConnections();
|
|
|
|
const clientId = generateClientId();
|
|
|
|
if (!clientId || this.clients[clientId]) {
|
|
this.closeConnection(connection);
|
|
return;
|
|
}
|
|
|
|
logEvent('connection', clientId, 'debug'); // Store client information
|
|
this.clients[clientId] = {
|
|
connection: connection,
|
|
seen: getTime(),
|
|
key: null,
|
|
shared: null,
|
|
channel: null
|
|
};
|
|
|
|
// Send RSA public key
|
|
try {
|
|
logEvent('sending-public-key', clientId, 'debug');
|
|
this.sendMessage(connection, JSON.stringify({
|
|
type: 'server-key',
|
|
key: this.keyPair.rsaPublic
|
|
}));
|
|
} catch (error) {
|
|
logEvent('sending-public-key', error, 'error');
|
|
} // Handle messages
|
|
connection.addEventListener('message', async (event) => {
|
|
const message = event.data;
|
|
|
|
if (!isString(message) || !this.clients[clientId]) {
|
|
return;
|
|
}
|
|
|
|
this.clients[clientId].seen = getTime();
|
|
|
|
if (message === 'ping') {
|
|
this.sendMessage(connection, 'pong');
|
|
return;
|
|
}
|
|
|
|
logEvent('message', [clientId, message], 'debug'); // Handle key exchange
|
|
if (!this.clients[clientId].shared && message.length < 2048) {
|
|
try {
|
|
// Generate ECDH key pair using P-384 curve (equivalent to secp384r1)
|
|
const keys = await crypto.subtle.generateKey(
|
|
{
|
|
name: 'ECDH',
|
|
namedCurve: 'P-384'
|
|
},
|
|
true,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
const publicKeyBuffer = await crypto.subtle.exportKey('raw', keys.publicKey);
|
|
|
|
// Sign the public key using PKCS1 padding (compatible with original)
|
|
const signature = await crypto.subtle.sign(
|
|
{
|
|
name: 'RSASSA-PKCS1-v1_5'
|
|
},
|
|
this.keyPair.rsaPrivate,
|
|
publicKeyBuffer
|
|
);
|
|
|
|
// Convert hex string to Uint8Array for client public key
|
|
const clientPublicKeyHex = message;
|
|
const clientPublicKeyBytes = new Uint8Array(clientPublicKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
|
|
// Import client's public key
|
|
const clientPublicKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
clientPublicKeyBytes,
|
|
{ name: 'ECDH', namedCurve: 'P-384' },
|
|
false,
|
|
[]
|
|
);
|
|
|
|
// Derive shared secret bits (equivalent to computeSecret in Node.js)
|
|
const sharedSecretBits = await crypto.subtle.deriveBits(
|
|
{
|
|
name: 'ECDH',
|
|
public: clientPublicKey
|
|
},
|
|
keys.privateKey,
|
|
384 // P-384 produces 48 bytes (384 bits)
|
|
); // Take bytes 8-40 (32 bytes) for AES-256 key
|
|
this.clients[clientId].shared = new Uint8Array(sharedSecretBits).slice(8, 40);
|
|
|
|
const response = Array.from(new Uint8Array(publicKeyBuffer))
|
|
.map(b => b.toString(16).padStart(2, '0')).join('') +
|
|
'|' + btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
|
|
this.sendMessage(connection, response);
|
|
|
|
} catch (error) {
|
|
logEvent('message-key', [clientId, error], 'error');
|
|
this.closeConnection(connection);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle encrypted messages
|
|
if (this.clients[clientId].shared && message.length <= (8 * 1024 * 1024)) {
|
|
this.processEncryptedMessage(clientId, message);
|
|
}
|
|
}); // Handle connection close
|
|
connection.addEventListener('close', async (event) => {
|
|
logEvent('close', [clientId, event], 'debug');
|
|
|
|
const channel = this.clients[clientId].channel;
|
|
|
|
if (channel && this.channels[channel]) {
|
|
this.channels[channel].splice(this.channels[channel].indexOf(clientId), 1);
|
|
|
|
if (this.channels[channel].length === 0) {
|
|
delete(this.channels[channel]);
|
|
} else {
|
|
try {
|
|
const members = this.channels[channel];
|
|
|
|
for (const member of members) {
|
|
const client = this.clients[member]; if (this.isClientInChannel(client, channel)) {
|
|
this.sendMessage(client.connection, encryptMessage({
|
|
a: 'l',
|
|
p: members.filter((value) => {
|
|
return (value !== member ? true : false);
|
|
})
|
|
}, client.shared));
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
logEvent('close-list', [clientId, error], 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.clients[clientId]) {
|
|
delete(this.clients[clientId]);
|
|
}
|
|
});
|
|
}
|
|
// Process encrypted messages
|
|
processEncryptedMessage(clientId, message) {
|
|
let decrypted = null;
|
|
|
|
try {
|
|
decrypted = decryptMessage(message, this.clients[clientId].shared);
|
|
|
|
logEvent('message-decrypted', [clientId, decrypted], 'debug');
|
|
|
|
if (!isObject(decrypted) || !isString(decrypted.a)) {
|
|
return;
|
|
}
|
|
|
|
const action = decrypted.a;
|
|
|
|
if (action === 'j') {
|
|
this.handleJoinChannel(clientId, decrypted);
|
|
} else if (action === 'c') {
|
|
this.handleClientMessage(clientId, decrypted);
|
|
} else if (action === 'w') {
|
|
this.handleChannelMessage(clientId, decrypted);
|
|
}
|
|
|
|
} catch (error) {
|
|
logEvent('process-encrypted-message', [clientId, error], 'error');
|
|
} finally {
|
|
decrypted = null;
|
|
}
|
|
}
|
|
// Handle channel join requests
|
|
handleJoinChannel(clientId, decrypted) {
|
|
if (!isString(decrypted.p) || this.clients[clientId].channel) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const channel = decrypted.p;
|
|
|
|
this.clients[clientId].channel = channel;
|
|
|
|
if (!this.channels[channel]) {
|
|
this.channels[channel] = [clientId];
|
|
} else {
|
|
this.channels[channel].push(clientId);
|
|
}
|
|
|
|
this.broadcastMemberList(channel);
|
|
|
|
} catch (error) {
|
|
logEvent('message-join', [clientId, error], 'error');
|
|
}
|
|
}
|
|
// Handle client messages
|
|
handleClientMessage(clientId, decrypted) {
|
|
if (!isString(decrypted.p) || !isString(decrypted.c) || !this.clients[clientId].channel) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const channel = this.clients[clientId].channel;
|
|
const targetClient = this.clients[decrypted.c];
|
|
|
|
if (this.isClientInChannel(targetClient, channel)) {
|
|
const messageObj = {
|
|
a: 'c',
|
|
p: decrypted.p,
|
|
c: clientId
|
|
};
|
|
|
|
const encrypted = encryptMessage(messageObj, targetClient.shared);
|
|
this.sendMessage(targetClient.connection, encrypted);
|
|
|
|
messageObj.p = null;
|
|
}
|
|
|
|
} catch (error) {
|
|
logEvent('message-client', [clientId, error], 'error');
|
|
}
|
|
} // Handle channel messages
|
|
handleChannelMessage(clientId, decrypted) {
|
|
if (!isObject(decrypted.p) || !this.clients[clientId].channel) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const channel = this.clients[clientId].channel;
|
|
// 过滤有效的目标成员
|
|
const validMembers = Object.keys(decrypted.p).filter(member => {
|
|
const targetClient = this.clients[member];
|
|
return isString(decrypted.p[member]) && this.isClientInChannel(targetClient, channel);
|
|
});
|
|
|
|
// 处理所有有效的目标成员
|
|
for (const member of validMembers) {
|
|
const targetClient = this.clients[member];
|
|
const messageObj = {
|
|
a: 'c',
|
|
p: decrypted.p[member],
|
|
c: clientId
|
|
}; const encrypted = encryptMessage(messageObj, targetClient.shared);
|
|
this.sendMessage(targetClient.connection, encrypted);
|
|
|
|
messageObj.p = null;
|
|
}
|
|
|
|
} catch (error) {
|
|
logEvent('message-channel', [clientId, error], 'error');
|
|
}
|
|
}
|
|
// Broadcast member list to channel
|
|
broadcastMemberList(channel) {
|
|
try {
|
|
const members = this.channels[channel];
|
|
|
|
for (const member of members) {
|
|
const client = this.clients[member];
|
|
|
|
if (this.isClientInChannel(client, channel)) {
|
|
const messageObj = {
|
|
a: 'l',
|
|
p: members.filter((value) => {
|
|
return (value !== member ? true : false);
|
|
})
|
|
};
|
|
|
|
const encrypted = encryptMessage(messageObj, client.shared);
|
|
this.sendMessage(client.connection, encrypted);
|
|
|
|
messageObj.p = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logEvent('broadcast-member-list', error, 'error');
|
|
}
|
|
} // Check if client is in channel
|
|
isClientInChannel(client, channel) {
|
|
return (
|
|
client &&
|
|
client.connection &&
|
|
client.shared &&
|
|
client.channel &&
|
|
client.channel === channel ?
|
|
true :
|
|
false
|
|
);
|
|
}
|
|
// Send message helper
|
|
sendMessage(connection, message) {
|
|
try {
|
|
// In Cloudflare Workers, WebSocket.READY_STATE_OPEN is 1
|
|
if (connection.readyState === 1) {
|
|
connection.send(message);
|
|
}
|
|
} catch (error) {
|
|
logEvent('sendMessage', error, 'error');
|
|
}
|
|
} // Close connection helper
|
|
closeConnection(connection) {
|
|
try {
|
|
connection.close(); } catch (error) {
|
|
logEvent('closeConnection', error, 'error');
|
|
}
|
|
}
|
|
|
|
// 连接清理方法
|
|
async cleanupOldConnections() {
|
|
const seenThreshold = getTime() - this.config.seenTimeout;
|
|
const clientsToRemove = [];
|
|
|
|
// 先收集需要移除的客户端,避免在迭代时修改对象
|
|
for (const clientId in this.clients) {
|
|
if (this.clients[clientId].seen < seenThreshold) {
|
|
clientsToRemove.push(clientId);
|
|
}
|
|
}
|
|
|
|
// 然后一次性移除所有过期客户端
|
|
for (const clientId of clientsToRemove) {
|
|
try {
|
|
logEvent('connection-seen', clientId, 'debug');
|
|
this.clients[clientId].connection.close();
|
|
delete this.clients[clientId];
|
|
} catch (error) {
|
|
logEvent('connection-seen', error, 'error'); }
|
|
}
|
|
|
|
// 如果没有任何客户端和房间,检查是否需要轮换密钥
|
|
if (Object.keys(this.clients).length === 0 && Object.keys(this.channels).length === 0) {
|
|
const pendingRotation = await this.state.storage.get('pendingKeyRotation');
|
|
if (pendingRotation) {
|
|
console.log('没有活跃客户端或房间,执行密钥轮换...');
|
|
await this.state.storage.delete('rsaKeyPair'); await this.state.storage.delete('pendingKeyRotation');
|
|
this.keyPair = null;
|
|
await this.initRSAKeyPair();
|
|
}
|
|
}
|
|
|
|
return clientsToRemove.length; // 返回清理的连接数量
|
|
}
|
|
}
|