Peercord/Peercord Source/src/p2p/modules/messaging.js
0% [█ █ █ █ █ █ █ █ █ █] 100% 3f8a00cd19 v1.0.8
Some fixes, but networking issues still persists.
2026-06-16 22:20:41 -05:00

569 lines
22 KiB
JavaScript

const b4a = window.require('b4a');
import { generateUUID, sodium, ADMIN_PUBLIC_KEY } from '../utils.js';
import { getSharedSecret, encryptPayload, decryptPayload } from './identity.js';
export function getAllMessages(network) {
const joinedTopics = new Set(network.servers.map(s => s.topicHex));
return Array.from(network.messages.values()).filter(m => {
const ch = m.payload.channel;
if (!m.recipient && ch) {
if (ch.length > 64 && ch[64] === '-') {
const topicHex = ch.substring(0, 64);
const chName = ch.substring(65);
if (!joinedTopics.has(topicHex)) return false;
const server = network.servers.find(s => s.topicHex === topicHex);
if (server && network.myKey !== server.owner && network.myKey !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[network.myKey] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) return false;
}
const hasReadPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('read_messages');
});
if (!hasReadPerm && server.roles && server.roles.length > 0) return false;
}
}
} else if (ch.length === 64) {
if (!joinedTopics.has(ch)) return false;
}
}
return true;
}).map(m => {
const known = network.knownProfiles.get(m.sender);
const isInvite = m.payload.type === 'server_invite';
const isFile = m.payload.type === 'file';
// Deep clone reactions to ensure React detects the state change and re-renders
const rawReactions = network.reactions.get(m.payload.id) || {};
const clonedReactions = {};
for (const [emoji, users] of Object.entries(rawReactions)) {
clonedReactions[emoji] = [...users];
}
return {
id: m.payload.id,
channel: m.recipient ? m.recipient : m.payload.channel,
recipient: m.recipient,
text: isInvite ? null : m.payload.text,
payload: isInvite || isFile ? m.payload : null,
localPath: m.localPath,
localBlobUrl: m.localBlobUrl,
isMediaInDB: m.isMediaInDB,
timestamp: m.payload.timestamp,
logicalTime: m.payload.logicalTime || 0,
edited: m.payload.edited || false,
replyTo: m.payload.replyTo || null,
reactions: clonedReactions,
sender: m.sender,
senderName: known ? known.displayName : 'Unknown',
senderAvatar: known ? known.avatar : null,
isEncrypted: !!m.cipher,
cipher: m.cipher || null,
nonce: m.nonce || null
};
}).sort((a, b) => {
if (a.logicalTime !== b.logicalTime) return a.logicalTime - b.logicalTime;
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
return a.id.localeCompare(b.id);
});
}
export async function processMessage(network, msg) {
if (!msg || !msg.sender || !msg.signature) return;
// FIX: Prevent double-processing of messages caused by Hypercore's download+append event race condition
if (network.processedSigs.has(msg.signature)) return;
network.processedSigs.add(msg.signature);
const applyReaction = (targetId, emoji, sender) => {
if (!network.reactions.has(targetId)) network.reactions.set(targetId, {});
const msgReactions = network.reactions.get(targetId);
if (!msgReactions[emoji]) msgReactions[emoji] = [];
const idx = msgReactions[emoji].indexOf(sender);
if (idx > -1) {
msgReactions[emoji].splice(idx, 1);
if (msgReactions[emoji].length === 0) delete msgReactions[emoji];
} else {
msgReactions[emoji].push(sender);
}
network._emitMessages();
};
if (msg.recipient) {
if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return;
const targetKey = msg.sender === network.myKey ? msg.recipient : msg.sender;
const sharedSecret = getSharedSecret(network, targetKey);
const sigPayload = msg.nonce + msg.cipher + msg.recipient;
const isValid = sodium.crypto_sign_verify_detached(
b4a.from(msg.signature, 'hex'),
b4a.from(sigPayload),
b4a.from(msg.sender, 'hex')
);
if (!isValid) return;
const decrypted = decryptPayload(msg.nonce, msg.cipher, sharedSecret);
if (!decrypted) return;
msg.payload = decrypted;
if (decrypted.logicalTime) {
network.logicalClock = Math.max(network.logicalClock, decrypted.logicalTime) + 1;
}
if (decrypted.type === 'server_invite') {
if (!network.messages.has(decrypted.id)) {
network.messages.set(decrypted.id, msg);
network._emitMessages();
}
return;
}
if (decrypted.type === 'group_chat_add') {
const { topicHex, name, icon, owner, channels } = decrypted;
network.joinServer(topicHex, name, icon, owner, true, true, channels);
return;
}
if (decrypted.type === 'reaction') {
applyReaction(decrypted.targetId, decrypted.emoji, msg.sender);
return;
}
if (msg.payload.type === 'dm_request' && msg.sender !== network.myKey) {
if (!network.dms[msg.sender]) {
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile, isOpen: true };
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
network.knownProfiles.set(msg.sender, msg.payload.profile);
network._emitKnownProfiles();
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
} else if (network.dms[msg.sender].status === 'pending_outgoing') {
// Mutual request! Auto-accept.
network.dms[msg.sender].status = 'accepted';
network.dms[msg.sender].isOpen = true;
if (msg.payload.profile) {
network.dms[msg.sender].profile = { ...network.dms[msg.sender].profile, ...msg.payload.profile };
network.knownProfiles.set(msg.sender, network.dms[msg.sender].profile);
network._emitKnownProfiles();
}
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
// Send an accept back just in case
acceptDMRequest(network, msg.sender);
}
} else if (msg.payload.type === 'dm_accept' && msg.sender !== network.myKey) {
if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') {
network.dms[msg.sender].status = 'accepted';
network.dms[msg.sender].isOpen = true;
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
} else if (msg.payload.type === 'dm_chat' || msg.payload.type === 'file') {
if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') {
network.dms[msg.sender].status = 'accepted';
network.dms[msg.sender].isOpen = true;
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
if (!network.deletedMessages.has(msg.payload.id) && !network.messages.has(msg.payload.id)) {
network.messages.set(msg.payload.id, msg);
network._emitMessages();
// Re-open DM if it was closed
if (network.dms[msg.sender] && !network.dms[msg.sender].isOpen && network.dms[msg.sender].status !== 'blocked') {
network.dms[msg.sender].isOpen = true;
network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
if (msg.payload.type === 'file') {
network._downloadFile(msg.payload.id, msg.payload.file, msg.sender === network.myKey);
}
}
}
return;
}
if (!msg.signature || !msg.payloadStr) return;
try {
const sigBuf = b4a.from(msg.signature, 'hex');
const pubBuf = b4a.from(msg.sender, 'hex');
const isValid = sodium.crypto_sign_verify_detached(sigBuf, b4a.from(msg.payloadStr), pubBuf);
if (!isValid) return;
msg.payload = JSON.parse(msg.payloadStr);
if (msg.payload.logicalTime) {
network.logicalClock = Math.max(network.logicalClock, msg.payload.logicalTime) + 1;
}
} catch (err) { return; }
const { type, id, targetId, channel, text, serverTopicHex, allowAnyoneToInvite, name, icon, channels, roles, memberRoles, emoji } = msg.payload;
if (type === 'server_delete') {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server && msg.sender === server.owner) {
await network._wipeLocalServerData(serverTopicHex);
}
return;
}
if (type === 'server_leave') {
const targetUser = msg.payload.targetUser || msg.sender;
if (targetUser !== msg.sender) {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
let canKick = false;
if (msg.sender === server.owner || msg.sender === ADMIN_PUBLIC_KEY) canKick = true;
else {
const userRoles = server.memberRoles?.[msg.sender] || [];
canKick = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && (r.permissions.includes('admin') || r.permissions.includes('kick_members'));
});
}
if (!canKick) return;
}
}
if (network.serverMembers[serverTopicHex]) {
network.serverMembers[serverTopicHex].delete(targetUser);
network._emitServerMembers();
}
if (targetUser === network.myKey) {
network._wipeLocalServerData(serverTopicHex);
}
return;
}
if (type === 'server_join') {
if (!network.serverMembers[serverTopicHex]) network.serverMembers[serverTopicHex] = new Set();
network.serverMembers[serverTopicHex].add(msg.sender);
// Auto-assign default Members role
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
const membersRole = server.roles?.find(r => r.id === 'role_members');
if (membersRole) {
if (!server.memberRoles) server.memberRoles = {};
if (!server.memberRoles[msg.sender]) server.memberRoles[msg.sender] = [];
if (!server.memberRoles[msg.sender].includes(membersRole.id)) {
server.memberRoles[msg.sender].push(membersRole.id);
network.serverDb.put(serverTopicHex, server);
}
}
}
network._emitServerMembers();
return;
}
if (type === 'server_settings_update') {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
let canUpdateSettings = false;
let canManageChannels = false;
let canManageRoles = false;
if (msg.sender === server.owner || msg.sender === ADMIN_PUBLIC_KEY) {
canUpdateSettings = true;
canManageChannels = true;
canManageRoles = true;
} else {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (isServerAdmin) {
canUpdateSettings = true;
canManageChannels = true;
canManageRoles = true;
} else {
canManageChannels = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_channels');
});
canManageRoles = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('manage_roles');
});
}
}
if (canUpdateSettings || canManageChannels || canManageRoles) {
if (canUpdateSettings && allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
if (canUpdateSettings && name !== undefined) server.name = name;
if (canUpdateSettings && icon !== undefined) server.icon = icon;
if (canManageChannels && channels !== undefined) server.channels = channels;
if (canManageRoles && roles !== undefined) server.roles = roles;
if (canManageRoles && memberRoles !== undefined) server.memberRoles = memberRoles;
network.serverDb.put(serverTopicHex, server);
network._emitServers();
}
}
return;
}
if (type === 'reaction') {
let canReact = true;
const targetMsg = network.messages.get(targetId);
if (targetMsg && targetMsg.payload.channel && targetMsg.payload.channel.length > 64 && targetMsg.payload.channel[64] === '-') {
const topicHex = targetMsg.payload.channel.substring(0, 64);
const chName = targetMsg.payload.channel.substring(65);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server && msg.sender !== server.owner && msg.sender !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) canReact = false;
}
if (canReact) {
const hasReactPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('add_reactions');
});
if (!hasReactPerm && server.roles && server.roles.length > 0) canReact = false;
}
}
}
}
if (canReact) {
applyReaction(targetId, emoji, msg.sender);
}
return;
}
if (type === 'delete') {
const targetMsg = network.messages.get(targetId);
if (!targetMsg) return;
let canDelete = false;
if (msg.sender === ADMIN_PUBLIC_KEY || msg.sender === targetMsg.sender) {
canDelete = true;
} else if (targetMsg.payload.channel) {
const topicHex = targetMsg.payload.channel.substring(0, 64);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server) {
if (server.owner === msg.sender) canDelete = true;
const userRoles = server.memberRoles?.[msg.sender] || [];
const hasAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (hasAdmin) canDelete = true;
}
}
if (canDelete) {
network.deletedMessages.add(targetId);
network.messages.delete(targetId);
network.reactions.delete(targetId);
network._emitMessages();
}
return;
}
if (type === 'edit') {
const original = network.messages.get(targetId);
if (original && original.sender === msg.sender) {
original.payload.text = text;
original.payload.edited = true;
network._emitMessages();
}
return;
}
if (type === 'chat' || type === 'file') {
let canAccept = false;
if (channel && channel.length > 64 && channel[64] === '-') {
const topicHex = channel.substring(0, 64);
const chName = channel.substring(65);
const server = network.servers.find(s => s.topicHex === topicHex);
if (server) {
canAccept = true;
if (msg.sender !== server.owner && msg.sender !== ADMIN_PUBLIC_KEY) {
const userRoles = server.memberRoles?.[msg.sender] || [];
const isServerAdmin = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('admin');
});
if (!isServerAdmin) {
const channelPerms = server.channels?.permissions?.[chName];
if (channelPerms && channelPerms.length > 0) {
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
if (!hasChannelAccess) canAccept = false;
}
if (canAccept) {
const channelSendPerms = server.channels?.send_permissions?.[chName];
if (channelSendPerms && channelSendPerms.length > 0) {
const hasChannelSendAccess = userRoles.some(rId => channelSendPerms.includes(rId));
if (!hasChannelSendAccess) canAccept = false;
}
}
if (canAccept) {
const hasSendPerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('send_messages');
});
if (!hasSendPerm && server.roles && server.roles.length > 0) canAccept = false;
if (type === 'file') {
const hasFilePerm = userRoles.some(rId => {
const r = server.roles?.find(role => role.id === rId);
return r && r.permissions.includes('send_files');
});
if (!hasFilePerm && server.roles && server.roles.length > 0) canAccept = false;
}
}
}
}
}
} else if (channel && channel.length === 64) {
const gc = network.servers.find(s => s.topicHex === channel && s.isGroupChat);
if (gc) {
canAccept = true;
}
}
if (canAccept && !network.deletedMessages.has(id) && !network.messages.has(id)) {
network.messages.set(id, msg);
network._emitMessages();
if (type === 'file') {
network._downloadFile(id, msg.payload.file, msg.sender === network.myKey);
}
}
}
}
export async function _appendSignedMessage(network, payloadObj) {
if (!network.localCore) return;
network.logicalClock++;
payloadObj.logicalTime = network.logicalClock;
payloadObj.timestamp = Date.now() + network.timeOffset;
payloadObj.senderName = network.displayName;
const payloadStr = JSON.stringify(payloadObj);
const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, b4a.from(payloadStr), network.secretKey);
const finalMessage = {
sender: network.myKey,
senderName: network.displayName,
signature: b4a.toString(sigBuf, 'hex'),
payloadStr: payloadStr
};
await network.localCore.append(finalMessage);
processMessage(network, finalMessage);
}
export async function _appendEncryptedMessage(network, targetKey, payloadObj) {
if (!network.localCore) return;
network.logicalClock++;
payloadObj.logicalTime = network.logicalClock;
payloadObj.timestamp = Date.now() + network.timeOffset;
const sharedSecret = getSharedSecret(network, targetKey);
const { nonce, cipher } = encryptPayload(payloadObj, sharedSecret);
const sigPayload = nonce + cipher + targetKey;
const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, b4a.from(sigPayload), network.secretKey);
const finalMessage = {
sender: network.myKey, recipient: targetKey, nonce, cipher, signature: b4a.toString(sigBuf, 'hex')
};
await network.localCore.append(finalMessage);
processMessage(network, finalMessage);
}
export async function sendDMRequest(network, targetKey, profile) {
network.dms[targetKey] = { status: 'pending_outgoing', profile, isOpen: true };
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
await _appendEncryptedMessage(network, targetKey, { type: 'dm_request', profile: { displayName: network.displayName, username: network.username, avatar: network.avatar, bio: network.bio, connections: network.connections } });
network._broadcastIdentity();
}
export async function acceptDMRequest(network, targetKey) {
if (network.dms[targetKey]) {
network.dms[targetKey].status = 'accepted';
network.dms[targetKey].isOpen = true;
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
await _appendEncryptedMessage(network, targetKey, { type: 'dm_accept' });
network._broadcastIdentity();
}
export async function sendMessage(network, channel, text, replyTo = null) {
await _appendSignedMessage(network, { type: 'chat', id: generateUUID(), channel, text, replyTo });
}
export async function sendDM(network, targetKey, text, replyTo = null) {
await _appendEncryptedMessage(network, targetKey, { type: 'dm_chat', id: generateUUID(), text, replyTo });
}
export async function sendEditMessage(network, targetId, newText) {
await _appendSignedMessage(network, { type: 'edit', id: generateUUID(), targetId, text: newText });
}
export async function sendDeleteMessage(network, targetId) {
await _appendSignedMessage(network, { type: 'delete', id: generateUUID(), targetId });
}
export async function sendReaction(network, targetId, emoji, isDM = false, targetKey = null) {
if (isDM && targetKey) {
await _appendEncryptedMessage(network, targetKey, { type: 'reaction', id: generateUUID(), targetId, emoji });
} else {
await _appendSignedMessage(network, { type: 'reaction', id: generateUUID(), targetId, emoji });
}
}
export function sendEphemeral(network, payload) {
if (!network.swarm) return;
const msg = { type: 'ephemeral', payload };
for (const { send } of network.peers.values()) {
if (send) send(msg);
}
}
export function sendOffline(network) { sendEphemeral(network, { type: 'offline' }); }
export function sendTyping(network, channel) { sendEphemeral(network, { type: 'typing', channel, displayName: network.displayName }); }
export function sendReadReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'read', channel, messageId, timestamp: Date.now() }); }
export function sendDeliveredReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'delivered', channel, messageId, timestamp: Date.now() }); }