diff --git a/backend/.env.example b/backend/.env.example index dc7f974a..deb5f51b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,6 +18,9 @@ STORAGE_CONFIG_SECRET = # Optional separate key for encrypting TURN, LiveKit SFU, FCM and APNs private keys at rest. # Falls back to STORAGE_CONFIG_SECRET, then JWT_SECRET. Keep this value stable. CALL_CONFIG_SECRET = +# Optional separate key for DB-backed chat translation/transcription provider secrets. +# Falls back to CALL_CONFIG_SECRET, STORAGE_CONFIG_SECRET, then JWT_SECRET. Keep stable. +CHAT_AI_CONFIG_SECRET = APP_ORIGIN = http://localhost:3000,http://127.0.0.1:3000 PUBLIC_ORIGIN = http://localhost:3000 API_BASE_URL = http://127.0.0.1:5599/api @@ -36,6 +39,16 @@ SERVE_FRONTEND = false # FCM/APNs private keys are encrypted in MongoDB using CALL_CONFIG_SECRET. # Production calling also requires REDIS_URL for shared durable active-call state. +# Chat abuse/flood controls. Redis is used when REDIS_URL is configured; +# otherwise a single-process in-memory fallback is used. +CHAT_RATE_LIMIT_MESSAGES = 30 +CHAT_RATE_LIMIT_WINDOW_SEC = 10 +CHAT_DUPLICATE_LIMIT = 6 +CHAT_DUPLICATE_WINDOW_SEC = 30 + +# Translation/transcription provider URLs and API keys are configured from the +# DB-backed Admin Chat AI panel. Provider keys are encrypted using the secret chain above. + # Email transport is stored in the database from Admin Settings > App Config. GOOGLE_CLIENT_ID = diff --git a/backend/package.json b/backend/package.json index 62831f45..98ca89bc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "predev": "node scripts/free-ports.js 8080", "dev": "nodemon --watch server --watch .env --ignore logs --ignore uploads server", - "build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/events/calling.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js", + "build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/chat.js && node --check server/db/models/chatRoomCounter.js && node --check server/db/models/messageReceipt.js && node --check server/db/models/chatDraft.js && node --check server/db/models/messageRequest.js && node --check server/db/models/chatTopic.js && node --check server/db/models/e2eeDeviceKey.js && node --check server/db/models/resumableUpload.js && node --check server/db/models/chatAiConfig.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/helpers/chatAbuse.js && node --check server/helpers/chatMentions.js && node --check server/helpers/messageRequests.js && node --check server/helpers/chatReliability.js && node --check server/helpers/chatAiConfig.js && node --check server/helpers/e2eeKeyDirectory.js && node --check server/helpers/chatMaintenance.js && node --check server/middleware/chatSendIdempotency.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/chatV2.js && node --check server/controllers/chatAiAdmin.js && node --check server/controllers/chatSuggestions.js && node --check server/controllers/chatResumableUpload.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/chatV2.js && node --check server/routes/chatAiAdmin.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/auth.js && node --check server/socket/index.js && node --check server/socket/events/chatV2.js && node --check server/socket/events/calling.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js", "start": "node server" }, "dependencies": { diff --git a/backend/server/controllers/chatAiAdmin.js b/backend/server/controllers/chatAiAdmin.js new file mode 100644 index 00000000..107447b0 --- /dev/null +++ b/backend/server/controllers/chatAiAdmin.js @@ -0,0 +1,22 @@ +const response = require('../helpers/response'); +const { + getAdminChatAiConfig, + updateChatAiConfig, +} = require('../helpers/chatAiConfig'); + +exports.getConfig = async (req, res) => { + try { + response({ res, payload: await getAdminChatAiConfig() }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.updateConfig = async (req, res) => { + try { + const payload = await updateChatAiConfig(req.body || {}); + response({ res, message: 'Chat AI configuration updated', payload }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; diff --git a/backend/server/controllers/chatResumableUpload.js b/backend/server/controllers/chatResumableUpload.js new file mode 100644 index 00000000..a271df56 --- /dev/null +++ b/backend/server/controllers/chatResumableUpload.js @@ -0,0 +1,166 @@ +const path = require('path'); +const mongoose = require('mongoose'); +const ResumableUploadModel = require('../db/models/resumableUpload'); +const { toPlain } = require('../db/utils'); +const response = require('../helpers/response'); +const { saveBufferFile } = require('../helpers/storage'); + +const safeFileName = (value = 'file.bin') => { + const base = path + .basename(String(value || 'file.bin')) + .replace(/[^a-zA-Z0-9._ -]/g, '_') + .trim(); + return base.slice(0, 180) || 'file.bin'; +}; + +const fileTypeFromMime = (mime = '', filename = '') => { + const normalized = String(mime || '').toLowerCase(); + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('video/')) return 'video'; + if (normalized.startsWith('audio/')) return 'audio'; + const ext = path.extname(String(filename || '')).slice(1).toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'].includes(ext)) return 'image'; + if (['mp4', 'mov', 'mkv', 'webm', 'm4v'].includes(ext)) return 'video'; + if (['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'].includes(ext)) return 'audio'; + return 'document'; +}; + +exports.complete = async (req, res) => { + try { + const uploadId = req.params.uploadId; + const row = await ResumableUploadModel.findOne({ + where: { uploadId, userId: req.user._id }, + }); + if (!row) { + response({ + res, + statusCode: 404, + success: false, + message: 'Upload session not found', + }); + return; + } + + if (row.status === 'complete' && row.result?.url) { + response({ + res, + message: 'Upload already complete', + payload: row.result, + }); + return; + } + + if (row.status !== 'uploading') { + response({ + res, + statusCode: 409, + success: false, + message: `Upload cannot be completed from ${row.status} state`, + }); + return; + } + + const collection = mongoose.connection.db.collection( + 'resumable_upload_chunks' + ); + const chunks = await collection + .find({ uploadId, userId: req.user._id }) + .sort({ partNumber: 1 }) + .toArray(); + const expectedParts = Math.ceil( + Number(row.totalSize) / Number(row.chunkSize) + ); + + if ( + chunks.length !== expectedParts || + chunks.some((chunk, index) => chunk.partNumber !== index) + ) { + response({ + res, + statusCode: 409, + success: false, + message: 'Upload is incomplete', + payload: { + expectedParts, + receivedParts: chunks.map((item) => item.partNumber), + }, + }); + return; + } + + const buffer = Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk.data.buffer || chunk.data)) + ); + if (buffer.length !== Number(row.totalSize)) { + response({ + res, + statusCode: 409, + success: false, + message: 'Uploaded byte count does not match expected size', + }); + return; + } + + const stored = await saveBufferFile({ + buffer, + folder: `chat/${req.user._id}`, + filename: `${Date.now()}-${safeFileName(row.filename)}`, + }); + const ext = path.extname(row.filename).slice(1).toLowerCase() || 'bin'; + const payload = { + url: stored.url, + originalname: row.filename, + type: fileTypeFromMime(row.mime, row.filename), + format: ext.slice(0, 24), + size: Number(row.totalSize), + duration: 0, + thumbnailUrl: '', + streamUrl: '', + streamHdUrl: '', + width: 0, + height: 0, + }; + + // The final chat send endpoint creates the canonical FileModel row. Keeping + // the resumable finalizer storage-only prevents an orphan duplicate DB row. + await row.update({ + status: 'complete', + uploadedBytes: row.totalSize, + result: payload, + }); + await collection.deleteMany({ uploadId, userId: req.user._id }); + + response({ + res, + message: 'Upload complete', + payload, + }); + } catch (error0) { + response({ + res, + statusCode: error0.statusCode || 500, + success: false, + message: error0.message, + }); + } +}; + +exports.status = async (req, res) => { + try { + const row = await ResumableUploadModel.findOne({ + where: { uploadId: req.params.uploadId, userId: req.user._id }, + }); + if (!row) { + response({ + res, + statusCode: 404, + success: false, + message: 'Upload session not found', + }); + return; + } + response({ res, payload: toPlain(row) }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; diff --git a/backend/server/controllers/chatSuggestions.js b/backend/server/controllers/chatSuggestions.js new file mode 100644 index 00000000..f211587c --- /dev/null +++ b/backend/server/controllers/chatSuggestions.js @@ -0,0 +1,62 @@ +const { Op } = require('sequelize'); +const InboxModel = require('../db/models/inbox'); +const ProfileModel = require('../db/models/profile'); +const { asArray, toPlainMany } = require('../db/utils'); +const response = require('../helpers/response'); + +exports.mentionSuggestions = async (req, res) => { + try { + const roomId = String(req.params.roomId || '').trim(); + const userId = req.user?._id; + const q = String(req.query.q || '').trim().toLowerCase().slice(0, 32); + + const inbox = await InboxModel.findOne({ where: { roomId } }); + if (!inbox || !asArray(inbox.ownersId).includes(userId)) { + response({ + res, + statusCode: 403, + success: false, + message: 'Forbidden', + }); + return; + } + + const participantIds = asArray(inbox.ownersId).filter(Boolean).slice(0, 5000); + if (!participantIds.length) { + response({ res, payload: [] }); + return; + } + + const rows = await ProfileModel.findAll({ + where: { userId: { [Op.in]: participantIds } }, + attributes: ['userId', 'username', 'fullname', 'avatar'], + limit: 5000, + }); + + const payload = toPlainMany(rows) + .filter((profile) => profile.userId !== userId) + .filter((profile) => { + if (!q) return true; + return ( + String(profile.username || '').toLowerCase().includes(q) || + String(profile.fullname || '').toLowerCase().includes(q) + ); + }) + .filter((profile) => !!String(profile.username || '').trim()) + .sort((left, right) => + String(left.fullname || left.username || '').localeCompare( + String(right.fullname || right.username || '') + ) + ) + .slice(0, 12); + + response({ res, payload }); + } catch (error0) { + response({ + res, + statusCode: error0.statusCode || 500, + success: false, + message: error0.message, + }); + } +}; diff --git a/backend/server/controllers/chatV2.js b/backend/server/controllers/chatV2.js new file mode 100644 index 00000000..a9d3e434 --- /dev/null +++ b/backend/server/controllers/chatV2.js @@ -0,0 +1,759 @@ +const crypto = require('crypto'); +const path = require('path'); +const mongoose = require('mongoose'); +const axios = require('axios'); +const { Op } = require('sequelize'); + +const ChatModel = require('../db/models/chat'); +const InboxModel = require('../db/models/inbox'); +const FileModel = require('../db/models/file'); +const ProfileModel = require('../db/models/profile'); +const GroupModel = require('../db/models/group'); +const ChannelModel = require('../db/models/channel'); +const SettingModel = require('../db/models/setting'); +const MessageReceiptModel = require('../db/models/messageReceipt'); +const ChatDraftModel = require('../db/models/chatDraft'); +const MessageRequestModel = require('../db/models/messageRequest'); +const ChatTopicModel = require('../db/models/chatTopic'); +const E2eeDeviceKeyModel = require('../db/models/e2eeDeviceKey'); +const ResumableUploadModel = require('../db/models/resumableUpload'); + +const response = require('../helpers/response'); +const { + asArray, + addToSet, + pullFromArray, + toPlain, + toPlainMany, +} = require('../db/utils'); +const { clearPendingFor } = require('../helpers/messageRequests'); +const { loadAppConfig } = require('../helpers/appConfig'); +const { saveBufferFile, readStorageFileToBuffer } = require('../helpers/storage'); +const { getRuntimeChatAiConfig } = require('../helpers/chatAiConfig'); + +const unique = (values) => [...new Set(asArray(values).filter(Boolean))]; + +const ensureRoomAccess = async ({ roomId, userId }) => { + const inbox = await InboxModel.findOne({ where: { roomId } }); + if (!inbox) { + const error = new Error('Room not found'); + error.statusCode = 404; + throw error; + } + if (!asArray(inbox.ownersId).includes(userId)) { + const error = new Error('Forbidden'); + error.statusCode = 403; + throw error; + } + return inbox; +}; + +const getRoomAdminState = async ({ roomId, userId }) => { + const [channel, group] = await Promise.all([ + ChannelModel.findOne({ where: { roomId } }), + GroupModel.findOne({ where: { roomId } }), + ]); + const entity = toPlain(channel) || toPlain(group); + if (!entity) return { entity: null, isAdmin: false }; + const admins = unique([entity.adminId, ...asArray(entity.adminsId)]); + return { entity, isAdmin: admins.includes(userId) }; +}; + +const emitToOwners = (inbox, event, payload) => { + if (!global?.io || !inbox) return; + asArray(inbox.ownersId).forEach((ownerId) => global.io.to(ownerId).emit(event, payload)); + if (inbox.roomId) global.io.to(inbox.roomId).emit(event, payload); +}; + +const fileTypeFromMime = (mime = '', filename = '') => { + const normalized = String(mime || '').toLowerCase(); + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('video/')) return 'video'; + if (normalized.startsWith('audio/')) return 'audio'; + const ext = path.extname(String(filename || '')).slice(1).toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'].includes(ext)) return 'image'; + if (['mp4', 'mov', 'mkv', 'webm', 'm4v'].includes(ext)) return 'video'; + if (['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'webm'].includes(ext)) return 'audio'; + return 'document'; +}; + +const safeFileName = (value = 'file.bin') => { + const base = path.basename(String(value || 'file.bin')).replace(/[^a-zA-Z0-9._ -]/g, '_').trim(); + return base.slice(0, 180) || 'file.bin'; +}; + +exports.getMessageReceipts = async (req, res) => { + try { + const chat = await ChatModel.findOne({ where: { _id: req.params.chatId } }); + if (!chat) throw Object.assign(new Error('Message not found'), { statusCode: 404 }); + await ensureRoomAccess({ roomId: chat.roomId, userId: req.user._id }); + + const rows = await MessageReceiptModel.findAll({ + where: { chatId: chat._id }, + order: [['readAt', 'DESC'], ['deliveredAt', 'DESC']], + }); + const receipts = toPlainMany(rows); + const userIds = unique(receipts.map((item) => item.userId)); + const profiles = userIds.length + ? toPlainMany(await ProfileModel.findAll({ + where: { userId: { [Op.in]: userIds } }, + attributes: ['userId', 'fullname', 'username', 'avatar'], + })) + : []; + const profileMap = new Map(profiles.map((item) => [item.userId, item])); + + response({ + res, + payload: receipts.map((item) => ({ ...item, profile: profileMap.get(item.userId) || null })), + }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.listDrafts = async (req, res) => { + try { + const rows = await ChatDraftModel.findAll({ + where: { userId: req.user._id }, + order: [['updatedAt', 'DESC']], + limit: 200, + }); + response({ res, payload: toPlainMany(rows) }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.getDraft = async (req, res) => { + try { + await ensureRoomAccess({ roomId: req.params.roomId, userId: req.user._id }); + const row = await ChatDraftModel.findOne({ + where: { userId: req.user._id, roomId: req.params.roomId }, + }); + response({ res, payload: toPlain(row) || null }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.saveDraft = async (req, res) => { + try { + const roomId = String(req.params.roomId || ''); + await ensureRoomAccess({ roomId, userId: req.user._id }); + const patch = { + userId: req.user._id, + roomId, + text: String(req.body?.text || '').slice(0, 20000), + replyTo: req.body?.replyTo || null, + topicId: req.body?.topicId || null, + meta: req.body?.meta && typeof req.body.meta === 'object' ? req.body.meta : {}, + }; + let row = await ChatDraftModel.findOne({ where: { userId: req.user._id, roomId } }); + if (row) await row.update(patch); + else row = await ChatDraftModel.create(patch); + response({ res, message: 'Draft saved', payload: toPlain(row) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.deleteDraft = async (req, res) => { + try { + await ChatDraftModel.destroy({ where: { userId: req.user._id, roomId: req.params.roomId } }); + response({ res, message: 'Draft cleared' }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.listMentions = async (req, res) => { + try { + const inboxes = toPlainMany(await InboxModel.findAll()).filter( + (inbox) => asArray(inbox.ownersId).includes(req.user._id) && !asArray(inbox.deletedBy).includes(req.user._id) + ); + const roomIds = inboxes.map((item) => item.roomId); + if (!roomIds.length) return response({ res, payload: [] }); + + const rows = await ChatModel.findAll({ + where: { + roomId: { [Op.in]: roomIds }, + mentionUserIds: { [Op.contains]: [req.user._id] }, + }, + order: [['createdAt', 'DESC']], + limit: Math.min(200, Math.max(1, Number(req.query.limit || 50))), + }); + response({ res, payload: toPlainMany(rows).filter((chat) => !asArray(chat.deletedBy).includes(req.user._id)) }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.searchMessages = async (req, res) => { + try { + const q = String(req.query.q || '').trim(); + const requestedRoomId = String(req.query.roomId || '').trim(); + const senderId = String(req.query.senderId || '').trim(); + const type = String(req.query.type || 'all').trim().toLowerCase(); + const topicId = String(req.query.topicId || '').trim(); + const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50))); + + const inboxes = toPlainMany(await InboxModel.findAll()).filter( + (inbox) => + asArray(inbox.ownersId).includes(req.user._id) && + !asArray(inbox.deletedBy).includes(req.user._id) && + (!requestedRoomId || inbox.roomId === requestedRoomId) + ); + const roomIds = inboxes.map((item) => item.roomId); + if (!roomIds.length) return response({ res, payload: [] }); + + const where = { roomId: { [Op.in]: roomIds } }; + if (q) where.text = { [Op.iLike]: `%${q}%` }; + if (senderId) where.userId = senderId; + if (topicId) where.topicId = topicId; + if (req.query.from || req.query.to) { + where.createdAt = {}; + if (req.query.from) where.createdAt[Op.gte] = new Date(req.query.from); + if (req.query.to) where.createdAt[Op.lte] = new Date(req.query.to); + } + + let rows = toPlainMany(await ChatModel.findAll({ + where, + order: [['createdAt', 'DESC']], + limit: Math.min(1000, limit * 8), + })).filter((chat) => !asArray(chat.deletedBy).includes(req.user._id)); + + const fileIds = unique(rows.map((chat) => chat.fileId)); + const files = fileIds.length + ? toPlainMany(await FileModel.findAll({ where: { fileId: { [Op.in]: fileIds } } })) + : []; + const fileMap = new Map(files.map((file) => [file.fileId, file])); + + if (type !== 'all') { + rows = rows.filter((chat) => { + const file = chat.fileId ? fileMap.get(chat.fileId) : null; + if (['image', 'video', 'audio', 'document'].includes(type)) return file?.type === type; + if (type === 'link') return /https?:\/\/\S+/i.test(chat.text || ''); + if (type === 'call') return /\b(call|missed|declined|rejected)\b/i.test(chat.text || ''); + if (type === 'poll') return String(chat.text || '').startsWith('__poll__::'); + if (type === 'text') return !file && !!String(chat.text || '').trim(); + return true; + }); + } + + rows = rows.slice(0, limit); + const senderIds = unique(rows.map((chat) => chat.userId)); + const profiles = senderIds.length + ? toPlainMany(await ProfileModel.findAll({ + where: { userId: { [Op.in]: senderIds } }, + attributes: ['userId', 'fullname', 'username', 'avatar'], + })) + : []; + const profileMap = new Map(profiles.map((profile) => [profile.userId, profile])); + const inboxMap = new Map(inboxes.map((inbox) => [inbox.roomId, inbox])); + + response({ + res, + payload: rows.map((chat) => ({ + ...chat, + profile: profileMap.get(chat.userId) || null, + file: chat.fileId ? fileMap.get(chat.fileId) || null : null, + room: inboxMap.get(chat.roomId) || null, + })), + }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.getEditHistory = async (req, res) => { + try { + const chat = await ChatModel.findOne({ where: { _id: req.params.chatId } }); + if (!chat) throw Object.assign(new Error('Message not found'), { statusCode: 404 }); + await ensureRoomAccess({ roomId: chat.roomId, userId: req.user._id }); + response({ + res, + payload: { + chatId: chat._id, + currentText: chat.e2eeEnvelope ? 'Encrypted message' : chat.text, + isEdited: !!chat.isEdited, + editedAt: chat.editedAt || null, + history: asArray(chat.editHistory), + }, + }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.listMessageRequests = async (req, res) => { + try { + const rows = toPlainMany(await MessageRequestModel.findAll({ + where: { recipientId: req.user._id, status: 'pending' }, + order: [['lastMessageAt', 'DESC']], + limit: 200, + })); + const requesterIds = unique(rows.map((item) => item.requesterId)); + const profiles = requesterIds.length + ? toPlainMany(await ProfileModel.findAll({ + where: { userId: { [Op.in]: requesterIds } }, + attributes: ['userId', 'fullname', 'username', 'avatar', 'bio'], + })) + : []; + const profileMap = new Map(profiles.map((profile) => [profile.userId, profile])); + response({ + res, + payload: rows.map((item) => ({ ...item, profile: profileMap.get(item.requesterId) || null })), + }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.actionMessageRequest = async (req, res) => { + try { + const action = String(req.body?.action || '').toLowerCase(); + if (!['accept', 'decline', 'block'].includes(action)) { + return response({ res, statusCode: 400, success: false, message: 'Unsupported request action' }); + } + const row = await MessageRequestModel.findOne({ + where: { _id: req.params.requestId, recipientId: req.user._id }, + }); + if (!row) throw Object.assign(new Error('Message request not found'), { statusCode: 404 }); + + const status = action === 'accept' ? 'accepted' : action === 'block' ? 'blocked' : 'declined'; + await row.update({ status, actionAt: new Date() }); + const inbox = await clearPendingFor({ roomId: row.roomId, userId: req.user._id }); + + if (action !== 'accept' && inbox) { + await inbox.update({ deletedBy: addToSet(inbox.deletedBy, [req.user._id]) }); + const chats = await ChatModel.findAll({ where: { roomId: row.roomId } }); + await Promise.all(chats.map((chat) => chat.update({ deletedBy: addToSet(chat.deletedBy, [req.user._id]) }))); + } + + if (action === 'block') { + const setting = await SettingModel.findOne({ where: { userId: req.user._id } }); + if (setting) await setting.update({ blockedUserIds: addToSet(setting.blockedUserIds, [row.requesterId]) }); + } + + if (global?.io) { + global.io.to(req.user._id).emit('message-request/updated', { + requestId: row._id, + roomId: row.roomId, + status, + }); + global.io.to(row.requesterId).emit('message-request/updated', { + requestId: row._id, + roomId: row.roomId, + status, + }); + if (action === 'accept') global.io.to(req.user._id).emit('inbox/refresh', { roomId: row.roomId }); + else global.io.to(req.user._id).emit('inbox/delete', [row.roomId]); + } + + response({ res, message: `Message request ${status}`, payload: toPlain(row) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.listTopics = async (req, res) => { + try { + await ensureRoomAccess({ roomId: req.params.roomId, userId: req.user._id }); + const rows = await ChatTopicModel.findAll({ + where: { roomId: req.params.roomId }, + order: [['pinned', 'DESC'], ['updatedAt', 'DESC']], + }); + response({ res, payload: toPlainMany(rows) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.createTopic = async (req, res) => { + try { + const roomId = req.params.roomId; + const inbox = await ensureRoomAccess({ roomId, userId: req.user._id }); + if (inbox.roomType !== 'group') { + return response({ res, statusCode: 400, success: false, message: 'Topics are only available in groups and channels' }); + } + const { isAdmin } = await getRoomAdminState({ roomId, userId: req.user._id }); + if (!isAdmin) return response({ res, statusCode: 403, success: false, message: 'Only an admin can create topics' }); + const name = String(req.body?.name || '').trim(); + if (name.length < 2 || name.length > 120) { + return response({ res, statusCode: 400, success: false, message: 'Topic name must be 2-120 characters' }); + } + const topic = await ChatTopicModel.create({ + roomId, + name, + icon: String(req.body?.icon || 'topic').slice(0, 32), + createdBy: req.user._id, + pinned: !!req.body?.pinned, + closed: false, + participantIds: [], + }); + emitToOwners(inbox, 'chat/topic', { action: 'created', topic: toPlain(topic) }); + response({ res, message: 'Topic created', payload: toPlain(topic) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.updateTopic = async (req, res) => { + try { + const topic = await ChatTopicModel.findOne({ where: { _id: req.params.topicId } }); + if (!topic) throw Object.assign(new Error('Topic not found'), { statusCode: 404 }); + const inbox = await ensureRoomAccess({ roomId: topic.roomId, userId: req.user._id }); + const { isAdmin } = await getRoomAdminState({ roomId: topic.roomId, userId: req.user._id }); + if (!isAdmin && topic.createdBy !== req.user._id) { + return response({ res, statusCode: 403, success: false, message: 'Only topic creator or admin can update this topic' }); + } + const patch = {}; + if (req.body?.name !== undefined) patch.name = String(req.body.name || '').trim().slice(0, 120); + if (req.body?.icon !== undefined) patch.icon = String(req.body.icon || 'topic').slice(0, 32); + if (req.body?.closed !== undefined) patch.closed = !!req.body.closed; + if (req.body?.pinned !== undefined) patch.pinned = !!req.body.pinned; + await topic.update(patch); + emitToOwners(inbox, 'chat/topic', { action: 'updated', topic: toPlain(topic) }); + response({ res, message: 'Topic updated', payload: toPlain(topic) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.deleteTopic = async (req, res) => { + try { + const topic = await ChatTopicModel.findOne({ where: { _id: req.params.topicId } }); + if (!topic) return response({ res, message: 'Topic deleted' }); + const inbox = await ensureRoomAccess({ roomId: topic.roomId, userId: req.user._id }); + const { isAdmin } = await getRoomAdminState({ roomId: topic.roomId, userId: req.user._id }); + if (!isAdmin && topic.createdBy !== req.user._id) { + return response({ res, statusCode: 403, success: false, message: 'Only topic creator or admin can delete this topic' }); + } + await ChatModel.update({ topicId: null }, { where: { topicId: topic._id } }); + await topic.destroy(); + emitToOwners(inbox, 'chat/topic', { action: 'deleted', topicId: req.params.topicId, roomId: topic.roomId }); + response({ res, message: 'Topic deleted' }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.registerE2eeKey = async (req, res) => { + try { + const sessionId = req.session?._id || req.sessionId || req.token?.sid; + if (!sessionId) return response({ res, statusCode: 409, success: false, message: 'A device session is required for E2EE' }); + const publicJwk = req.body?.publicJwk; + const fingerprint = String(req.body?.fingerprint || '').trim().slice(0, 128); + if (!publicJwk || typeof publicJwk !== 'object' || !fingerprint) { + return response({ res, statusCode: 400, success: false, message: 'publicJwk and fingerprint are required' }); + } + let row = await E2eeDeviceKeyModel.findOne({ where: { userId: req.user._id, sessionId } }); + const changed = !!row && row.fingerprint !== fingerprint; + const patch = { + userId: req.user._id, + sessionId, + publicJwk, + fingerprint, + algorithm: 'ECDH-P256', + active: true, + revokedAt: null, + }; + if (row) await row.update(patch); + else row = await E2eeDeviceKeyModel.create(patch); + if (changed && global?.io) global.io.to(req.user._id).emit('e2ee/key-changed', { sessionId, fingerprint }); + response({ res, message: 'E2EE device key registered', payload: { sessionId, fingerprint, algorithm: 'ECDH-P256' } }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.listE2eeKeys = async (req, res) => { + try { + const ids = unique(String(req.query.userIds || '').split(',').map((item) => item.trim())).slice(0, 100); + if (!ids.length) return response({ res, payload: [] }); + const rows = await E2eeDeviceKeyModel.findAll({ + where: { userId: { [Op.in]: ids }, active: true }, + attributes: ['userId', 'sessionId', 'publicJwk', 'fingerprint', 'algorithm', 'updatedAt'], + }); + response({ res, payload: toPlainMany(rows) }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.setRoomE2ee = async (req, res) => { + try { + const roomId = req.params.roomId; + const inbox = await ensureRoomAccess({ roomId, userId: req.user._id }); + if (inbox.roomType !== 'private') { + return response({ res, statusCode: 400, success: false, message: 'Device E2EE is currently available for private chats only' }); + } + const enabled = !!req.body?.enabled; + if (enabled) { + const owners = asArray(inbox.ownersId); + const keys = toPlainMany(await E2eeDeviceKeyModel.findAll({ + where: { userId: { [Op.in]: owners }, active: true }, + })); + const keyed = new Set(keys.map((item) => item.userId)); + const missingUserIds = owners.filter((id) => !keyed.has(id)); + if (missingUserIds.length) { + return response({ + res, + statusCode: 409, + success: false, + message: 'Every participant needs at least one registered E2EE device key', + payload: { missingUserIds }, + }); + } + } + await inbox.update({ + e2eeEnabled: enabled, + e2eeEnabledBy: enabled ? req.user._id : null, + e2eeVersion: enabled ? 1 : 0, + }); + emitToOwners(inbox, 'e2ee/room', { + roomId, + enabled, + enabledBy: enabled ? req.user._id : null, + version: enabled ? 1 : 0, + }); + response({ res, message: enabled ? 'E2EE enabled' : 'E2EE disabled', payload: { roomId, enabled, version: enabled ? 1 : 0 } }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.getRoomE2ee = async (req, res) => { + try { + const inbox = await ensureRoomAccess({ roomId: req.params.roomId, userId: req.user._id }); + response({ + res, + payload: { + roomId: inbox.roomId, + enabled: !!inbox.e2eeEnabled, + enabledBy: inbox.e2eeEnabledBy || null, + version: Number(inbox.e2eeVersion || 0), + }, + }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.initResumableUpload = async (req, res) => { + try { + const appConfig = await loadAppConfig(); + const filename = safeFileName(req.body?.filename); + const mime = String(req.body?.mime || 'application/octet-stream').slice(0, 120); + const totalSize = Number(req.body?.totalSize || 0); + const maxBytes = Math.max(1, Number(appConfig?.uploadLimits?.chatMb || 100)) * 1024 * 1024; + if (!Number.isFinite(totalSize) || totalSize <= 0 || totalSize > maxBytes) { + return response({ res, statusCode: 413, success: false, message: `Upload must be between 1 byte and ${appConfig?.uploadLimits?.chatMb || 100} MB` }); + } + const chunkSize = Math.min(4 * 1024 * 1024, Math.max(256 * 1024, Number(req.body?.chunkSize || 1024 * 1024))); + const uploadId = crypto.randomUUID(); + const row = await ResumableUploadModel.create({ + uploadId, + userId: req.user._id, + filename, + mime, + totalSize, + chunkSize, + uploadedBytes: 0, + receivedParts: [], + status: 'uploading', + result: {}, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + response({ res, message: 'Resumable upload created', payload: toPlain(row) }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.putResumableChunk = async (req, res) => { + try { + const uploadId = req.params.uploadId; + const partNumber = Number(req.params.partNumber); + if (!Number.isInteger(partNumber) || partNumber < 0) { + return response({ res, statusCode: 400, success: false, message: 'Invalid part number' }); + } + const row = await ResumableUploadModel.findOne({ where: { uploadId, userId: req.user._id } }); + if (!row || row.status !== 'uploading') throw Object.assign(new Error('Upload session not found'), { statusCode: 404 }); + if (new Date(row.expiresAt).getTime() <= Date.now()) { + await row.update({ status: 'expired' }); + return response({ res, statusCode: 410, success: false, message: 'Upload session expired' }); + } + const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); + if (!buffer.length || buffer.length > Number(row.chunkSize || 0) + 1024) { + return response({ res, statusCode: 400, success: false, message: 'Invalid chunk size' }); + } + const collection = mongoose.connection.db.collection('resumable_upload_chunks'); + const existing = await collection.findOne({ uploadId, userId: req.user._id, partNumber }); + await collection.replaceOne( + { uploadId, userId: req.user._id, partNumber }, + { uploadId, userId: req.user._id, partNumber, data: buffer, size: buffer.length, updatedAt: new Date() }, + { upsert: true } + ); + const receivedParts = unique([...asArray(row.receivedParts).map(Number), partNumber]).sort((a, b) => a - b); + const uploadedBytes = Math.min( + Number(row.totalSize), + Math.max(0, Number(row.uploadedBytes || 0) - Number(existing?.size || 0) + buffer.length) + ); + await row.update({ receivedParts, uploadedBytes }); + response({ res, message: 'Chunk stored', payload: { uploadId, partNumber, uploadedBytes, totalSize: Number(row.totalSize), receivedParts } }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.getResumableUpload = async (req, res) => { + try { + const row = await ResumableUploadModel.findOne({ where: { uploadId: req.params.uploadId, userId: req.user._id } }); + if (!row) throw Object.assign(new Error('Upload session not found'), { statusCode: 404 }); + response({ res, payload: toPlain(row) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.completeResumableUpload = async (req, res) => { + try { + const uploadId = req.params.uploadId; + const row = await ResumableUploadModel.findOne({ where: { uploadId, userId: req.user._id } }); + if (!row || row.status !== 'uploading') throw Object.assign(new Error('Upload session not found'), { statusCode: 404 }); + const collection = mongoose.connection.db.collection('resumable_upload_chunks'); + const chunks = await collection.find({ uploadId, userId: req.user._id }).sort({ partNumber: 1 }).toArray(); + const expectedParts = Math.ceil(Number(row.totalSize) / Number(row.chunkSize)); + if (chunks.length !== expectedParts || chunks.some((chunk, index) => chunk.partNumber !== index)) { + return response({ res, statusCode: 409, success: false, message: 'Upload is incomplete', payload: { expectedParts, receivedParts: chunks.map((item) => item.partNumber) } }); + } + const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.data.buffer || chunk.data))); + if (buffer.length !== Number(row.totalSize)) { + return response({ res, statusCode: 409, success: false, message: 'Uploaded byte count does not match expected size' }); + } + const stored = await saveBufferFile({ + buffer, + folder: `chat/${req.user._id}`, + filename: `${Date.now()}-${safeFileName(row.filename)}`, + }); + const ext = path.extname(row.filename).slice(1).toLowerCase() || 'bin'; + const file = await FileModel.create({ + fileId: crypto.randomUUID(), + originalname: row.filename, + url: stored.url, + type: fileTypeFromMime(row.mime, row.filename), + format: ext.slice(0, 24), + size: String(row.totalSize), + duration: 0, + thumbnailUrl: '', + streamUrl: '', + streamHdUrl: '', + width: 0, + height: 0, + }); + await row.update({ status: 'complete', uploadedBytes: row.totalSize, result: toPlain(file) }); + await collection.deleteMany({ uploadId, userId: req.user._id }); + response({ res, message: 'Upload complete', payload: toPlain(file) }); + } catch (error0) { + response({ res, statusCode: error0.statusCode || 500, success: false, message: error0.message }); + } +}; + +exports.cancelResumableUpload = async (req, res) => { + try { + const uploadId = req.params.uploadId; + const row = await ResumableUploadModel.findOne({ where: { uploadId, userId: req.user._id } }); + if (row && row.status === 'uploading') await row.update({ status: 'cancelled' }); + if (mongoose.connection.db) { + await mongoose.connection.db.collection('resumable_upload_chunks').deleteMany({ uploadId, userId: req.user._id }); + } + response({ res, message: 'Upload cancelled' }); + } catch (error0) { + response({ res, statusCode: 500, success: false, message: error0.message }); + } +}; + +exports.translateMessage = async (req, res) => { + try { + const chatId = req.body?.chatId || null; + let sourceText = String(req.body?.text || '').trim(); + let chat = null; + if (chatId) { + chat = await ChatModel.findOne({ where: { _id: chatId } }); + if (!chat) throw Object.assign(new Error('Message not found'), { statusCode: 404 }); + await ensureRoomAccess({ roomId: chat.roomId, userId: req.user._id }); + if (chat.e2eeEnvelope) { + return response({ res, statusCode: 400, success: false, message: 'Server translation is disabled for E2EE messages; use an on-device translator' }); + } + sourceText = sourceText || String(chat.text || ''); + } + if (!sourceText) return response({ res, statusCode: 400, success: false, message: 'Text is required' }); + const config = await getRuntimeChatAiConfig(); + if (!config.translationEnabled || !config.translationUrl) { + return response({ res, statusCode: 503, success: false, message: 'Translation provider is not configured' }); + } + const targetLanguage = String(req.body?.targetLanguage || config.defaultTargetLanguage || 'en').slice(0, 16); + const headers = { 'content-type': 'application/json' }; + if (config.translationApiKey) { + headers.authorization = `Bearer ${config.translationApiKey}`; + headers['x-api-key'] = config.translationApiKey; + } + const provider = await axios.post( + config.translationUrl, + { q: sourceText, text: sourceText, source: 'auto', target: targetLanguage, format: 'text' }, + { headers, timeout: 30000 } + ); + const translatedText = String( + provider.data?.translatedText || provider.data?.translation || provider.data?.text || provider.data?.output || '' + ).trim(); + if (!translatedText) throw new Error('Translation provider returned no translated text'); + if (chat) { + await chat.update({ translations: { ...(chat.translations || {}), [targetLanguage]: translatedText } }); + } + response({ res, payload: { translatedText, targetLanguage } }); + } catch (error0) { + response({ res, statusCode: error0.response?.status || error0.statusCode || 500, success: false, message: error0.response?.data?.message || error0.message }); + } +}; + +exports.transcribeVoice = async (req, res) => { + try { + const chat = await ChatModel.findOne({ where: { _id: req.body?.chatId } }); + if (!chat) throw Object.assign(new Error('Message not found'), { statusCode: 404 }); + await ensureRoomAccess({ roomId: chat.roomId, userId: req.user._id }); + if (chat.e2eeEnvelope) { + return response({ res, statusCode: 400, success: false, message: 'Server transcription is disabled for E2EE messages' }); + } + const file = chat.fileId ? await FileModel.findOne({ where: { fileId: chat.fileId } }) : null; + if (!file || file.type !== 'audio') { + return response({ res, statusCode: 400, success: false, message: 'Message does not contain an audio file' }); + } + const config = await getRuntimeChatAiConfig(); + if (!config.transcriptionEnabled || !config.transcriptionUrl) { + return response({ res, statusCode: 503, success: false, message: 'Transcription provider is not configured' }); + } + const buffer = await readStorageFileToBuffer(file.url); + const headers = { 'content-type': 'application/json' }; + if (config.transcriptionApiKey) { + headers.authorization = `Bearer ${config.transcriptionApiKey}`; + headers['x-api-key'] = config.transcriptionApiKey; + } + const provider = await axios.post( + config.transcriptionUrl, + { + audioBase64: buffer.toString('base64'), + mime: `audio/${file.format || 'webm'}`, + filename: file.originalname, + language: req.body?.language || 'auto', + }, + { headers, timeout: 120000, maxBodyLength: Infinity } + ); + const transcript = String(provider.data?.transcript || provider.data?.text || provider.data?.output || '').trim(); + if (!transcript) throw new Error('Transcription provider returned no text'); + await chat.update({ transcript: transcript.slice(0, 16000) }); + response({ res, payload: { chatId: chat._id, transcript } }); + } catch (error0) { + response({ res, statusCode: error0.response?.status || error0.statusCode || 500, success: false, message: error0.response?.data?.message || error0.message }); + } +}; diff --git a/backend/server/db/models/chat.js b/backend/server/db/models/chat.js index e7a30cad..4ca1648e 100644 --- a/backend/server/db/models/chat.js +++ b/backend/server/db/models/chat.js @@ -107,10 +107,51 @@ const ChatModel = sequelize.define( allowNull: false, defaultValue: [], }, + clientMessageId: { + type: DataTypes.STRING(96), + allowNull: true, + defaultValue: null, + }, + sequence: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + mentionUserIds: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: [], + }, + topicId: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + e2eeEnvelope: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: null, + }, + transcript: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: '', + }, + translations: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: {}, + }, }, { timestamps: true, version: false, + indexes: [ + { fields: ['roomId', 'sequence'] }, + { fields: ['roomId', 'createdAt'] }, + { fields: ['userId', 'clientMessageId'], unique: true, sparse: true }, + { fields: ['topicId', 'createdAt'] }, + ], } ); diff --git a/backend/server/db/models/chatAiConfig.js b/backend/server/db/models/chatAiConfig.js new file mode 100644 index 00000000..c72f6f0b --- /dev/null +++ b/backend/server/db/models/chatAiConfig.js @@ -0,0 +1,20 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const ChatAiConfigModel = sequelize.define( + 'chat_ai_configs', + { + _id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + key: { type: DataTypes.STRING(32), allowNull: false, defaultValue: 'default', unique: 'chat_ai_config_key_unique' }, + translationEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + translationUrl: { type: DataTypes.STRING(512), allowNull: false, defaultValue: '' }, + translationApiKey: { type: DataTypes.TEXT, allowNull: false, defaultValue: '' }, + transcriptionEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + transcriptionUrl: { type: DataTypes.STRING(512), allowNull: false, defaultValue: '' }, + transcriptionApiKey: { type: DataTypes.TEXT, allowNull: false, defaultValue: '' }, + defaultTargetLanguage: { type: DataTypes.STRING(16), allowNull: false, defaultValue: 'en' }, + }, + { timestamps: true, version: false } +); + +module.exports = ChatAiConfigModel; diff --git a/backend/server/db/models/chatDraft.js b/backend/server/db/models/chatDraft.js new file mode 100644 index 00000000..8a1f4644 --- /dev/null +++ b/backend/server/db/models/chatDraft.js @@ -0,0 +1,51 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const ChatDraftModel = sequelize.define( + 'chat_drafts', + { + _id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + roomId: { + type: DataTypes.STRING(64), + allowNull: false, + }, + text: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: '', + }, + replyTo: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + topicId: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + meta: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: {}, + }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['userId', 'roomId'], unique: true }, + { fields: ['userId', 'updatedAt'] }, + ], + } +); + +module.exports = ChatDraftModel; diff --git a/backend/server/db/models/chatRoomCounter.js b/backend/server/db/models/chatRoomCounter.js new file mode 100644 index 00000000..298b383b --- /dev/null +++ b/backend/server/db/models/chatRoomCounter.js @@ -0,0 +1,25 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const ChatRoomCounterModel = sequelize.define( + 'chat_room_counters', + { + roomId: { + type: DataTypes.STRING(64), + allowNull: false, + primaryKey: true, + unique: true, + }, + sequence: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + }, + { + timestamps: false, + version: false, + } +); + +module.exports = ChatRoomCounterModel; diff --git a/backend/server/db/models/chatTopic.js b/backend/server/db/models/chatTopic.js new file mode 100644 index 00000000..c589219f --- /dev/null +++ b/backend/server/db/models/chatTopic.js @@ -0,0 +1,26 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const ChatTopicModel = sequelize.define( + 'chat_topics', + { + _id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + roomId: { type: DataTypes.STRING(64), allowNull: false }, + name: { type: DataTypes.STRING(120), allowNull: false }, + icon: { type: DataTypes.STRING(32), allowNull: false, defaultValue: 'topic' }, + createdBy: { type: DataTypes.UUID, allowNull: false }, + closed: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + pinned: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + participantIds: { type: DataTypes.JSON, allowNull: false, defaultValue: [] }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['roomId', 'createdAt'] }, + { fields: ['roomId', 'pinned'] }, + ], + } +); + +module.exports = ChatTopicModel; diff --git a/backend/server/db/models/e2eeDeviceKey.js b/backend/server/db/models/e2eeDeviceKey.js new file mode 100644 index 00000000..f795c3ac --- /dev/null +++ b/backend/server/db/models/e2eeDeviceKey.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const E2eeDeviceKeyModel = sequelize.define( + 'e2ee_device_keys', + { + _id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + userId: { type: DataTypes.UUID, allowNull: false }, + sessionId: { type: DataTypes.UUID, allowNull: false }, + publicJwk: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + fingerprint: { type: DataTypes.STRING(128), allowNull: false }, + algorithm: { type: DataTypes.STRING(32), allowNull: false, defaultValue: 'ECDH-P256' }, + active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + revokedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['userId', 'sessionId'], unique: true }, + { fields: ['userId', 'active'] }, + { fields: ['fingerprint'] }, + ], + } +); + +module.exports = E2eeDeviceKeyModel; diff --git a/backend/server/db/models/inbox.js b/backend/server/db/models/inbox.js index 287ae58e..c7706ce6 100644 --- a/backend/server/db/models/inbox.js +++ b/backend/server/db/models/inbox.js @@ -79,6 +79,11 @@ const InboxModel = sequelize.define( allowNull: false, defaultValue: [], }, + requestPendingFor: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: [], + }, secretChatEnabled: { type: DataTypes.BOOLEAN, allowNull: false, @@ -119,6 +124,21 @@ const InboxModel = sequelize.define( allowNull: true, defaultValue: null, }, + e2eeEnabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + e2eeEnabledBy: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + e2eeVersion: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, chatLockBy: { type: DataTypes.JSON, allowNull: false, diff --git a/backend/server/db/models/messageReceipt.js b/backend/server/db/models/messageReceipt.js new file mode 100644 index 00000000..8111b3d4 --- /dev/null +++ b/backend/server/db/models/messageReceipt.js @@ -0,0 +1,51 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const MessageReceiptModel = sequelize.define( + 'message_receipts', + { + _id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + chatId: { + type: DataTypes.UUID, + allowNull: false, + }, + roomId: { + type: DataTypes.STRING(64), + allowNull: false, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + sessionId: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + deliveredAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + readAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['chatId', 'userId', 'sessionId'], unique: true, sparse: true }, + { fields: ['roomId', 'userId'] }, + { fields: ['chatId'] }, + ], + } +); + +module.exports = MessageReceiptModel; diff --git a/backend/server/db/models/messageRequest.js b/backend/server/db/models/messageRequest.js new file mode 100644 index 00000000..32730587 --- /dev/null +++ b/backend/server/db/models/messageRequest.js @@ -0,0 +1,56 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const MessageRequestModel = sequelize.define( + 'message_requests', + { + _id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + requesterId: { + type: DataTypes.UUID, + allowNull: false, + }, + recipientId: { + type: DataTypes.UUID, + allowNull: false, + }, + roomId: { + type: DataTypes.STRING(64), + allowNull: false, + }, + status: { + type: DataTypes.ENUM('pending', 'accepted', 'declined', 'blocked'), + allowNull: false, + defaultValue: 'pending', + }, + preview: { + type: DataTypes.STRING(320), + allowNull: false, + defaultValue: '', + }, + lastMessageAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + actionAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['recipientId', 'roomId'], unique: true }, + { fields: ['recipientId', 'status', 'lastMessageAt'] }, + { fields: ['requesterId', 'recipientId'] }, + ], + } +); + +module.exports = MessageRequestModel; diff --git a/backend/server/db/models/resumableUpload.js b/backend/server/db/models/resumableUpload.js new file mode 100644 index 00000000..24461883 --- /dev/null +++ b/backend/server/db/models/resumableUpload.js @@ -0,0 +1,34 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../sequelize'); + +const ResumableUploadModel = sequelize.define( + 'resumable_uploads', + { + _id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + uploadId: { type: DataTypes.UUID, allowNull: false, unique: 'resumable_upload_id_unique' }, + userId: { type: DataTypes.UUID, allowNull: false }, + filename: { type: DataTypes.STRING(255), allowNull: false }, + mime: { type: DataTypes.STRING(120), allowNull: false, defaultValue: 'application/octet-stream' }, + totalSize: { type: DataTypes.BIGINT, allowNull: false }, + chunkSize: { type: DataTypes.INTEGER, allowNull: false }, + uploadedBytes: { type: DataTypes.BIGINT, allowNull: false, defaultValue: 0 }, + receivedParts: { type: DataTypes.JSON, allowNull: false, defaultValue: [] }, + status: { + type: DataTypes.ENUM('uploading', 'complete', 'cancelled', 'expired'), + allowNull: false, + defaultValue: 'uploading', + }, + result: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + expiresAt: { type: DataTypes.DATE, allowNull: false }, + }, + { + timestamps: true, + version: false, + indexes: [ + { fields: ['userId', 'status', 'createdAt'] }, + { fields: ['expiresAt'] }, + ], + } +); + +module.exports = ResumableUploadModel; diff --git a/backend/server/helpers/chatAbuse.js b/backend/server/helpers/chatAbuse.js new file mode 100644 index 00000000..e0f581a4 --- /dev/null +++ b/backend/server/helpers/chatAbuse.js @@ -0,0 +1,111 @@ +const crypto = require('crypto'); +const { createClient } = require('redis'); +const logger = require('./logger'); + +const localWindows = new Map(); +let redisPromise = null; + +const MAX_MESSAGES = Math.max(5, Number(process.env.CHAT_RATE_LIMIT_MESSAGES || 30)); +const WINDOW_SECONDS = Math.max(5, Number(process.env.CHAT_RATE_LIMIT_WINDOW_SEC || 10)); +const MAX_DUPLICATES = Math.max(2, Number(process.env.CHAT_DUPLICATE_LIMIT || 6)); +const DUPLICATE_WINDOW_SECONDS = Math.max(10, Number(process.env.CHAT_DUPLICATE_WINDOW_SEC || 30)); + +const getRedis = async () => { + const url = String(process.env.REDIS_URL || '').trim(); + if (!url) return null; + if (!redisPromise) { + redisPromise = (async () => { + const client = createClient({ url }); + client.on('error', (error) => { + logger.warn('CHAT_ABUSE_REDIS_ERROR', { message: error.message }); + }); + await client.connect(); + return client; + })().catch((error) => { + redisPromise = null; + logger.warn('CHAT_ABUSE_REDIS_CONNECT_FAILED', { message: error.message }); + return null; + }); + } + return redisPromise; +}; + +const createRateError = (message, code) => { + const error = new Error(message); + error.statusCode = 429; + error.code = code; + return error; +}; + +const normalizeBody = (text = '') => String(text || '').trim().replace(/\s+/g, ' ').toLowerCase(); +const bodyHash = (text) => crypto.createHash('sha256').update(normalizeBody(text)).digest('hex').slice(0, 24); + +const assertLocal = ({ userId, text }) => { + const now = Date.now(); + const key = String(userId); + const state = localWindows.get(key) || { messages: [], duplicates: new Map() }; + state.messages = state.messages.filter((at) => now - at < WINDOW_SECONDS * 1000); + if (state.messages.length >= MAX_MESSAGES) { + throw createRateError('You are sending messages too quickly. Please wait a moment.', 'CHAT_RATE_LIMIT'); + } + state.messages.push(now); + + const normalized = normalizeBody(text); + if (normalized.length >= 4) { + const hash = bodyHash(normalized); + const previous = (state.duplicates.get(hash) || []).filter( + (at) => now - at < DUPLICATE_WINDOW_SECONDS * 1000 + ); + if (previous.length >= MAX_DUPLICATES) { + throw createRateError('Repeated message flood detected.', 'CHAT_DUPLICATE_FLOOD'); + } + previous.push(now); + state.duplicates.set(hash, previous); + } + + localWindows.set(key, state); +}; + +const assertRedis = async ({ redis, userId, text }) => { + const base = `syncchat:chat:rate:${userId}`; + const count = await redis.incr(base); + if (count === 1) await redis.expire(base, WINDOW_SECONDS); + if (count > MAX_MESSAGES) { + throw createRateError('You are sending messages too quickly. Please wait a moment.', 'CHAT_RATE_LIMIT'); + } + + const normalized = normalizeBody(text); + if (normalized.length >= 4) { + const dupKey = `syncchat:chat:dup:${userId}:${bodyHash(normalized)}`; + const duplicateCount = await redis.incr(dupKey); + if (duplicateCount === 1) await redis.expire(dupKey, DUPLICATE_WINDOW_SECONDS); + if (duplicateCount > MAX_DUPLICATES) { + throw createRateError('Repeated message flood detected.', 'CHAT_DUPLICATE_FLOOD'); + } + } +}; + +const assertChatSendAllowed = async ({ userId, text = '' }) => { + if (!userId) throw createRateError('Authenticated user is required.', 'CHAT_AUTH_REQUIRED'); + + const urls = String(text || '').match(/https?:\/\/\S+/gi) || []; + if (urls.length > 12) { + throw createRateError('Too many links in one message.', 'CHAT_LINK_FLOOD'); + } + + const mentions = String(text || '').match(/@[a-z0-9_]{2,32}/gi) || []; + if (mentions.length > 50) { + throw createRateError('Too many mentions in one message.', 'CHAT_MENTION_FLOOD'); + } + + const redis = await getRedis(); + if (redis) { + await assertRedis({ redis, userId, text }); + return; + } + assertLocal({ userId, text }); +}; + +module.exports = { + assertChatSendAllowed, +}; diff --git a/backend/server/helpers/chatAiConfig.js b/backend/server/helpers/chatAiConfig.js new file mode 100644 index 00000000..de77c611 --- /dev/null +++ b/backend/server/helpers/chatAiConfig.js @@ -0,0 +1,115 @@ +const crypto = require('crypto'); +const ChatAiConfigModel = require('../db/models/chatAiConfig'); + +const ENC_PREFIX = 'enc:v1:'; + +const getSecret = () => { + const raw = String( + process.env.CHAT_AI_CONFIG_SECRET || + process.env.CALL_CONFIG_SECRET || + process.env.STORAGE_CONFIG_SECRET || + process.env.JWT_SECRET || + '' + ).trim(); + if (!raw) throw new Error('CHAT_AI_CONFIG_SECRET or another stable application secret is required'); + return crypto.createHash('sha256').update(raw).digest(); +}; + +const encryptSecret = (value = '') => { + const text = String(value || ''); + if (!text) return ''; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', getSecret(), iv); + const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${ENC_PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${body.toString('base64')}`; +}; + +const decryptSecret = (value = '') => { + const raw = String(value || ''); + if (!raw) return ''; + if (!raw.startsWith(ENC_PREFIX)) return raw; + const parts = raw.slice(ENC_PREFIX.length).split(':'); + if (parts.length !== 3) return ''; + const [ivText, tagText, bodyText] = parts; + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + getSecret(), + Buffer.from(ivText, 'base64') + ); + decipher.setAuthTag(Buffer.from(tagText, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(bodyText, 'base64')), + decipher.final(), + ]).toString('utf8'); +}; + +const defaults = () => ({ + key: 'default', + translationEnabled: false, + translationUrl: '', + translationApiKey: '', + transcriptionEnabled: false, + transcriptionUrl: '', + transcriptionApiKey: '', + defaultTargetLanguage: 'en', +}); + +const getConfigRow = async () => { + let row = await ChatAiConfigModel.findOne({ where: { key: 'default' } }); + if (!row) row = await ChatAiConfigModel.create(defaults()); + return row; +}; + +const getRuntimeChatAiConfig = async () => { + const row = await getConfigRow(); + return { + translationEnabled: !!row.translationEnabled, + translationUrl: row.translationUrl || '', + translationApiKey: decryptSecret(row.translationApiKey || ''), + transcriptionEnabled: !!row.transcriptionEnabled, + transcriptionUrl: row.transcriptionUrl || '', + transcriptionApiKey: decryptSecret(row.transcriptionApiKey || ''), + defaultTargetLanguage: row.defaultTargetLanguage || 'en', + }; +}; + +const getAdminChatAiConfig = async () => { + const row = await getConfigRow(); + return { + translationEnabled: !!row.translationEnabled, + translationUrl: row.translationUrl || '', + translationApiKeySet: !!row.translationApiKey, + transcriptionEnabled: !!row.transcriptionEnabled, + transcriptionUrl: row.transcriptionUrl || '', + transcriptionApiKeySet: !!row.transcriptionApiKey, + defaultTargetLanguage: row.defaultTargetLanguage || 'en', + }; +}; + +const updateChatAiConfig = async (input = {}) => { + const row = await getConfigRow(); + const patch = { + translationEnabled: !!input.translationEnabled, + translationUrl: String(input.translationUrl || '').trim().slice(0, 512), + transcriptionEnabled: !!input.transcriptionEnabled, + transcriptionUrl: String(input.transcriptionUrl || '').trim().slice(0, 512), + defaultTargetLanguage: String(input.defaultTargetLanguage || 'en').trim().slice(0, 16) || 'en', + }; + if (String(input.translationApiKey || '').trim()) { + patch.translationApiKey = encryptSecret(String(input.translationApiKey).trim()); + } + if (String(input.transcriptionApiKey || '').trim()) { + patch.transcriptionApiKey = encryptSecret(String(input.transcriptionApiKey).trim()); + } + if (input.clearTranslationApiKey === true) patch.translationApiKey = ''; + if (input.clearTranscriptionApiKey === true) patch.transcriptionApiKey = ''; + await row.update(patch); + return getAdminChatAiConfig(); +}; + +module.exports = { + getRuntimeChatAiConfig, + getAdminChatAiConfig, + updateChatAiConfig, +}; diff --git a/backend/server/helpers/chatCreateContext.js b/backend/server/helpers/chatCreateContext.js new file mode 100644 index 00000000..84577d9d --- /dev/null +++ b/backend/server/helpers/chatCreateContext.js @@ -0,0 +1,49 @@ +const { AsyncLocalStorage } = require('async_hooks'); +const ChatModel = require('../db/models/chat'); + +const storage = new AsyncLocalStorage(); +let installed = false; +let originalCreate = null; + +const installChatCreateContext = () => { + if (installed) return; + installed = true; + originalCreate = ChatModel.create.bind(ChatModel); + + ChatModel.create = async (values = {}) => { + const context = storage.getStore(); + const matches = !!( + context && + !context.consumed && + String(values.roomId || '') === String(context.roomId || '') && + String(values.userId || '') === String(context.userId || '') && + !values.isSecretSystemMessage + ); + + if (!matches) return originalCreate(values); + + context.consumed = true; + return originalCreate({ + ...values, + clientMessageId: values.clientMessageId || context.clientMessageId || null, + sequence: Number(values.sequence || 0) || Number(context.sequence || 0), + mentionUserIds: + Array.isArray(values.mentionUserIds) && values.mentionUserIds.length + ? values.mentionUserIds + : context.mentionUserIds || [], + topicId: values.topicId || context.topicId || null, + e2eeEnvelope: values.e2eeEnvelope || context.e2eeEnvelope || null, + transcript: values.transcript || context.transcript || '', + }); + }; +}; + +const runChatCreateContext = (context, handler) => { + installChatCreateContext(); + return storage.run({ ...(context || {}), consumed: false }, handler); +}; + +module.exports = { + installChatCreateContext, + runChatCreateContext, +}; diff --git a/backend/server/helpers/chatMaintenance.js b/backend/server/helpers/chatMaintenance.js new file mode 100644 index 00000000..4fb4df2c --- /dev/null +++ b/backend/server/helpers/chatMaintenance.js @@ -0,0 +1,100 @@ +const mongoose = require('mongoose'); +const { Op } = require('sequelize'); +const ResumableUploadModel = require('../db/models/resumableUpload'); +const ChatDraftModel = require('../db/models/chatDraft'); +const MessageReceiptModel = require('../db/models/messageReceipt'); +const { toPlainMany } = require('../db/utils'); +const { cleanupStaleE2eeKeys } = require('./e2eeKeyDirectory'); +const logger = require('./logger'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +const cleanupExpiredResumableUploads = async () => { + const now = new Date(); + const expiredRows = await ResumableUploadModel.findAll({ + where: { + status: 'uploading', + expiresAt: { [Op.lte]: now }, + }, + }); + + if (expiredRows.length) { + await Promise.all( + expiredRows.map((row) => row.update({ status: 'expired' })) + ); + } + + const terminalRows = await ResumableUploadModel.findAll({ + where: { + status: { [Op.in]: ['expired', 'cancelled', 'complete'] }, + updatedAt: { [Op.lte]: new Date(Date.now() - 7 * DAY_MS) }, + }, + }); + + const expiredIds = toPlainMany(expiredRows) + .map((item) => item.uploadId) + .filter(Boolean); + const terminalIds = toPlainMany(terminalRows) + .map((item) => item.uploadId) + .filter(Boolean); + const chunkCleanupIds = [...new Set([...expiredIds, ...terminalIds])]; + + if (mongoose.connection.db && chunkCleanupIds.length) { + await mongoose.connection.db + .collection('resumable_upload_chunks') + .deleteMany({ uploadId: { $in: chunkCleanupIds } }); + } + + if (terminalRows.length) { + await Promise.all(terminalRows.map((row) => row.destroy())); + } + + return { + expiredUploads: expiredRows.length, + removedUploadRows: terminalRows.length, + cleanedChunkSessions: chunkCleanupIds.length, + }; +}; + +const cleanupOldEphemeralChatMetadata = async () => { + const draftCutoff = new Date(Date.now() - 90 * DAY_MS); + const receiptCutoff = new Date(Date.now() - 180 * DAY_MS); + + const [draftsRemoved, receiptsRemoved] = await Promise.all([ + ChatDraftModel.destroy({ + where: { updatedAt: { [Op.lte]: draftCutoff } }, + }), + MessageReceiptModel.destroy({ + where: { updatedAt: { [Op.lte]: receiptCutoff } }, + }), + ]); + + return { + draftsRemoved: Number(draftsRemoved || 0), + receiptsRemoved: Number(receiptsRemoved || 0), + }; +}; + +const cleanupChatMaintenance = async () => { + const startedAt = Date.now(); + const result = { + uploads: await cleanupExpiredResumableUploads(), + metadata: await cleanupOldEphemeralChatMetadata(), + e2ee: await cleanupStaleE2eeKeys(), + }; + + logger.info('CHAT_MAINTENANCE_COMPLETE', { + tookMs: Date.now() - startedAt, + uploads: result.uploads, + metadata: result.metadata, + staleE2eeSessions: result.e2ee.staleSessionIds.length, + }); + + return result; +}; + +module.exports = { + cleanupExpiredResumableUploads, + cleanupOldEphemeralChatMetadata, + cleanupChatMaintenance, +}; diff --git a/backend/server/helpers/chatMentions.js b/backend/server/helpers/chatMentions.js new file mode 100644 index 00000000..11c4d05a --- /dev/null +++ b/backend/server/helpers/chatMentions.js @@ -0,0 +1,59 @@ +const { Op } = require('sequelize'); +const ProfileModel = require('../db/models/profile'); +const GroupModel = require('../db/models/group'); +const ChannelModel = require('../db/models/channel'); +const { asArray, toPlain, toPlainMany } = require('../db/utils'); + +const unique = (values) => [...new Set(asArray(values).filter(Boolean))]; + +const resolveMentions = async ({ text = '', roomId, roomType, senderId }) => { + const raw = String(text || ''); + const usernameTokens = unique( + [...raw.matchAll(/@([a-z0-9_]{2,32})/gi)].map((match) => match[1].toLowerCase()) + ); + const special = new Set(usernameTokens.filter((item) => ['all', 'admins'].includes(item))); + const usernames = usernameTokens.filter((item) => !special.has(item)); + + const profiles = usernames.length + ? await ProfileModel.findAll({ + where: { username: { [Op.in]: usernames } }, + attributes: ['userId', 'username', 'fullname'], + }) + : []; + + let mentionedUserIds = toPlainMany(profiles).map((profile) => profile.userId); + let allMention = false; + let adminsMention = false; + + if (roomType === 'group' && (special.has('all') || special.has('admins'))) { + const [channelDoc, groupDoc] = await Promise.all([ + ChannelModel.findOne({ where: { roomId } }), + GroupModel.findOne({ where: { roomId } }), + ]); + const room = toPlain(channelDoc) || toPlain(groupDoc) || {}; + const admins = unique([room.adminId, ...asArray(room.adminsId)]); + const participants = unique(room.participantsId); + const senderIsAdmin = admins.includes(senderId); + + if (special.has('admins')) { + adminsMention = true; + mentionedUserIds.push(...admins); + } + if (special.has('all') && senderIsAdmin) { + allMention = true; + mentionedUserIds.push(...participants); + } + } + + mentionedUserIds = unique(mentionedUserIds).filter((id) => id !== senderId); + return { + mentionedUserIds, + usernames, + allMention, + adminsMention, + }; +}; + +module.exports = { + resolveMentions, +}; diff --git a/backend/server/helpers/chatReliability.js b/backend/server/helpers/chatReliability.js new file mode 100644 index 00000000..85537d33 --- /dev/null +++ b/backend/server/helpers/chatReliability.js @@ -0,0 +1,203 @@ +const { v4: uuidv4 } = require('uuid'); +const ChatModel = require('../db/models/chat'); +const ChatRoomCounterModel = require('../db/models/chatRoomCounter'); +const InboxModel = require('../db/models/inbox'); +const { asArray, toPlain } = require('../db/utils'); +const { assertChatSendAllowed } = require('./chatAbuse'); +const { resolveMentions } = require('./chatMentions'); +const { upsertMessageRequest } = require('./messageRequests'); +const logger = require('./logger'); + +const nextSequence = async (roomId) => { + const model = ChatRoomCounterModel.mongoModel; + const row = await model.findOneAndUpdate( + { roomId }, + { $inc: { sequence: 1 }, $setOnInsert: { roomId } }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + return Number(row?.sequence || 0); +}; + +const findDuplicate = async ({ userId, clientMessageId }) => { + if (!userId || !clientMessageId) return null; + return ChatModel.findOne({ where: { userId, clientMessageId } }); +}; + +const findCreatedChat = async ({ roomId, userId, startedAt }) => { + const chat = await ChatModel.findOne({ + where: { roomId, userId }, + order: [['createdAt', 'DESC']], + }); + if (!chat) return null; + const createdAt = new Date(chat.createdAt || 0).getTime(); + if (!Number.isFinite(createdAt) || createdAt < startedAt - 1500) return null; + return chat; +}; + +const emitMeta = ({ socket, chat, meta }) => { + const payload = { + chatId: chat._id, + roomId: chat.roomId, + userId: chat.userId, + clientMessageId: meta.clientMessageId, + sequence: meta.sequence, + mentionUserIds: meta.mentionUserIds, + topicId: meta.topicId, + e2eeEnvelope: meta.e2eeEnvelope || null, + transcript: meta.transcript || '', + }; + socket.emit('chat/ack', { + accepted: true, + duplicate: false, + ...payload, + }); + if (global?.io) { + global.io.to(chat.roomId).emit('chat/meta', payload); + asArray(meta.ownerIds).forEach((ownerId) => { + global.io.to(ownerId).emit('chat/meta', payload); + }); + } +}; + +const wrapReliableChatInsert = (socket) => { + if (!socket || socket.authType !== 'user' || socket.__syncchatReliableInsert) return; + const listeners = socket.listeners('chat/insert'); + if (!listeners.length) return; + socket.__syncchatReliableInsert = true; + socket.removeAllListeners('chat/insert'); + + socket.on('chat/insert', async (rawArgs = {}) => { + const original = listeners[0]; + const startedAt = Date.now(); + const args = rawArgs && typeof rawArgs === 'object' ? rawArgs : {}; + args.userId = socket.userId; + args.clientMessageId = String(args.clientMessageId || uuidv4()).slice(0, 96); + + try { + const duplicate = await findDuplicate({ + userId: socket.userId, + clientMessageId: args.clientMessageId, + }); + if (duplicate) { + const plain = toPlain(duplicate); + socket.emit('chat/ack', { + accepted: true, + duplicate: true, + chatId: plain._id, + roomId: plain.roomId, + clientMessageId: plain.clientMessageId, + sequence: Number(plain.sequence || 0), + }); + return; + } + + await assertChatSendAllowed({ userId: socket.userId, text: args.text || '' }); + const sequence = await nextSequence(args.roomId); + const mentions = await resolveMentions({ + text: args.text || '', + roomId: args.roomId, + roomType: args.roomType, + senderId: socket.userId, + }); + + await original(args); + + const created = await findCreatedChat({ + roomId: args.roomId, + userId: socket.userId, + startedAt, + }); + if (!created) { + socket.emit('chat/ack', { + accepted: false, + clientMessageId: args.clientMessageId, + roomId: args.roomId, + reason: 'message-not-created', + }); + return; + } + + const inbox = await InboxModel.findOne({ where: { roomId: args.roomId } }); + const ownerIds = asArray(toPlain(inbox)?.ownersId || args.ownersId); + const topicId = args.topicId || null; + const e2eeEnvelope = args.e2eeEnvelope && typeof args.e2eeEnvelope === 'object' + ? args.e2eeEnvelope + : null; + const transcript = String(args.transcript || '').slice(0, 8000); + + await created.update({ + clientMessageId: args.clientMessageId, + sequence, + mentionUserIds: mentions.mentionedUserIds, + topicId, + e2eeEnvelope, + transcript, + }); + + if (args.roomType === 'private' && ownerIds.length === 2) { + const recipientId = ownerIds.find((id) => id !== socket.userId); + if (recipientId) { + const request = await upsertMessageRequest({ + senderId: socket.userId, + recipientId, + roomId: args.roomId, + preview: e2eeEnvelope ? 'Encrypted message' : args.text || '', + }); + if (request?.status === 'pending' && global?.io) { + global.io.to(recipientId).emit('message-request/new', request); + // Keep requests out of the normal inbox on every connected device. + global.io.to(recipientId).emit('inbox/delete', [args.roomId]); + } + } + } + + if (mentions.mentionedUserIds.length && global?.io) { + mentions.mentionedUserIds.forEach((userId) => { + global.io.to(userId).emit('chat/mention', { + chatId: created._id, + roomId: args.roomId, + fromUserId: socket.userId, + text: e2eeEnvelope ? 'Encrypted mention' : String(args.text || '').slice(0, 240), + allMention: mentions.allMention, + adminsMention: mentions.adminsMention, + }); + }); + } + + emitMeta({ + socket, + chat: toPlain(created), + meta: { + clientMessageId: args.clientMessageId, + sequence, + mentionUserIds: mentions.mentionedUserIds, + topicId, + e2eeEnvelope, + transcript, + ownerIds, + }, + }); + } catch (error0) { + logger.warn('CHAT_RELIABILITY_REJECTED', { + userId: socket.userId, + roomId: args.roomId || null, + code: error0.code || null, + message: error0.message, + }); + socket.emit('chat/ack', { + accepted: false, + clientMessageId: args.clientMessageId, + roomId: args.roomId || null, + code: error0.code || 'CHAT_SEND_FAILED', + message: error0.message, + }); + } + }); + + listeners.slice(1).forEach((listener) => socket.on('chat/insert', listener)); +}; + +module.exports = { + nextSequence, + wrapReliableChatInsert, +}; diff --git a/backend/server/helpers/e2eeKeyDirectory.js b/backend/server/helpers/e2eeKeyDirectory.js new file mode 100644 index 00000000..2e9aaa62 --- /dev/null +++ b/backend/server/helpers/e2eeKeyDirectory.js @@ -0,0 +1,90 @@ +const { Op } = require('sequelize'); +const E2eeDeviceKeyModel = require('../db/models/e2eeDeviceKey'); +const UserSessionModel = require('../db/models/userSession'); +const InboxModel = require('../db/models/inbox'); +const { asArray, toPlainMany } = require('../db/utils'); + +const unique = (values) => [...new Set(asArray(values).filter(Boolean).map(String))]; + +const cleanupStaleE2eeKeys = async ({ userIds = [] } = {}) => { + const normalizedUserIds = unique(userIds); + const where = { active: true }; + if (normalizedUserIds.length) where.userId = { [Op.in]: normalizedUserIds }; + + const keyRows = await E2eeDeviceKeyModel.findAll({ where }); + const keys = toPlainMany(keyRows); + if (!keys.length) return { activeKeys: [], staleSessionIds: [] }; + + const sessionIds = unique(keys.map((item) => item.sessionId)); + const sessions = sessionIds.length + ? toPlainMany( + await UserSessionModel.findAll({ + where: { _id: { [Op.in]: sessionIds } }, + attributes: ['_id', 'userId', 'revokedAt'], + }) + ) + : []; + + const activeSessionIds = new Set( + sessions + .filter((session) => !session.revokedAt) + .map((session) => String(session._id)) + ); + const staleKeys = keyRows.filter( + (row) => !activeSessionIds.has(String(row.sessionId || '')) + ); + + if (staleKeys.length) { + await Promise.all( + staleKeys.map((row) => + row.update({ + active: false, + revokedAt: row.revokedAt || new Date(), + }) + ) + ); + } + + const staleIds = new Set(staleKeys.map((row) => String(row.sessionId || ''))); + return { + activeKeys: keys.filter((item) => !staleIds.has(String(item.sessionId || ''))), + staleSessionIds: [...staleIds], + }; +}; + +const queryUserIds = (req) => + unique(String(req.query?.userIds || '').split(',').map((item) => item.trim())); + +const roomUserIds = async (req) => { + const roomId = String(req.params?.roomId || '').trim(); + if (!roomId) return []; + const inbox = await InboxModel.findOne({ + where: { roomId }, + attributes: ['ownersId'], + }); + return unique(inbox?.ownersId); +}; + +const cleanupQueryE2eeKeys = async (req, res, next) => { + try { + await cleanupStaleE2eeKeys({ userIds: queryUserIds(req) }); + next(); + } catch (error0) { + next(error0); + } +}; + +const cleanupRoomE2eeKeys = async (req, res, next) => { + try { + await cleanupStaleE2eeKeys({ userIds: await roomUserIds(req) }); + next(); + } catch (error0) { + next(error0); + } +}; + +module.exports = { + cleanupStaleE2eeKeys, + cleanupQueryE2eeKeys, + cleanupRoomE2eeKeys, +}; diff --git a/backend/server/helpers/messageRequests.js b/backend/server/helpers/messageRequests.js new file mode 100644 index 00000000..73846576 --- /dev/null +++ b/backend/server/helpers/messageRequests.js @@ -0,0 +1,60 @@ +const MessageRequestModel = require('../db/models/messageRequest'); +const InboxModel = require('../db/models/inbox'); +const { getSettingMap, getContactMap } = require('./privacy'); +const { asArray, addToSet, toPlain } = require('../db/utils'); + +const shouldStageMessageRequest = async ({ senderId, recipientId }) => { + if (!senderId || !recipientId || senderId === recipientId) return false; + const [settings, contacts] = await Promise.all([ + getSettingMap([recipientId]), + getContactMap({ ownerIds: [recipientId], friendIds: [senderId] }), + ]); + if (contacts.get(`${recipientId}:${senderId}`)) return false; + const setting = settings.get(recipientId); + return setting?.messageRequestsEnabled !== false; +}; + +const upsertMessageRequest = async ({ senderId, recipientId, roomId, preview = '' }) => { + if (!(await shouldStageMessageRequest({ senderId, recipientId }))) return null; + + let row = await MessageRequestModel.findOne({ + where: { recipientId, roomId }, + }); + const patch = { + requesterId: senderId, + recipientId, + roomId, + status: row?.status === 'accepted' ? 'accepted' : 'pending', + preview: String(preview || '').slice(0, 320), + lastMessageAt: new Date(), + actionAt: row?.status === 'accepted' ? row.actionAt : null, + }; + + if (row) await row.update(patch); + else row = await MessageRequestModel.create(patch); + + if (row.status === 'pending') { + const inbox = await InboxModel.findOne({ where: { roomId } }); + if (inbox) { + await inbox.update({ + requestPendingFor: addToSet(inbox.requestPendingFor, [recipientId]), + }); + } + } + + return toPlain(row); +}; + +const clearPendingFor = async ({ roomId, userId }) => { + const inbox = await InboxModel.findOne({ where: { roomId } }); + if (!inbox) return null; + const next = asArray(inbox.requestPendingFor).filter((id) => id !== userId); + await inbox.update({ requestPendingFor: next }); + return inbox; +}; + +module.exports = { + shouldStageMessageRequest, + upsertMessageRequest, + clearPendingFor, +}; diff --git a/backend/server/helpers/models/inbox.js b/backend/server/helpers/models/inbox.js index ff6b3be4..a14d6a2f 100644 --- a/backend/server/helpers/models/inbox.js +++ b/backend/server/helpers/models/inbox.js @@ -21,14 +21,11 @@ const isMatch = (inbox, queries = {}) => { if (value && typeof value === 'object' && value.$all) { return hasAll(inbox.ownersId, value.$all); } - return asArray(inbox.ownersId).includes(value); } if (value && typeof value === 'object' && value.$ne !== undefined) { - if (Array.isArray(inbox[key])) { - return !inbox[key].includes(value.$ne); - } + if (Array.isArray(inbox[key])) return !inbox[key].includes(value.$ne); return inbox[key] !== value.$ne; } @@ -40,10 +37,9 @@ exports.find = async (queries, search = '', options = {}) => { const viewerId = typeof queries?.ownersId === 'string' ? String(queries.ownersId) : null; const includeHidden = !!options.includeHidden; + const includeRequests = !!options.includeRequests; const inboxesRaw = await InboxModel.findAll(); - const inboxes = toPlainMany(inboxesRaw).filter((inbox) => - isMatch(inbox, queries) - ); + const inboxes = toPlainMany(inboxesRaw).filter((inbox) => isMatch(inbox, queries)); if (inboxes.length === 0) return []; @@ -90,10 +86,7 @@ exports.find = async (queries, search = '', options = {}) => { ); const regex = new RegExp(search || '', 'i'); - const privacy = await buildPrivacyContext({ - viewerId, - targetIds: ownersIds, - }); + const privacy = await buildPrivacyContext({ viewerId, targetIds: ownersIds }); return inboxes .map((inbox) => { @@ -102,30 +95,28 @@ exports.find = async (queries, search = '', options = {}) => { delete sanitized.secretSessionKey; return { - ...sanitized, - owners: asArray(inbox.ownersId) - .map((ownerId) => - ownersById.get(ownerId) - ? sanitizeProfileForViewer({ - profile: ownersById.get(ownerId), - viewerId, - setting: privacy.settingMap.get(ownerId), - isViewerContact: privacy.isViewerContact(ownerId), - }) - : null - ) - .filter(Boolean), - group: groupsByRoom.get(inbox.roomId) || null, - channel: channelsByRoom.get(inbox.roomId) || null, - file: inbox.fileId ? filesById.get(inbox.fileId) || null : null, - }}) + ...sanitized, + owners: asArray(inbox.ownersId) + .map((ownerId) => + ownersById.get(ownerId) + ? sanitizeProfileForViewer({ + profile: ownersById.get(ownerId), + viewerId, + setting: privacy.settingMap.get(ownerId), + isViewerContact: privacy.isViewerContact(ownerId), + }) + : null + ) + .filter(Boolean), + group: groupsByRoom.get(inbox.roomId) || null, + channel: channelsByRoom.get(inbox.roomId) || null, + file: inbox.fileId ? filesById.get(inbox.fileId) || null : null, + }; + }) .filter((inbox) => { - if (viewerId && asArray(inbox.deletedBy).includes(viewerId)) { - return false; - } - if (!includeHidden && viewerId && asArray(inbox.hiddenBy).includes(viewerId)) { - return false; - } + if (viewerId && asArray(inbox.deletedBy).includes(viewerId)) return false; + if (!includeHidden && viewerId && asArray(inbox.hiddenBy).includes(viewerId)) return false; + if (!includeRequests && viewerId && asArray(inbox.requestPendingFor).includes(viewerId)) return false; if (!search) return true; if (inbox.roomType === 'private') { return inbox.owners.some((owner) => regex.test(owner.fullname || '')); @@ -138,10 +129,6 @@ exports.find = async (queries, search = '', options = {}) => { const bPinned = asArray(b.pinnedBy).includes(viewerId); if (aPinned !== bPinned) return bPinned - aPinned; } - - return ( - new Date(b.content?.time || 0).getTime() - - new Date(a.content?.time || 0).getTime() - ); + return new Date(b.content?.time || 0).getTime() - new Date(a.content?.time || 0).getTime(); }); }; diff --git a/backend/server/middleware/chatSendIdempotency.js b/backend/server/middleware/chatSendIdempotency.js new file mode 100644 index 00000000..676d075b --- /dev/null +++ b/backend/server/middleware/chatSendIdempotency.js @@ -0,0 +1,107 @@ +const { v4: uuidv4 } = require('uuid'); +const ChatModel = require('../db/models/chat'); +const FileModel = require('../db/models/file'); +const ProfileModel = require('../db/models/profile'); +const { toPlain } = require('../db/utils'); +const { nextSequence } = require('../helpers/chatReliability'); +const { resolveMentions } = require('../helpers/chatMentions'); +const logger = require('../helpers/logger'); + +const buildDuplicatePayload = async (chat) => { + const plain = toPlain(chat); + const [file, profile] = await Promise.all([ + plain?.fileId + ? FileModel.findOne({ where: { fileId: plain.fileId } }) + : null, + ProfileModel.findOne({ + where: { userId: plain?.userId }, + attributes: ['userId', 'avatar', 'fullname', 'username'], + }), + ]); + return { + ...plain, + file: toPlain(file), + profile: toPlain(profile), + }; +}; + +module.exports = async (req, res, next) => { + try { + const senderId = req.user?._id; + const roomId = String(req.body?.roomId || '').trim(); + if (!senderId || !roomId) { + next(); + return; + } + + const clientMessageId = String( + req.body?.clientMessageId || req.get('x-client-message-id') || uuidv4() + ) + .trim() + .slice(0, 96); + req.body.clientMessageId = clientMessageId; + + const duplicate = await ChatModel.findOne({ + where: { userId: senderId, roomId, clientMessageId }, + }); + if (duplicate) { + res.status(200).json({ + success: true, + message: 'File message already sent', + duplicate: true, + payload: await buildDuplicatePayload(duplicate), + }); + return; + } + + const originalJson = res.json.bind(res); + let completed = false; + res.json = (body) => { + if (completed) return originalJson(body); + completed = true; + + const chatId = body?.success !== false ? body?.payload?._id : null; + if (!chatId) return originalJson(body); + + Promise.resolve() + .then(async () => { + const chat = await ChatModel.findOne({ + where: { _id: chatId, userId: senderId, roomId }, + }); + if (!chat) return; + const sequence = Number(chat.sequence || 0) || (await nextSequence(roomId)); + const mentions = await resolveMentions({ + text: req.body?.text || '', + roomId, + roomType: req.body?.roomType || 'private', + senderId, + }); + await chat.update({ + clientMessageId, + sequence, + mentionUserIds: mentions.mentionedUserIds, + topicId: req.body?.topicId || null, + }); + body.payload.clientMessageId = clientMessageId; + body.payload.sequence = sequence; + body.payload.mentionUserIds = mentions.mentionedUserIds; + body.payload.topicId = req.body?.topicId || null; + }) + .catch((error0) => { + logger.warn('CHAT_MEDIA_IDEMPOTENCY_PATCH_FAILED', { + chatId, + roomId, + userId: senderId, + message: error0.message, + }); + }) + .finally(() => originalJson(body)); + + return res; + }; + + next(); + } catch (error0) { + next(error0); + } +}; diff --git a/backend/server/middleware/e2eeContentPolicy.js b/backend/server/middleware/e2eeContentPolicy.js new file mode 100644 index 00000000..28fff9a3 --- /dev/null +++ b/backend/server/middleware/e2eeContentPolicy.js @@ -0,0 +1,42 @@ +const InboxModel = require('../db/models/inbox'); +const response = require('../helpers/response'); + +const getRoom = async (req) => { + const roomId = String(req.body?.roomId || req.params?.roomId || '').trim(); + if (!roomId) return null; + return InboxModel.findOne({ where: { roomId } }); +}; + +const rejectWhenE2ee = (message, code) => async (req, res, next) => { + try { + const inbox = await getRoom(req); + if (inbox?.roomType === 'private' && inbox?.e2eeEnabled) { + response({ + res, + statusCode: 409, + success: false, + message, + payload: { code, roomId: inbox.roomId }, + }); + return; + } + next(); + } catch (error0) { + next(error0); + } +}; + +const rejectE2eeUnencryptedMedia = rejectWhenE2ee( + 'Media sending is disabled while device E2EE is enabled because encrypted media attachments are not implemented yet.', + 'E2EE_MEDIA_NOT_SUPPORTED' +); + +const rejectE2eeServerScheduledMessage = rejectWhenE2ee( + 'Scheduled send is disabled while device E2EE is enabled because the server cannot encrypt a message later without device private keys.', + 'E2EE_SCHEDULE_NOT_SUPPORTED' +); + +module.exports = { + rejectE2eeUnencryptedMedia, + rejectE2eeServerScheduledMessage, +}; diff --git a/backend/server/routes/chat.js b/backend/server/routes/chat.js index 5c5ff719..6cc77a46 100644 --- a/backend/server/routes/chat.js +++ b/backend/server/routes/chat.js @@ -1,12 +1,13 @@ const router = require('express').Router(); const authenticate = require('../middleware/auth'); const upload = require('../middleware/upload'); +const chatSendIdempotency = require('../middleware/chatSendIdempotency'); const ctrl = require('../controllers/chat'); const chatUpload = require('../controllers/chatUpload'); const chatDeletion = require('../controllers/chatDeletion'); router.post('/chats/upload', authenticate, upload.single('file'), chatUpload.upload); -router.post('/chats/send-file', authenticate, ctrl.sendFile); +router.post('/chats/send-file', authenticate, chatSendIdempotency, ctrl.sendFile); router.post('/chats/:chatId/view-once-open', authenticate, ctrl.openViewOnce); router.get('/chats/scheduled', authenticate, ctrl.findScheduled); router.post('/chats/scheduled', authenticate, ctrl.createScheduled); diff --git a/backend/server/routes/chatAiAdmin.js b/backend/server/routes/chatAiAdmin.js new file mode 100644 index 00000000..0ebe8d8c --- /dev/null +++ b/backend/server/routes/chatAiAdmin.js @@ -0,0 +1,20 @@ +const router = require('express').Router(); +const adminAuth = require('../middleware/adminAuth'); +const { requirePermission } = require('../middleware/adminPermission'); +const { PERMISSIONS } = require('../helpers/adminPermissions'); +const ctrl = require('../controllers/chatAiAdmin'); + +router.get( + '/admin/chat-ai/config', + adminAuth, + requirePermission(PERMISSIONS.APP_CONFIG_READ), + ctrl.getConfig +); +router.patch( + '/admin/chat-ai/config', + adminAuth, + requirePermission(PERMISSIONS.APP_CONFIG_WRITE), + ctrl.updateConfig +); + +module.exports = router; diff --git a/backend/server/routes/chatV2.js b/backend/server/routes/chatV2.js new file mode 100644 index 00000000..9b418991 --- /dev/null +++ b/backend/server/routes/chatV2.js @@ -0,0 +1,78 @@ +const express = require('express'); +const router = express.Router(); +const authenticate = require('../middleware/auth'); +const ctrl = require('../controllers/chatV2'); +const suggestions = require('../controllers/chatSuggestions'); +const resumable = require('../controllers/chatResumableUpload'); +const { + cleanupQueryE2eeKeys, + cleanupRoomE2eeKeys, +} = require('../helpers/e2eeKeyDirectory'); + +router.get('/chat-v2/messages/:chatId/receipts', authenticate, ctrl.getMessageReceipts); +router.get('/chat-v2/messages/:chatId/history', authenticate, ctrl.getEditHistory); + +router.get('/chat-v2/drafts', authenticate, ctrl.listDrafts); +router.get('/chat-v2/drafts/:roomId', authenticate, ctrl.getDraft); +router.put('/chat-v2/drafts/:roomId', authenticate, ctrl.saveDraft); +router.delete('/chat-v2/drafts/:roomId', authenticate, ctrl.deleteDraft); + +router.get('/chat-v2/mentions', authenticate, ctrl.listMentions); +router.get( + '/chat-v2/mention-suggestions/:roomId', + authenticate, + suggestions.mentionSuggestions +); +router.get('/chat-v2/search', authenticate, ctrl.searchMessages); + +router.get('/chat-v2/message-requests', authenticate, ctrl.listMessageRequests); +router.post( + '/chat-v2/message-requests/:requestId/action', + authenticate, + ctrl.actionMessageRequest +); + +router.get('/chat-v2/topics/:roomId', authenticate, ctrl.listTopics); +router.post('/chat-v2/topics/:roomId', authenticate, ctrl.createTopic); +router.patch('/chat-v2/topics/item/:topicId', authenticate, ctrl.updateTopic); +router.delete('/chat-v2/topics/item/:topicId', authenticate, ctrl.deleteTopic); + +router.put('/chat-v2/e2ee/device-key', authenticate, ctrl.registerE2eeKey); +router.get( + '/chat-v2/e2ee/keys', + authenticate, + cleanupQueryE2eeKeys, + ctrl.listE2eeKeys +); +router.get( + '/chat-v2/e2ee/rooms/:roomId', + authenticate, + cleanupRoomE2eeKeys, + ctrl.getRoomE2ee +); +router.post( + '/chat-v2/e2ee/rooms/:roomId', + authenticate, + cleanupRoomE2eeKeys, + ctrl.setRoomE2ee +); + +router.post('/chat-v2/uploads', authenticate, ctrl.initResumableUpload); +router.put( + '/chat-v2/uploads/:uploadId/parts/:partNumber', + authenticate, + express.raw({ type: 'application/octet-stream', limit: '5mb' }), + ctrl.putResumableChunk +); +router.get('/chat-v2/uploads/:uploadId', authenticate, ctrl.getResumableUpload); +router.post( + '/chat-v2/uploads/:uploadId/complete', + authenticate, + resumable.complete +); +router.delete('/chat-v2/uploads/:uploadId', authenticate, ctrl.cancelResumableUpload); + +router.post('/chat-v2/translate', authenticate, ctrl.translateMessage); +router.post('/chat-v2/transcribe', authenticate, ctrl.transcribeVoice); + +module.exports = router; diff --git a/backend/server/routes/cron.js b/backend/server/routes/cron.js index 17227a20..7fbfec4a 100644 --- a/backend/server/routes/cron.js +++ b/backend/server/routes/cron.js @@ -2,8 +2,11 @@ const router = require('express').Router(); const { processScheduledMessages, } = require('../helpers/scheduledMessages'); +const { + cleanupChatMaintenance, +} = require('../helpers/chatMaintenance'); -router.get('/internal/scheduled-messages/run', async (req, res) => { +const authorizeCron = (req, res) => { const secret = String(process.env.CRON_SECRET || '').trim(); const authorization = String(req.headers.authorization || ''); @@ -12,14 +15,21 @@ router.get('/internal/scheduled-messages/run', async (req, res) => { success: false, message: 'Unauthorized cron request', }); - return; + return false; } + return true; +}; + +router.get('/internal/scheduled-messages/run', async (req, res) => { + if (!authorizeCron(req, res)) return; try { await processScheduledMessages(); + const chatMaintenance = await cleanupChatMaintenance(); res.status(200).json({ success: true, - message: 'Scheduled messages processed', + message: 'Scheduled messages and chat maintenance processed', + chatMaintenance, timestamp: new Date().toISOString(), }); } catch (error) { @@ -30,4 +40,23 @@ router.get('/internal/scheduled-messages/run', async (req, res) => { } }); +router.get('/internal/chat-maintenance/run', async (req, res) => { + if (!authorizeCron(req, res)) return; + + try { + const payload = await cleanupChatMaintenance(); + res.status(200).json({ + success: true, + message: 'Chat maintenance processed', + payload, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message || 'Chat maintenance failed', + }); + } +}); + module.exports = router; diff --git a/backend/server/routes/index.js b/backend/server/routes/index.js index b262ade5..02a3736b 100644 --- a/backend/server/routes/index.js +++ b/backend/server/routes/index.js @@ -19,6 +19,7 @@ router.get('/health', (req, res) => { const cron = require('./cron'); const user = require('./user'); const chat = require('./chat'); +const chatV2 = require('./chatV2'); const contact = require('./contact'); const setting = require('./setting'); const profile = require('./profile'); @@ -35,12 +36,14 @@ const callingConfig = require('./callingConfig'); const storageAdmin = require('./storageAdmin'); const callingAdmin = require('./callingAdmin'); const callingPushAdmin = require('./callingPushAdmin'); +const chatAiAdmin = require('./chatAiAdmin'); const adminProfileSecurity = require('./adminProfileSecurity'); const admin = require('./admin'); router.use(cron); router.use(user); router.use(chat); +router.use(chatV2); router.use(contact); router.use(setting); router.use(profile); @@ -57,6 +60,7 @@ router.use(callingConfig); router.use(storageAdmin); router.use(callingAdmin); router.use(callingPushAdmin); +router.use(chatAiAdmin); router.use(adminProfileSecurity); router.use(admin); diff --git a/backend/server/server.js b/backend/server/server.js index 0b8a2cc5..39eff5c0 100644 --- a/backend/server/server.js +++ b/backend/server/server.js @@ -13,6 +13,7 @@ const logger = require('./helpers/logger'); const { loadSecurityConfig, getClientIp, getRequestFingerprint } = require('./helpers/securityConfig'); const { loadAppConfig } = require('./helpers/appConfig'); const { getAdminOrigin, getHostnameFromOrigin } = require('./helpers/origins'); +const { installSocketAuthentication } = require('./socket/auth'); const app = express(); const server = http.createServer(app); @@ -146,6 +147,7 @@ global.io = new SocketServer(server, { transports: ['websocket'], maxHttpBufferSize: Number(process.env.SOCKET_MAX_HTTP_BUFFER_SIZE || 25e6), }); +installSocketAuthentication(global.io); require('./socket'); module.exports = server; diff --git a/backend/server/socket/auth.js b/backend/server/socket/auth.js new file mode 100644 index 00000000..7d48c67d --- /dev/null +++ b/backend/server/socket/auth.js @@ -0,0 +1,157 @@ +const UserModel = require('../db/models/user'); +const UserSessionModel = require('../db/models/userSession'); +const AdminModel = require('../db/models/admin'); +const AdminSessionModel = require('../db/models/adminSession'); +const { verifyToken } = require('../helpers/userSessions'); +const { verifyAdminToken } = require('../helpers/adminSessions'); +const logger = require('../helpers/logger'); + +const touchSeen = async (session) => { + if (!session || session.revokedAt) return; + const lastSeen = new Date(session.lastSeenAt || 0).getTime(); + if (Date.now() - lastSeen < 60 * 1000) return; + await session.update({ lastSeenAt: new Date() }).catch(() => {}); +}; + +const authenticateUser = async (token) => { + const decoded = verifyToken(token); + const userId = decoded?._id || decoded?.id || decoded?.userId; + if (!userId) throw new Error('Invalid user token'); + + const user = await UserModel.findOne({ where: { _id: userId } }); + if (!user || user.status === 'banned' || user.status === 'blocked') { + throw new Error('User account is not active'); + } + + let session = null; + if (decoded.sid) { + session = await UserSessionModel.findOne({ + where: { _id: decoded.sid, userId }, + }); + if (!session || session.revokedAt) throw new Error('User session is no longer active'); + await touchSeen(session); + } + + return { + authType: 'user', + userId: String(userId), + sessionId: decoded.sid ? String(decoded.sid) : null, + user, + }; +}; + +const authenticateAdmin = async (token) => { + const decoded = verifyAdminToken(token); + const adminId = decoded?.aid; + if (!adminId) throw new Error('Invalid admin token'); + + const admin = await AdminModel.findOne({ where: { _id: adminId } }); + if (!admin || admin.active === false || admin.status === 'disabled') { + throw new Error('Admin account is not active'); + } + + let session = null; + if (decoded.sid) { + session = await AdminSessionModel.findOne({ + where: { _id: decoded.sid, adminId }, + }); + if (!session || session.revokedAt) throw new Error('Admin session is no longer active'); + await touchSeen(session); + } + + return { + authType: 'admin', + adminId: String(adminId), + sessionId: decoded.sid ? String(decoded.sid) : null, + admin, + }; +}; + +const applyVerifiedIdentity = (socket, packet) => { + const [event, payload] = packet || []; + if (!event) return; + const name = String(event); + + if (socket.authType === 'admin') { + if (!name.startsWith('admin/')) { + const error = new Error('Admin socket cannot emit user events'); + error.data = { code: 'SOCKET_SCOPE_DENIED' }; + throw error; + } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + if ('adminId' in payload) payload.adminId = socket.adminId; + if ('actorId' in payload) payload.actorId = socket.adminId; + } + return; + } + + if (name.startsWith('admin/')) { + const error = new Error('User socket cannot emit admin events'); + error.data = { code: 'SOCKET_SCOPE_DENIED' }; + throw error; + } + + if (name === 'user/connect' || name === 'user/disconnect') { + packet[1] = socket.userId; + return; + } + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return; + + // Common actor fields are authoritative from the verified handshake. Target + // fields such as friendId/participantId/recipientsId remain client-selected. + ['userId', 'senderId', 'fromUserId', 'actorId'].forEach((field) => { + if (field in payload) payload[field] = socket.userId; + }); + + // Legacy create events used `adminId` as the actor/creator. Do not rewrite + // adminId on moderation payloads because there it can describe a target. + if ((name === 'group/create' || name === 'channel/create') && 'adminId' in payload) { + payload.adminId = socket.userId; + } +}; + +const installSocketAuthentication = (io) => { + if (!io) return; + + io.use(async (socket, next) => { + try { + const userToken = String(socket.handshake?.auth?.token || '').trim(); + const adminToken = String(socket.handshake?.auth?.adminToken || '').trim(); + + if (adminToken) { + Object.assign(socket, await authenticateAdmin(adminToken)); + } else if (userToken) { + Object.assign(socket, await authenticateUser(userToken)); + } else { + const error = new Error('Authentication required'); + error.data = { code: 'SOCKET_AUTH_REQUIRED' }; + next(error); + return; + } + + socket.use((packet, packetNext) => { + try { + applyVerifiedIdentity(socket, packet); + packetNext(); + } catch (error0) { + packetNext(error0); + } + }); + + next(); + } catch (error0) { + logger.warn('SOCKET_AUTH_REJECTED', { + socketId: socket.id, + message: error0.message, + }); + const error = new Error('Socket authentication failed'); + error.data = { code: 'SOCKET_AUTH_INVALID' }; + next(error); + } + }); +}; + +module.exports = { + installSocketAuthentication, +}; diff --git a/backend/server/socket/e2eeGuard.js b/backend/server/socket/e2eeGuard.js new file mode 100644 index 00000000..69565b40 --- /dev/null +++ b/backend/server/socket/e2eeGuard.js @@ -0,0 +1,93 @@ +const { Op } = require('sequelize'); +const InboxModel = require('../db/models/inbox'); +const ChatModel = require('../db/models/chat'); +const { asArray, toPlainMany } = require('../db/utils'); +const logger = require('../helpers/logger'); + +const rejection = (socket, eventName, roomId, message, code) => { + if (eventName === 'chat/forward') { + socket.emit('chat/forward-blocked', { roomId, message, code }); + } else { + socket.emit('chat/error', { roomId, message, code }); + } + const error = new Error(message); + error.data = { code, roomId }; + return error; +}; + +const installE2eeSocketGuard = (socket) => { + if (!socket || socket.authType !== 'user' || socket.__syncchatE2eeGuard) return; + socket.__syncchatE2eeGuard = true; + + socket.use(async (packet, next) => { + const [eventName, payload = {}] = packet || []; + if (!String(eventName || '').startsWith('chat/')) { + next(); + return; + } + + try { + if (eventName === 'chat/insert') { + const roomId = String(payload?.roomId || ''); + if (!roomId) { + next(); + return; + } + const inbox = await InboxModel.findOne({ where: { roomId } }); + if ( + inbox?.roomType === 'private' && + inbox?.e2eeEnabled && + String(payload?.text || '').trim() && + !payload?.e2eeEnvelope + ) { + next(rejection(socket, eventName, roomId, 'This chat requires device E2EE. Plaintext message rejected.', 'E2EE_REQUIRED')); + return; + } + } + + if (eventName === 'chat/edit') { + const roomId = String(payload?.roomId || ''); + const chatId = String(payload?.chatId || ''); + if (roomId && chatId) { + const [inbox, chat] = await Promise.all([ + InboxModel.findOne({ where: { roomId } }), + ChatModel.findOne({ where: { _id: chatId, roomId } }), + ]); + if (inbox?.e2eeEnabled || chat?.e2eeEnvelope) { + next(rejection(socket, eventName, roomId, 'Editing is disabled for device-E2EE messages until encrypted edit envelopes are supported.', 'E2EE_EDIT_BLOCKED')); + return; + } + } + } + + if (eventName === 'chat/forward') { + const fromRoomId = String(payload?.fromRoomId || ''); + const chatsId = asArray(payload?.chatsId).filter(Boolean).slice(0, 500); + if (fromRoomId && chatsId.length) { + const [inbox, chats] = await Promise.all([ + InboxModel.findOne({ where: { roomId: fromRoomId } }), + ChatModel.findAll({ where: { _id: { [Op.in]: chatsId }, roomId: fromRoomId } }), + ]); + const hasE2ee = !!inbox?.e2eeEnabled || toPlainMany(chats).some((chat) => !!chat.e2eeEnvelope); + if (hasE2ee) { + next(rejection(socket, eventName, fromRoomId, 'Forwarding device-E2EE messages is disabled to prevent plaintext downgrade.', 'E2EE_FORWARD_BLOCKED')); + return; + } + } + } + + next(); + } catch (error0) { + logger.warn('E2EE_SOCKET_GUARD_ERROR', { + userId: socket.userId, + eventName, + message: error0.message, + }); + next(error0); + } + }); +}; + +module.exports = { + installE2eeSocketGuard, +}; diff --git a/backend/server/socket/events/chatV2.js b/backend/server/socket/events/chatV2.js new file mode 100644 index 00000000..b671a4ac --- /dev/null +++ b/backend/server/socket/events/chatV2.js @@ -0,0 +1,113 @@ +const { Op } = require('sequelize'); +const ChatModel = require('../../db/models/chat'); +const InboxModel = require('../../db/models/inbox'); +const MessageReceiptModel = require('../../db/models/messageReceipt'); +const { asArray, toPlain, toPlainMany } = require('../../db/utils'); +const logger = require('../../helpers/logger'); + +const ensureRoomMember = async ({ roomId, userId }) => { + const inbox = await InboxModel.findOne({ where: { roomId } }); + if (!inbox || !asArray(inbox.ownersId).includes(userId)) return null; + return inbox; +}; + +const upsertReceipt = async ({ chatId, roomId, userId, sessionId, type }) => { + let row = await MessageReceiptModel.findOne({ + where: { + chatId, + userId, + sessionId: sessionId || null, + }, + }); + const now = new Date(); + const patch = { + chatId, + roomId, + userId, + sessionId: sessionId || null, + ...(type === 'read' + ? { deliveredAt: row?.deliveredAt || now, readAt: now } + : { deliveredAt: row?.deliveredAt || now }), + }; + if (row) await row.update(patch); + else row = await MessageReceiptModel.create(patch); + return toPlain(row); +}; + +module.exports = (socket) => { + if (socket.authType !== 'user') return; + + socket.on('chat/receipt', async (payload = {}) => { + try { + const chatId = String(payload.chatId || ''); + const roomId = String(payload.roomId || ''); + const type = payload.type === 'read' ? 'read' : 'delivered'; + if (!chatId || !roomId) return; + if (!(await ensureRoomMember({ roomId, userId: socket.userId }))) return; + + const chat = await ChatModel.findOne({ where: { _id: chatId, roomId } }); + if (!chat || chat.userId === socket.userId) return; + + const receipt = await upsertReceipt({ + chatId, + roomId, + userId: socket.userId, + sessionId: socket.sessionId, + type, + }); + + if (type === 'read') { + await chat.update({ delivered: true, readed: true }).catch(() => {}); + } else if (!chat.delivered) { + await chat.update({ delivered: true }).catch(() => {}); + } + + const event = { chatId, roomId, type, receipt }; + if (global?.io) { + global.io.to(roomId).emit('chat/receipt', event); + global.io.to(chat.userId).emit('chat/receipt', event); + } + } catch (error0) { + logger.warn('CHAT_RECEIPT_ERROR', { message: error0.message, userId: socket.userId }); + } + }); + + socket.on('chat/sync-request', async (payload = {}, ack) => { + try { + const roomId = String(payload.roomId || ''); + const afterSequence = Math.max(0, Number(payload.afterSequence || 0)); + const limit = Math.min(250, Math.max(1, Number(payload.limit || 100))); + if (!roomId || !(await ensureRoomMember({ roomId, userId: socket.userId }))) { + if (typeof ack === 'function') ack({ success: false, message: 'Forbidden' }); + return; + } + + const rows = await ChatModel.findAll({ + where: { + roomId, + sequence: { [Op.gt]: afterSequence }, + }, + order: [['sequence', 'ASC']], + limit, + }); + const messages = toPlainMany(rows).filter( + (chat) => !asArray(chat.deletedBy).includes(socket.userId) + ); + const result = { + success: true, + roomId, + messages, + lastSequence: messages.length + ? Number(messages[messages.length - 1].sequence || afterSequence) + : afterSequence, + hasMore: messages.length >= limit, + }; + socket.emit('chat/sync-result', result); + if (typeof ack === 'function') ack(result); + } catch (error0) { + if (typeof ack === 'function') ack({ success: false, message: error0.message }); + } + }); +}; + +module.exports.upsertReceipt = upsertReceipt; diff --git a/backend/server/socket/index.js b/backend/server/socket/index.js index 06400361..02181d75 100644 --- a/backend/server/socket/index.js +++ b/backend/server/socket/index.js @@ -3,20 +3,27 @@ const logger = require('../helpers/logger'); const user = require('./events/user'); const chat = require('./events/chat'); +const chatV2 = require('./events/chatV2'); const room = require('./events/room'); const group = require('./events/group'); const channel = require('./events/channel'); const admin = require('./events/admin'); +const { wrapReliableChatInsert } = require('../helpers/chatReliability'); io.on('connection', (socket) => { logger.info('SOCKET_CONNECT', { socketId: socket.id, address: socket.handshake.address, + authType: socket.authType || null, + userId: socket.userId || null, + adminId: socket.adminId || null, + sessionId: socket.sessionId || null, }); socket.onAny((event, ...args) => { logger.info('SOCKET_IN', { socketId: socket.id, + authType: socket.authType || null, event, args, }); @@ -35,14 +42,29 @@ io.on('connection', (socket) => { socket.on('disconnect', (reason) => { logger.warn('SOCKET_DISCONNECT', { socketId: socket.id, + authType: socket.authType || null, + userId: socket.userId || null, + adminId: socket.adminId || null, reason, }); }); + if (socket.authType === 'admin') { + socket.join(`admin:${socket.adminId}`); + admin(socket); + return; + } + + socket.join(socket.userId); user(socket); room(socket); chat(socket); group(socket); channel(socket); - admin(socket); + chatV2(socket); + + // Wrap the legacy chat/insert listener after it is registered so existing + // moderation/file/secret-chat behavior is preserved while adding idempotency, + // monotonic room sequence numbers, mention metadata and message requests. + wrapReliableChatInsert(socket); }); diff --git a/docs/CHAT_V2_FINAL_SECURITY.md b/docs/CHAT_V2_FINAL_SECURITY.md new file mode 100644 index 00000000..0c8727d5 --- /dev/null +++ b/docs/CHAT_V2_FINAL_SECURITY.md @@ -0,0 +1,47 @@ +# SyncChat Chat V2 — Final Security Boundary + +The production Chat V2 layer is fail-closed around the features that are not yet cryptographically implemented. + +## Device E2EE + +New private-chat text messages can use browser-device E2EE with P-256 ECDH, HKDF-SHA256 and AES-256-GCM. Device private keys stay in browser IndexedDB; the backend stores public device keys/fingerprints and encrypted message envelopes. + +This implementation is intentionally **not described as Signal Protocol / Double Ratchet**. It does not claim Signal-style forward secrecy, post-compromise security, safety-number verification UX, or encrypted media attachments. + +While device E2EE is enabled: + +- plaintext `chat/insert` events are rejected server-side; +- media upload/send is blocked by the official client and media send is rejected server-side; +- scheduled server-side messages are rejected; +- message editing is rejected until encrypted edit envelopes exist; +- forwarding is rejected to prevent a plaintext downgrade; +- server AI translation/transcription remains disabled for encrypted content; +- drafts remain device-local instead of being persisted as plaintext on the server; +- offline queued E2EE text is sealed locally with a non-extractable AES-GCM key before it is stored in IndexedDB; +- cached E2EE history stores the raw encrypted server payload and decrypts only in memory; +- revoked linked-device E2EE public keys are removed from the active key directory. + +## Reliable delivery + +Normal text and media messages use stable `clientMessageId` values. Message metadata is attached at creation through an AsyncLocalStorage-backed create context so the unique sender/client-message constraint is atomic rather than patched after the message is created. Per-room sequence numbers drive reconnect catch-up. + +## Existing linked devices + +Chat V2 reuses SyncChat's existing linked-device/session model. Socket authentication, device receipts and E2EE public keys are bound to the verified session ID; revoked sessions are rejected rather than creating a second device subsystem. + +## Production QA + +Before merging this branch to `main`, validate at minimum: + +1. user/admin Socket.IO handshake authentication and revoked-session rejection; +2. duplicate retry with the same `clientMessageId` creates one message only; +3. reconnect sequence catch-up and offline outbox ordering; +4. delivery/read receipt records across two linked sessions; +5. message request Accept/Delete/Block behavior; +6. `@username`, `@admins`, and admin-only `@all` autocomplete and notifications; +7. topic create/select/filter/send behavior; +8. E2EE text between two users and multiple active devices; +9. E2EE fail-closed media/schedule/edit/forward behavior; +10. resumable upload retry and FTP/FTPS final storage; +11. search, edit-history, translation and transcription provider flows; +12. Redis-backed flood protection across more than one backend instance. diff --git a/docs/CHAT_V2_HARDENING.md b/docs/CHAT_V2_HARDENING.md new file mode 100644 index 00000000..41bd5cf0 --- /dev/null +++ b/docs/CHAT_V2_HARDENING.md @@ -0,0 +1,58 @@ +# Chat V2 Hardening Notes + +This follow-up hardens the production Chat V2 layer added on `agent/shared-chat-lock-delete-scope`. + +## E2EE local-storage safety + +The web/PWA transport stores **raw server payloads before E2EE decryption**. E2EE plaintext produced in memory is not written back to the message cache. + +The cache also refuses to persist legacy Secret Chat, disappearing, view-once, secret-system, or already-decrypted E2EE records. + +Outgoing E2EE text that is queued while offline is sealed locally using a non-extractable AES-GCM key stored as a browser `CryptoKey` in IndexedDB. When connectivity returns, the local sealed payload is opened in memory, encrypted for the active recipient device directory, and then sent. The same `clientMessageId` is retained across retry. + +This means offline queue support does not require storing E2EE plaintext in IndexedDB. + +## Revoked-device E2EE keys + +Before E2EE key-directory reads and room E2EE state changes, SyncChat reconciles active E2EE device keys against `user_sessions`. Keys belonging to revoked or missing sessions are marked inactive and excluded from future envelopes. + +## Media idempotency + +`POST /api/chats/send-file` now receives a stable `clientMessageId` from the web client and retries network failures with the same ID. The server checks that ID before sending and patches successful file messages with a room sequence number, mentions, and selected topic metadata. + +This protects the common "server accepted the media message but the response was lost" retry case from creating duplicate messages. + +## Mention autocomplete + +Typing `@` in the active composer opens participant suggestions. Supported special mentions: + +- `@admins` +- `@all` for group/channel admins + +The suggestion API only returns profiles belonging to a room the authenticated user can access. + +## Resumable-upload maintenance + +Chat maintenance now: + +- expires abandoned resumable uploads +- removes stale MongoDB chunk sessions +- removes terminal upload metadata after seven days +- removes drafts older than 90 days +- removes device receipt metadata older than 180 days +- reconciles stale E2EE public keys + +It runs automatically whenever the existing scheduled-message cron is invoked and is also available at: + +```text +GET /api/internal/chat-maintenance/run +Authorization: Bearer +``` + +## Remaining cryptographic boundary + +SyncChat's new device E2EE is server-blind for **new private-chat text messages** and uses P-256 ECDH, HKDF-SHA256, and AES-256-GCM with browser-held private keys. + +It is intentionally **not described as Signal Protocol / Double Ratchet**. It does not yet provide Signal-style forward secrecy, post-compromise security, safety-number verification UX, or encrypted media attachments. Existing legacy Secret Chat remains a separate server-side AES-GCM feature. + +Those protocol properties require a vetted ratchet implementation and interoperability/security review rather than an ad-hoc claim of Signal compatibility. diff --git a/docs/CHAT_V2_PRODUCTION.md b/docs/CHAT_V2_PRODUCTION.md new file mode 100644 index 00000000..f707b694 --- /dev/null +++ b/docs/CHAT_V2_PRODUCTION.md @@ -0,0 +1,288 @@ +# SyncChat Chat V2 Production Layer + +This document describes the production messaging capabilities added on top of the existing SyncChat chat, group, channel, media, scheduling, secret-chat and moderation features. + +## 1. Authenticated Socket.IO identity + +Socket.IO now requires a valid user or admin session token during the WebSocket handshake. + +- Client: `auth.token` +- Admin: `auth.adminToken` +- Revoked device/admin sessions are rejected. +- User sockets cannot emit `admin/*` events. +- Admin sockets cannot emit user events. +- Legacy `user/connect` and `user/disconnect` IDs are replaced server-side with the verified socket identity. +- Actor fields such as `userId`, `senderId`, `fromUserId` and `actorId` are replaced with the verified user ID while target IDs remain untouched. + +This removes the previous client-supplied socket identity trust boundary. + +## 2. Reliable delivery and reconnect catch-up + +Every normal socket text message gets: + +- stable `clientMessageId` +- idempotent duplicate protection +- monotonic per-room `sequence` +- `chat/ack` +- IndexedDB outbox on the web client +- automatic retry after reconnect +- sequence-based `chat/sync-request` / `chat/sync-result` +- recent-message IndexedDB cache with GET fallback while offline + +Room sequence counters are stored in MongoDB and incremented atomically. + +## 3. Per-user/per-device receipts + +`message_receipts` records delivery/read state by: + +- `chatId` +- `userId` +- linked `sessionId` +- `deliveredAt` +- `readAt` + +The legacy `delivered` and `readed` booleans are still updated for backward compatibility. + +## 4. Multi-device + +SyncChat already had linked-device/session infrastructure. Chat V2 uses the existing session ID for: + +- socket authentication +- device receipts +- E2EE public device keys +- revoked-session rejection + +No duplicate device system was created. + +## 5. Device E2EE foundation + +Private chats can enable server-blind browser-device encryption for new text messages. + +Crypto: + +- P-256 ECDH device keys +- per-device ephemeral ECDH wrapping key +- HKDF-SHA256 derivation +- AES-256-GCM content encryption +- AES-256-GCM content-key wrapping +- device public keys/fingerprints stored on the server +- private keys stay in browser IndexedDB + +The server stores only the encrypted `e2eeEnvelope` and placeholder text for E2EE messages. Server-side AI translation/transcription is intentionally rejected for E2EE content. + +### Security boundary + +This is a server-blind device E2EE foundation. It is **not** the Signal Double Ratchet protocol and does not claim Signal-style forward secrecy, post-compromise security, safety-number UX, or encrypted media attachments yet. Existing legacy Secret Chat is separate and remains server-side AES-GCM. + +## 6. Draft sync + +Drafts are stored per user + room in MongoDB and restored into the composer. + +- text +- reply metadata +- selected topic +- multi-device/server persistence +- auto-clear after successful send + +## 7. Mentions + +Chat text supports: + +- `@username` +- `@admins` +- `@all` in group/channel rooms when the sender is an admin + +Mention targets are stored on the message and can be retrieved from the Mentions panel/API. + +## 8. Message Requests + +Unknown-user messages can be staged into a dedicated request queue when the recipient has message requests enabled. + +Actions: + +- Accept +- Delete/decline +- Block + +Pending requests are hidden from the normal inbox until accepted. + +## 9. Offline cache and outbox + +Web/PWA uses IndexedDB for: + +- recent chat cache +- per-room last sequence +- durable outgoing queue + +When the normal chat GET fails due to a network error, cached room messages can be returned to the existing UI. Sending while offline keeps the same `clientMessageId` when retried. + +## 10. Resumable media upload + +Large-file uploads use: + +1. create upload session +2. upload numbered binary chunks +3. persist chunks in MongoDB `resumable_upload_chunks` +4. retry failed chunks from the client +5. assemble after all chunks arrive +6. upload completed file to configured FTP/FTPS storage +7. create the normal SyncChat file record +8. send the file through the existing message path + +Temporary upload sessions expire after 24 hours. + +## 11. Advanced search + +`GET /api/chat-v2/search` supports: + +- search text +- current room or all rooms +- sender +- date range +- selected topic +- type: text/image/video/audio/document/link/call/poll + +The Chat Tools panel exposes search plus receipts, edit history, translation and voice transcription actions. + +## 12. Group/channel topics + +Groups/channels can have forum-style topics. + +- admin creates topic +- topic creator/admin can update/delete +- pin/close state +- selected topic is stored locally and synced with drafts +- new socket messages inherit selected `topicId` +- normal room history is filtered to the selected topic; “All messages” removes the filter + +## 13. Translation and voice transcription + +Provider configuration is DB-backed from the Admin Chat AI panel. + +Admin API: + +```text +GET /api/admin/chat-ai/config +PATCH /api/admin/chat-ai/config +``` + +Provider API keys are AES-256-GCM encrypted at rest using: + +```text +CHAT_AI_CONFIG_SECRET + -> CALL_CONFIG_SECRET + -> STORAGE_CONFIG_SECRET + -> JWT_SECRET +``` + +The generic provider contracts are: + +### Translation request + +```json +{ + "q": "source text", + "text": "source text", + "source": "auto", + "target": "en", + "format": "text" +} +``` + +Accepted response fields: `translatedText`, `translation`, `text`, or `output`. + +### Transcription request + +```json +{ + "audioBase64": "...", + "mime": "audio/webm", + "filename": "voice.webm", + "language": "auto" +} +``` + +Accepted response fields: `transcript`, `text`, or `output`. + +No provider is hardcoded. If the admin has not configured a provider, the runtime endpoint returns `503` instead of pretending AI is available. + +## 14. Spam/flood protection + +Normal chat inserts are protected by: + +- message-rate limit +- repeated-text flood detection +- link count limit +- mention count limit + +With `REDIS_URL`, counters work across backend instances. Without Redis there is a single-process fallback. + +Defaults: + +```env +CHAT_RATE_LIMIT_MESSAGES=30 +CHAT_RATE_LIMIT_WINDOW_SEC=10 +CHAT_DUPLICATE_LIMIT=6 +CHAT_DUPLICATE_WINDOW_SEC=30 +``` + +## 15. Edit history viewer + +Existing `editHistory` data is exposed by: + +```text +GET /api/chat-v2/messages/:chatId/history +``` + +The Chat Tools search result UI exposes the viewer for edited messages. + +## Runtime endpoints + +```text +GET /api/chat-v2/messages/:chatId/receipts +GET /api/chat-v2/messages/:chatId/history +GET /api/chat-v2/drafts +GET /api/chat-v2/drafts/:roomId +PUT /api/chat-v2/drafts/:roomId +DELETE /api/chat-v2/drafts/:roomId +GET /api/chat-v2/mentions +GET /api/chat-v2/search +GET /api/chat-v2/message-requests +POST /api/chat-v2/message-requests/:requestId/action +GET /api/chat-v2/topics/:roomId +POST /api/chat-v2/topics/:roomId +PATCH /api/chat-v2/topics/item/:topicId +DELETE /api/chat-v2/topics/item/:topicId +PUT /api/chat-v2/e2ee/device-key +GET /api/chat-v2/e2ee/keys +GET /api/chat-v2/e2ee/rooms/:roomId +POST /api/chat-v2/e2ee/rooms/:roomId +POST /api/chat-v2/uploads +PUT /api/chat-v2/uploads/:uploadId/parts/:partNumber +GET /api/chat-v2/uploads/:uploadId +POST /api/chat-v2/uploads/:uploadId/complete +DELETE /api/chat-v2/uploads/:uploadId +POST /api/chat-v2/translate +POST /api/chat-v2/transcribe +``` + +## Production QA checklist + +- two browser/device sessions cannot spoof another user over Socket.IO +- revoked session cannot reconnect to Socket.IO +- same `clientMessageId` submitted twice creates one message +- offline queued message sends once after reconnect +- sequence catch-up restores missed messages +- delivery/read receipt is recorded per device +- draft appears on another linked device +- unknown sender appears in Requests, not normal inbox +- Accept/Delete/Block request flows are tested +- `@username`, `@admins`, and admin-only `@all` are tested +- topic create/select/send/filter/delete is tested +- E2EE text decrypts on every registered device and server cannot recover plaintext from envelope +- E2EE send fails closed when a participant has no registered device key +- large upload survives chunk retry and lands on configured FTP/FTPS storage +- search filters return only accessible non-deleted messages +- provider-less AI actions return `503` +- configured translation/transcription provider works end-to-end +- spam/flood limits work with Redis across multiple backend instances diff --git a/frontend/admin/chatAiConfig.jsx b/frontend/admin/chatAiConfig.jsx new file mode 100644 index 00000000..a39f83f1 --- /dev/null +++ b/frontend/admin/chatAiConfig.jsx @@ -0,0 +1,201 @@ +import React from 'react'; +import axios from 'axios'; +import { BiBrain, BiX } from 'react-icons/bi'; + +const empty = { + translationEnabled: false, + translationUrl: '', + translationApiKey: '', + translationApiKeySet: false, + transcriptionEnabled: false, + transcriptionUrl: '', + transcriptionApiKey: '', + transcriptionApiKeySet: false, + defaultTargetLanguage: 'en', +}; + +const auth = () => ({ + headers: { + Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}`, + }, +}); + +function ChatAiConfig() { + const [hasToken, setHasToken] = React.useState(!!localStorage.getItem('admin_token')); + const [open, setOpen] = React.useState(false); + const [form, setForm] = React.useState(empty); + const [loading, setLoading] = React.useState(false); + const [message, setMessage] = React.useState(''); + const [error, setError] = React.useState(''); + + React.useEffect(() => { + const timer = setInterval(() => { + setHasToken(!!localStorage.getItem('admin_token')); + }, 1000); + return () => clearInterval(timer); + }, []); + + const load = React.useCallback(async () => { + if (!localStorage.getItem('admin_token')) return; + setLoading(true); + setError(''); + try { + const { data } = await axios.get('/admin/chat-ai/config', auth()); + setForm((prev) => ({ + ...empty, + ...(data?.payload || {}), + translationApiKey: '', + transcriptionApiKey: '', + translationApiKeySet: + !!data?.payload?.translationApiKeySet || prev.translationApiKeySet, + transcriptionApiKeySet: + !!data?.payload?.transcriptionApiKeySet || prev.transcriptionApiKeySet, + })); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + if (open) load(); + }, [open, load]); + + const save = async () => { + setLoading(true); + setMessage(''); + setError(''); + try { + const { data } = await axios.patch( + '/admin/chat-ai/config', + { + translationEnabled: form.translationEnabled, + translationUrl: form.translationUrl, + translationApiKey: form.translationApiKey, + transcriptionEnabled: form.transcriptionEnabled, + transcriptionUrl: form.transcriptionUrl, + transcriptionApiKey: form.transcriptionApiKey, + defaultTargetLanguage: form.defaultTargetLanguage, + }, + auth() + ); + setForm((prev) => ({ + ...prev, + ...(data?.payload || {}), + translationApiKey: '', + transcriptionApiKey: '', + })); + setMessage('Chat AI configuration saved.'); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } finally { + setLoading(false); + } + }; + + if (!hasToken) return null; + + return ( + <> + + + {open && ( +
setOpen(false)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 1000, + display: 'grid', + placeItems: 'center', + padding: 20, + background: 'rgba(15,23,42,.58)', + }} + > +
event.stopPropagation()} + style={{ + width: 'min(620px, 100%)', + maxHeight: '90vh', + overflow: 'auto', + borderRadius: 18, + background: '#fff', + color: '#0f172a', + boxShadow: '0 25px 70px rgba(0,0,0,.28)', + }} + > +
+
+

Chat AI

+

+ DB-backed translation and voice-transcription providers. API keys are encrypted at rest. +

+
+ +
+ +
+
+ + setForm((prev) => ({ ...prev, translationUrl: event.target.value }))} placeholder="Provider endpoint URL" style={{ padding: 10, border: '1px solid #cbd5e1', borderRadius: 10 }} /> + setForm((prev) => ({ ...prev, translationApiKey: event.target.value }))} placeholder={form.translationApiKeySet ? 'API key saved — enter to replace' : 'API key'} style={{ padding: 10, border: '1px solid #cbd5e1', borderRadius: 10 }} /> +
+ +
+ + setForm((prev) => ({ ...prev, transcriptionUrl: event.target.value }))} placeholder="Provider endpoint URL" style={{ padding: 10, border: '1px solid #cbd5e1', borderRadius: 10 }} /> + setForm((prev) => ({ ...prev, transcriptionApiKey: event.target.value }))} placeholder={form.transcriptionApiKeySet ? 'API key saved — enter to replace' : 'API key'} style={{ padding: 10, border: '1px solid #cbd5e1', borderRadius: 10 }} /> +
+ + + + {error &&
{error}
} + {message &&
{message}
} +
+ +
+ + +
+
+
+ )} + + ); +} + +export default ChatAiConfig; diff --git a/frontend/admin/helpers/socket.js b/frontend/admin/helpers/socket.js index 0a7dd2f4..19e018a2 100644 --- a/frontend/admin/helpers/socket.js +++ b/frontend/admin/helpers/socket.js @@ -2,9 +2,15 @@ import { io } from 'socket.io-client'; import config from '../config'; const socket = io(config.socketUrl, { + path: '/socket.io', + transports: ['websocket'], autoConnect: false, + reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1500, + auth(callback) { + callback({ adminToken: localStorage.getItem('admin_token') || '' }); + }, }); export default socket; diff --git a/frontend/admin/index.jsx b/frontend/admin/index.jsx index 21763ea1..3d5a31a5 100644 --- a/frontend/admin/index.jsx +++ b/frontend/admin/index.jsx @@ -1,6 +1,7 @@ import React from 'react'; import * as ReactDOM from 'react-dom/client'; import App from './app'; +import ChatAiConfig from './chatAiConfig'; import { registerServiceWorker } from '../client/pwa/registerSW'; import { installProfilePasswordPanel } from './profilePassword'; @@ -13,7 +14,12 @@ const observer = new MutationObserver(removeLoginPasswordMinLength); observer.observe(document.documentElement, { childList: true, subtree: true }); const root = ReactDOM.createRoot(document.querySelector('#admin-root')); -root.render(); +root.render( + <> + + + +); removeLoginPasswordMinLength(); installProfilePasswordPanel(); registerServiceWorker(); diff --git a/frontend/client/components/chat/GlobalChatTools.jsx b/frontend/client/components/chat/GlobalChatTools.jsx new file mode 100644 index 00000000..eaf82dd8 --- /dev/null +++ b/frontend/client/components/chat/GlobalChatTools.jsx @@ -0,0 +1,590 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import axios from 'axios'; +import * as bi from 'react-icons/bi'; +import { v4 as uuidv4 } from 'uuid'; +import { setChatRoom } from '../../redux/features/room'; +import { setRefreshInbox } from '../../redux/features/chore'; +import { ensureDeviceKey } from '../../helpers/e2eeV2'; +import { + flushChatOutbox, + listOutboxMessages, + retryOutboxMessage, +} from '../../helpers/chatTransportV2'; + +const tabs = [ + ['search', 'Search', bi.BiSearch], + ['requests', 'Requests', bi.BiMessageRoundedDots], + ['mentions', 'Mentions', bi.BiAt], + ['topics', 'Topics', bi.BiConversation], + ['security', 'Security', bi.BiShieldQuarter], + ['outbox', 'Outbox', bi.BiCloudUpload], +]; + +const prettyTime = (value) => { + if (!value) return ''; + try { + return new Date(value).toLocaleString(); + } catch (error0) { + return ''; + } +}; + +function GlobalChatTools() { + const dispatch = useDispatch(); + const chat = useSelector((state) => state.room.chat); + const master = useSelector((state) => state.user.master); + const room = chat?.data || null; + const [open, setOpen] = React.useState(false); + const [tab, setTab] = React.useState('search'); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const [notice, setNotice] = React.useState(''); + + const [searchForm, setSearchForm] = React.useState({ + q: '', + type: 'all', + scope: 'current', + targetLanguage: 'en', + }); + const [searchResults, setSearchResults] = React.useState([]); + const [requests, setRequests] = React.useState([]); + const [mentions, setMentions] = React.useState([]); + const [topics, setTopics] = React.useState([]); + const [topicName, setTopicName] = React.useState(''); + const [selectedTopicId, setSelectedTopicId] = React.useState(''); + const [e2ee, setE2ee] = React.useState({ enabled: false, enabledBy: null, version: 0 }); + const [outbox, setOutbox] = React.useState([]); + const [detail, setDetail] = React.useState(null); + const [uploadState, setUploadState] = React.useState({ + running: false, + progress: 0, + name: '', + }); + + const clearStatus = () => { + setError(''); + setNotice(''); + }; + + const refreshRoom = React.useCallback(() => { + if (!chat?.isOpen || !room) return; + dispatch( + setChatRoom({ + ...chat, + refreshId: uuidv4(), + data: { ...room }, + }) + ); + }, [chat, room, dispatch]); + + const loadSearch = React.useCallback(async () => { + setLoading(true); + clearStatus(); + try { + const { data } = await axios.get('/chat-v2/search', { + params: { + q: searchForm.q, + type: searchForm.type, + roomId: + searchForm.scope === 'current' && room?.roomId ? room.roomId : undefined, + topicId: selectedTopicId || undefined, + limit: 80, + }, + }); + setSearchResults(data?.payload || []); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } finally { + setLoading(false); + } + }, [room?.roomId, searchForm, selectedTopicId]); + + const loadRequests = React.useCallback(async () => { + try { + const { data } = await axios.get('/chat-v2/message-requests'); + setRequests(data?.payload || []); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }, []); + + const loadMentions = React.useCallback(async () => { + try { + const { data } = await axios.get('/chat-v2/mentions', { params: { limit: 100 } }); + setMentions(data?.payload || []); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }, []); + + const loadTopics = React.useCallback(async () => { + if (!room?.roomId || room?.roomType !== 'group') { + setTopics([]); + setSelectedTopicId(''); + return; + } + try { + const { data } = await axios.get(`/chat-v2/topics/${room.roomId}`); + const list = data?.payload || []; + setTopics(list); + const stored = String(localStorage.getItem(`syncchat:topic:${room.roomId}`) || ''); + setSelectedTopicId(list.some((item) => item._id === stored) ? stored : ''); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }, [room?.roomId, room?.roomType]); + + const loadE2ee = React.useCallback(async () => { + if (!room?.roomId || room?.roomType !== 'private') { + setE2ee({ enabled: false, enabledBy: null, version: 0 }); + return; + } + try { + const { data } = await axios.get(`/chat-v2/e2ee/rooms/${room.roomId}`); + setE2ee(data?.payload || { enabled: false, enabledBy: null, version: 0 }); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }, [room?.roomId, room?.roomType]); + + const loadOutbox = React.useCallback(async () => { + const rows = await listOutboxMessages().catch(() => []); + setOutbox(rows.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))); + }, []); + + const loadTab = React.useCallback(async () => { + clearStatus(); + if (tab === 'search') await loadSearch(); + if (tab === 'requests') await loadRequests(); + if (tab === 'mentions') await loadMentions(); + if (tab === 'topics') await loadTopics(); + if (tab === 'security') await loadE2ee(); + if (tab === 'outbox') await loadOutbox(); + }, [tab, loadSearch, loadRequests, loadMentions, loadTopics, loadE2ee, loadOutbox]); + + React.useEffect(() => { + if (!open) return; + loadTab(); + }, [open, loadTab]); + + React.useEffect(() => { + const onKey = (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setOpen((prev) => !prev); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + React.useEffect(() => { + if (!room?.roomId) return; + loadTopics(); + loadE2ee(); + }, [room?.roomId, loadTopics, loadE2ee]); + + const actionRequest = async (requestId, action) => { + clearStatus(); + try { + await axios.post(`/chat-v2/message-requests/${requestId}/action`, { action }); + setRequests((prev) => prev.filter((item) => item._id !== requestId)); + dispatch(setRefreshInbox(uuidv4())); + setNotice(`Request ${action === 'accept' ? 'accepted' : action === 'block' ? 'blocked' : 'deleted'}.`); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const chooseTopic = (topicId) => { + if (!room?.roomId) return; + setSelectedTopicId(topicId || ''); + if (topicId) localStorage.setItem(`syncchat:topic:${room.roomId}`, topicId); + else localStorage.removeItem(`syncchat:topic:${room.roomId}`); + window.dispatchEvent( + new CustomEvent('syncchat:topic-selected', { + detail: { roomId: room.roomId, topicId: topicId || null }, + }) + ); + setNotice(topicId ? 'New messages will be sent to this topic.' : 'Showing all messages.'); + refreshRoom(); + }; + + const createTopic = async () => { + const name = String(topicName || '').trim(); + if (!name || !room?.roomId) return; + clearStatus(); + try { + const { data } = await axios.post(`/chat-v2/topics/${room.roomId}`, { name }); + setTopicName(''); + await loadTopics(); + if (data?.payload?._id) chooseTopic(data.payload._id); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const toggleE2ee = async () => { + if (!room?.roomId || room?.roomType !== 'private') return; + clearStatus(); + setLoading(true); + try { + if (!e2ee.enabled) await ensureDeviceKey({ forceRegister: true }); + const { data } = await axios.post(`/chat-v2/e2ee/rooms/${room.roomId}`, { + enabled: !e2ee.enabled, + }); + const next = data?.payload || { enabled: !e2ee.enabled }; + setE2ee(next); + dispatch( + setChatRoom({ + ...chat, + refreshId: uuidv4(), + data: { + ...room, + e2eeEnabled: !!next.enabled, + e2eeEnabledBy: next.enabledBy || null, + e2eeVersion: Number(next.version || 0), + }, + }) + ); + setNotice(next.enabled ? 'Device E2EE enabled for new text messages.' : 'Device E2EE disabled.'); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } finally { + setLoading(false); + } + }; + + const showReceipts = async (chatId) => { + try { + const { data } = await axios.get(`/chat-v2/messages/${chatId}/receipts`); + setDetail({ title: 'Message receipts', payload: data?.payload || [] }); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const showHistory = async (chatId) => { + try { + const { data } = await axios.get(`/chat-v2/messages/${chatId}/history`); + setDetail({ title: 'Edit history', payload: data?.payload || {} }); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const translate = async (item) => { + try { + const { data } = await axios.post('/chat-v2/translate', { + chatId: item._id, + targetLanguage: searchForm.targetLanguage, + }); + setDetail({ title: `Translation (${searchForm.targetLanguage})`, payload: data?.payload || {} }); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const transcribe = async (item) => { + try { + const { data } = await axios.post('/chat-v2/transcribe', { chatId: item._id }); + setDetail({ title: 'Voice transcription', payload: data?.payload || {} }); + await loadSearch(); + } catch (error0) { + setError(error0?.response?.data?.message || error0.message); + } + }; + + const resumableUpload = async (file) => { + if (!file || !room?.roomId) return; + clearStatus(); + setUploadState({ running: true, progress: 0, name: file.name }); + const chunkSize = 1024 * 1024; + try { + const init = await axios.post('/chat-v2/uploads', { + filename: file.name, + mime: file.type || 'application/octet-stream', + totalSize: file.size, + chunkSize, + }); + const uploadId = init.data?.payload?.uploadId; + const totalParts = Math.ceil(file.size / chunkSize); + for (let index = 0; index < totalParts; index += 1) { + const chunk = file.slice(index * chunkSize, Math.min(file.size, (index + 1) * chunkSize)); + const bytes = await chunk.arrayBuffer(); + let attempt = 0; + let sent = false; + while (!sent && attempt < 3) { + attempt += 1; + try { + // eslint-disable-next-line no-await-in-loop + await axios.put(`/chat-v2/uploads/${uploadId}/parts/${index}`, bytes, { + headers: { 'Content-Type': 'application/octet-stream' }, + }); + sent = true; + } catch (error0) { + if (attempt >= 3) throw error0; + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, attempt * 600)); + } + } + setUploadState({ + running: true, + progress: Math.round(((index + 1) / totalParts) * 100), + name: file.name, + }); + } + const complete = await axios.post(`/chat-v2/uploads/${uploadId}/complete`); + const uploaded = complete.data?.payload; + if (!uploaded?.url) throw new Error('Upload completed without a file URL'); + + await axios.post('/chats/send-file', { + roomId: room.roomId, + ownersId: room.ownersId || [], + roomType: room.roomType, + text: '', + replyTo: null, + file: uploaded, + }); + setUploadState({ running: false, progress: 100, name: file.name }); + setNotice('Large file uploaded and sent.'); + refreshRoom(); + } catch (error0) { + setUploadState((prev) => ({ ...prev, running: false })); + setError(error0?.response?.data?.message || error0.message); + } + }; + + const buttonClass = (active = false) => + `flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${ + active + ? 'bg-sky-600 text-white' + : 'hover:bg-slate-100 dark:hover:bg-spill-700' + }`; + + return ( + <> + + + {open && ( +
setOpen(false)} aria-hidden> +
event.stopPropagation()} + aria-hidden + > +
+
+

Chat Tools

+

+ {room?.roomId ? room?.profile?.fullname || room?.channel?.name || room?.group?.name || 'Current chat' : 'Global chat tools'} +

+
+ +
+ +
+ {tabs.map(([id, label, Icon]) => ( + + ))} +
+ +
+ {error &&
{error}
} + {notice &&
{notice}
} + + {tab === 'search' && ( +
+
+ setSearchForm((prev) => ({ ...prev, q: event.target.value }))} onKeyDown={(event) => { if (event.key === 'Enter') loadSearch(); }} placeholder="Search messages…" className="rounded-xl border border-slate-300 bg-transparent px-3 py-2 dark:border-spill-600" /> + + + +
+
+ AI target: + setSearchForm((prev) => ({ ...prev, targetLanguage: event.target.value.slice(0, 16) }))} className="w-20 rounded border border-slate-300 bg-transparent px-2 py-1 dark:border-spill-600" /> +
+ {loading &&

Searching…

} +
+ {searchResults.map((item) => ( +
+
+
+

{item.profile?.fullname || item.userId} · {prettyTime(item.createdAt)}

+

{item.text || item.transcript || item.file?.originalname || '[attachment]'}

+ {item.transcript &&

Transcript: {item.transcript}

} +
+ #{item.sequence || 0} +
+
+ + {item.isEdited && } + {!!item.text && !item.e2eeEnvelope && } + {item.file?.type === 'audio' && !item.e2eeEnvelope && } +
+
+ ))} +
+
+ )} + + {tab === 'requests' && ( +
+ {requests.length === 0 &&

No pending message requests.

} + {requests.map((item) => ( +
+

{item.profile?.fullname || item.profile?.username || 'Unknown user'}

+

{item.preview || 'New message request'}

+

{prettyTime(item.lastMessageAt)}

+
+ + + +
+
+ ))} +
+ )} + + {tab === 'mentions' && ( +
+ {mentions.length === 0 &&

No recent mentions.

} + {mentions.map((item) => ( +
+

{prettyTime(item.createdAt)}

+

{item.text || '[attachment]'}

+
+ ))} +
+ )} + + {tab === 'topics' && ( +
+ {room?.roomType !== 'group' ? ( +

Open a group or channel to manage topics.

+ ) : ( + <> +
+ setTopicName(event.target.value)} placeholder="New topic name" className="flex-1 rounded-xl border border-slate-300 bg-transparent px-3 py-2 dark:border-spill-600" /> + +
+ + {topics.map((item) => ( + + ))} + + )} +
+ )} + + {tab === 'security' && ( +
+ {room?.roomType !== 'private' ? ( +

Open a private chat to manage device E2EE.

+ ) : ( + <> +
+
+
+

Device end-to-end encryption

+

{e2ee.enabled ? 'Enabled for new text messages' : 'Disabled'}

+
+ +
+
+
+ This mode uses browser-held device keys and server-blind ECDH/HKDF/AES-GCM encryption for message text. It is not the Signal Double Ratchet protocol and does not yet provide Signal-style forward secrecy for media attachments. +
+ + )} +
+ )} + + {tab === 'outbox' && ( +
+
+
+

Offline send queue

+

Queued messages retry after reconnect without changing clientMessageId.

+
+ +
+ {outbox.length === 0 &&

Outbox is empty.

} + {outbox.map((item) => ( +
+
+
+

{item.payload?.text || '[attachment/message]'}

+

{item.status} · attempts {item.attempts || 0}

+ {item.error &&

{item.error}

} +
+ {item.status === 'failed' && } +
+
+ ))} + + {room?.roomId && ( +
+ +
+ Signed in as {master?.fullname || master?.username || 'user'} + Ctrl/⌘ + K +
+
+
+ )} + + ); +} + +export default GlobalChatTools; diff --git a/frontend/client/helpers/chatDraftV2.js b/frontend/client/helpers/chatDraftV2.js new file mode 100644 index 00000000..07aba2e3 --- /dev/null +++ b/frontend/client/helpers/chatDraftV2.js @@ -0,0 +1,168 @@ +import axios from 'axios'; +import socket from './socket'; +import store from '../redux/store'; +import { setReplyingChat } from '../redux/features/chore'; + +let installed = false; +let currentRoomId = null; +let saveTimer = null; +let observer = null; +let attachedInput = null; +let inputHandler = null; +let restoredRoomId = null; + +const getRoom = () => store.getState()?.room?.chat?.data || null; +const getReply = () => store.getState()?.chore?.replyingChat || null; + +const findComposer = () => { + const nodes = [ + ...document.querySelectorAll( + 'textarea[name="text"], input[name="text"], textarea[data-chat-composer], input[data-chat-composer]' + ), + ]; + return ( + nodes.reverse().find((node) => { + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && !node.disabled; + }) || null + ); +}; + +const setNativeValue = (element, value) => { + if (!element) return; + const prototype = element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + if (descriptor?.set) descriptor.set.call(element, value); + else element.value = value; + element.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const clearTimer = () => { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = null; +}; + +const saveDraftNow = async () => { + clearTimer(); + const room = getRoom(); + if (!room?.roomId || room.roomId !== currentRoomId) return; + const input = findComposer(); + const text = String(input?.value || ''); + const replyingChat = getReply(); + const topicId = + String(localStorage.getItem(`syncchat:topic:${room.roomId}`) || '').trim() || null; + + try { + if (!text.trim() && !replyingChat && !topicId) { + await axios.delete(`/chat-v2/drafts/${room.roomId}`); + return; + } + await axios.put(`/chat-v2/drafts/${room.roomId}`, { + text, + replyTo: replyingChat?._id || null, + topicId, + meta: { + replyingChat: replyingChat || null, + }, + }); + } catch (error0) { + // Drafts also remain in the DOM while offline; the next input/room sync retries. + } +}; + +const scheduleSave = () => { + clearTimer(); + saveTimer = setTimeout(() => { + saveDraftNow().catch(() => {}); + }, 650); +}; + +const attachComposer = () => { + const next = findComposer(); + if (next === attachedInput) return; + if (attachedInput && inputHandler) { + attachedInput.removeEventListener('input', inputHandler); + } + attachedInput = next; + if (!attachedInput) return; + inputHandler = () => scheduleSave(); + attachedInput.addEventListener('input', inputHandler); +}; + +const restoreDraft = async (roomId) => { + if (!roomId || restoredRoomId === roomId) return; + restoredRoomId = roomId; + try { + const { data } = await axios.get(`/chat-v2/drafts/${roomId}`); + const draft = data?.payload; + if (!draft) return; + + const apply = () => { + attachComposer(); + if (!attachedInput) return false; + if (!String(attachedInput.value || '') && draft.text) { + setNativeValue(attachedInput, draft.text); + } + if (draft?.meta?.replyingChat) { + store.dispatch(setReplyingChat(draft.meta.replyingChat)); + } + if (draft.topicId) { + localStorage.setItem(`syncchat:topic:${roomId}`, draft.topicId); + window.dispatchEvent( + new CustomEvent('syncchat:topic-selected', { + detail: { roomId, topicId: draft.topicId }, + }) + ); + } + return true; + }; + + if (!apply()) { + let attempts = 0; + const timer = setInterval(() => { + attempts += 1; + if (apply() || attempts >= 20) clearInterval(timer); + }, 150); + } + } catch (error0) { + // No draft or offline. Composer continues normally. + } +}; + +const onStoreChange = () => { + const room = getRoom(); + const roomId = room?.roomId || null; + if (roomId === currentRoomId) { + attachComposer(); + return; + } + + if (currentRoomId) saveDraftNow().catch(() => {}); + currentRoomId = roomId; + restoredRoomId = null; + attachComposer(); + if (roomId) restoreDraft(roomId).catch(() => {}); +}; + +const installChatDraftV2 = () => { + if (installed) return; + installed = true; + + store.subscribe(onStoreChange); + observer = new MutationObserver(() => attachComposer()); + observer.observe(document.body, { childList: true, subtree: true }); + onStoreChange(); + + socket.on('chat/ack', (payload = {}) => { + if (!payload.accepted || !payload.roomId) return; + axios.delete(`/chat-v2/drafts/${payload.roomId}`).catch(() => {}); + }); + + window.addEventListener('beforeunload', () => { + if (saveTimer) clearTimeout(saveTimer); + }); +}; + +export default installChatDraftV2; diff --git a/frontend/client/helpers/chatHttpReliability.js b/frontend/client/helpers/chatHttpReliability.js new file mode 100644 index 00000000..e4d4bd62 --- /dev/null +++ b/frontend/client/helpers/chatHttpReliability.js @@ -0,0 +1,86 @@ +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; + +let installed = false; + +const isSendFileRequest = (config = {}) => + String(config.method || '').toLowerCase() === 'post' && + String(config.url || '').includes('/chats/send-file'); + +const activeTopicFor = (roomId) => + String(localStorage.getItem(`syncchat:topic:${roomId}`) || '').trim() || null; + +const parseData = (value) => { + if (value && typeof value === 'object') return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object') return parsed; + } catch (error0) { + // Ignore non-JSON request bodies. + } + } + return {}; +}; + +const getHeader = (headers, name) => { + if (!headers) return ''; + if (typeof headers.get === 'function') return headers.get(name) || ''; + const key = Object.keys(headers).find( + (item) => String(item).toLowerCase() === String(name).toLowerCase() + ); + return key ? headers[key] : ''; +}; + +const installChatHttpReliability = () => { + if (installed) return; + installed = true; + + axios.interceptors.request.use((config) => { + if (!isSendFileRequest(config)) return config; + const data = parseData(config.data); + const existingHeaderId = String( + getHeader(config.headers, 'X-Client-Message-Id') || '' + ).trim(); + const clientMessageId = + existingHeaderId || + String(data.clientMessageId || '').trim() || + (crypto.randomUUID ? crypto.randomUUID() : uuidv4()); + const roomId = String(data.roomId || '').trim(); + + return { + ...config, + headers: { + ...(config.headers || {}), + 'X-Client-Message-Id': clientMessageId, + }, + data: { + ...data, + clientMessageId, + topicId: data.topicId || (roomId ? activeTopicFor(roomId) : null), + }, + }; + }); + + axios.interceptors.response.use( + (response0) => response0, + async (error0) => { + const config = error0?.config; + if (!config || !isSendFileRequest(config)) return Promise.reject(error0); + + // Retry only transport failures/timeouts, never application validation errors. + if (error0.response) return Promise.reject(error0); + const count = Number(config.__syncchatMediaRetryCount || 0); + if (count >= 2) return Promise.reject(error0); + + const nextConfig = { + ...config, + __syncchatMediaRetryCount: count + 1, + }; + await new Promise((resolve) => setTimeout(resolve, 600 * (count + 1))); + return axios(nextConfig); + } + ); +}; + +export default installChatHttpReliability; diff --git a/frontend/client/helpers/chatTransportV2.js b/frontend/client/helpers/chatTransportV2.js new file mode 100644 index 00000000..b950a6ed --- /dev/null +++ b/frontend/client/helpers/chatTransportV2.js @@ -0,0 +1,613 @@ +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import socket from './socket'; +import store from '../redux/store'; +import { setChatRoom } from '../redux/features/room'; +import { setRefreshInbox } from '../redux/features/chore'; +import { + decryptEnvelope, + encryptTextForRoom, + ensureDeviceKey, +} from './e2eeV2'; + +const DB_NAME = 'syncchat-chat-v2'; +const DB_VERSION = 2; +const OUTBOX = 'outbox'; +const ROOM_STATE = 'roomState'; +const CACHE = 'messageCache'; +const LOCAL_KEYS = 'localKeys'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +let installed = false; +let rawEmit = null; +let interceptorId = null; +let flushing = false; + +const toBase64 = (value) => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +const fromBase64 = (value = '') => { + const binary = atob(String(value || '')); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const openDb = () => + new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(OUTBOX)) { + db.createObjectStore(OUTBOX, { keyPath: 'clientMessageId' }); + } + if (!db.objectStoreNames.contains(ROOM_STATE)) { + db.createObjectStore(ROOM_STATE, { keyPath: 'roomId' }); + } + if (!db.objectStoreNames.contains(CACHE)) { + const store0 = db.createObjectStore(CACHE, { keyPath: 'cacheId' }); + store0.createIndex('roomId', 'roomId', { unique: false }); + } + if (!db.objectStoreNames.contains(LOCAL_KEYS)) { + db.createObjectStore(LOCAL_KEYS, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + +const idbPut = async (storeName, value) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).put(value); + tx.oncomplete = () => resolve(value); + tx.onerror = () => reject(tx.error); + }); +}; + +const idbDelete = async (storeName, key) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}; + +const idbGet = async (storeName, key) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const request = tx.objectStore(storeName).get(key); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); +}; + +const idbGetAll = async (storeName) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const request = tx.objectStore(storeName).getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); +}; + +const idbGetRoomCache = async (roomId) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(CACHE, 'readonly'); + const store0 = tx.objectStore(CACHE); + const index = store0.index('roomId'); + const request = index.getAll(roomId); + request.onsuccess = () => + resolve( + (request.result || []) + .map((item) => item.chat) + .filter(Boolean) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + ); + request.onerror = () => reject(request.error); + }); +}; + +const getLocalOutboxKey = async () => { + const existing = await idbGet(LOCAL_KEYS, 'e2ee-outbox'); + if (existing?.key) return existing.key; + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + await idbPut(LOCAL_KEYS, { + id: 'e2ee-outbox', + key, + createdAt: new Date().toISOString(), + }); + return key; +}; + +const sealLocalPayload = async (payload) => { + const key = await getLocalOutboxKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(JSON.stringify(payload || {})) + ); + return { + __sealedE2eeOutbox: true, + iv: toBase64(iv), + ciphertext: toBase64(ciphertext), + }; +}; + +const unsealLocalPayload = async (payload) => { + if (!payload?.__sealedE2eeOutbox) return payload || {}; + const key = await getLocalOutboxKey(); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64(payload.iv) }, + key, + fromBase64(payload.ciphertext) + ); + return JSON.parse(decoder.decode(plaintext)); +}; + +const activeRoom = () => store.getState()?.room?.chat?.data || null; +const activeUserId = () => String(store.getState()?.user?.master?._id || ''); + +const topicFor = (roomId) => + String(localStorage.getItem(`syncchat:topic:${roomId}`) || '').trim() || null; + +const isCurrentRoomE2ee = (roomId) => { + const room = activeRoom(); + return !!( + room?.roomId === roomId && + room?.roomType === 'private' && + room?.e2eeEnabled + ); +}; + +const saveRoomSequence = async (roomId, sequence) => { + if (!roomId) return; + const previous = await idbGet(ROOM_STATE, roomId).catch(() => null); + const nextSequence = Math.max(Number(previous?.sequence || 0), Number(sequence || 0)); + await idbPut(ROOM_STATE, { + roomId, + sequence: nextSequence, + updatedAt: new Date().toISOString(), + }).catch(() => {}); +}; + +const isSensitiveEphemeralMessage = (chat) => + !!( + chat?.secret || + chat?.encryptedText || + chat?.encryptionSessionId || + chat?.expiresAt || + chat?.isSecretSystemMessage || + chat?.viewOnce || + (chat?.viewOnceType && chat.viewOnceType !== 'none') + ); + +const canCacheRawChat = (chat) => { + if (!chat?._id || !chat?.roomId) return false; + if (isSensitiveEphemeralMessage(chat)) return false; + if (chat?.e2eeDecrypted) return false; + if (chat?.e2eeEnvelope) return true; + if (String(chat?.text || '') === 'Encrypted message') return false; + return true; +}; + +const cacheRawMessage = async (chat) => { + if (!canCacheRawChat(chat)) return; + await idbPut(CACHE, { + cacheId: `${chat.roomId}:${chat._id}`, + roomId: chat.roomId, + chat, + updatedAt: new Date().toISOString(), + }).catch(() => {}); +}; + +const cacheRawPayload = async (value, seen = new Set()) => { + if (!value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + await Promise.all(value.map((item) => cacheRawPayload(item, seen))); + return; + } + if (value._id && value.roomId && ('text' in value || value.fileId || value.file)) { + await cacheRawMessage(value); + } + await Promise.all( + Object.values(value) + .filter((item) => item && typeof item === 'object') + .map((item) => cacheRawPayload(item, seen)) + ); +}; + +const decryptChatObject = async (value) => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return Promise.all(value.map((item) => decryptChatObject(item))); + + let next = value; + if (value.e2eeEnvelope && value.roomId) { + try { + const text = await decryptEnvelope({ + envelope: value.e2eeEnvelope, + roomId: value.roomId, + }); + if (text !== null) next = { ...value, text, e2eeDecrypted: true }; + } catch (error0) { + next = { ...value, text: 'Encrypted message', e2eeDecryptionError: true }; + } + } + + const output = { ...next }; + for (const key of Object.keys(output)) { + if (key === 'e2eeEnvelope') continue; + const item = output[key]; + if (item && typeof item === 'object') { + // eslint-disable-next-line no-await-in-loop + output[key] = await decryptChatObject(item); + } + } + return output; +}; + +const refreshActiveRoom = () => { + const state = store.getState(); + const chat = state?.room?.chat; + if (!chat?.isOpen || !chat?.data) return; + store.dispatch( + setChatRoom({ ...chat, refreshId: uuidv4(), data: { ...chat.data } }) + ); +}; + +const prepareOutgoing = async (source) => { + const payload = { ...(source || {}) }; + payload.clientMessageId = + String(payload.clientMessageId || '').trim() || + (crypto.randomUUID ? crypto.randomUUID() : uuidv4()); + payload.topicId = payload.topicId || topicFor(payload.roomId); + + if ( + isCurrentRoomE2ee(payload.roomId) && + String(payload.text || '').length > 0 && + !payload.e2eeEnvelope + ) { + const room = activeRoom(); + const owners = Array.isArray(room?.ownersId) + ? room.ownersId + : Array.isArray(payload.ownersId) + ? payload.ownersId + : []; + payload.e2eeEnvelope = await encryptTextForRoom({ + text: payload.text, + roomId: payload.roomId, + userIds: owners, + }); + payload.text = 'Encrypted message'; + } + return payload; +}; + +const sendOutboxItem = async (item) => { + if (!socket.connected || !navigator.onLine || !rawEmit) return false; + try { + const sourcePayload = await unsealLocalPayload(item.payload); + const payload = await prepareOutgoing(sourcePayload); + await idbPut(OUTBOX, { + ...item, + payload, + encryptedForTransport: !!payload.e2eeEnvelope, + status: 'sending', + attempts: Number(item.attempts || 0) + 1, + lastAttemptAt: new Date().toISOString(), + }); + rawEmit('chat/insert', payload); + return true; + } catch (error0) { + await idbPut(OUTBOX, { + ...item, + status: 'failed', + retry: false, + error: error0.message, + updatedAt: new Date().toISOString(), + }); + window.dispatchEvent( + new CustomEvent('syncchat:outbox-failed', { + detail: { + clientMessageId: item.clientMessageId, + roomId: item.roomId || null, + message: error0.message, + }, + }) + ); + return false; + } +}; + +export const flushChatOutbox = async () => { + if (flushing || !socket.connected || !navigator.onLine) return; + flushing = true; + try { + const rows = (await idbGetAll(OUTBOX)) + .filter((item) => item.status !== 'failed' || item.retry === true) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + for (const row of rows) { + // Preserve message order while reconnecting. + // eslint-disable-next-line no-await-in-loop + await sendOutboxItem(row); + } + } finally { + flushing = false; + } +}; + +export const retryOutboxMessage = async (clientMessageId) => { + const item = await idbGet(OUTBOX, clientMessageId); + if (!item) return false; + await idbPut(OUTBOX, { + ...item, + status: 'queued', + retry: true, + error: '', + updatedAt: new Date().toISOString(), + }); + await flushChatOutbox(); + return true; +}; + +export const listOutboxMessages = async () => { + const rows = await idbGetAll(OUTBOX); + return rows.map((item) => ({ + ...item, + payload: item.payload?.__sealedE2eeOutbox + ? { text: '[Encrypted queued message]' } + : item.payload, + })); +}; + +export const readOfflineRoomMessages = async (roomId) => + decryptChatObject(await idbGetRoomCache(roomId)); + +const requestCatchUp = async () => { + const room = activeRoom(); + if (!room?.roomId || !socket.connected || !rawEmit) return; + const state = await idbGet(ROOM_STATE, room.roomId).catch(() => null); + rawEmit( + 'chat/sync-request', + { + roomId: room.roomId, + afterSequence: Number(state?.sequence || 0), + limit: 200, + }, + (result) => { + if (!result?.success) return; + saveRoomSequence(room.roomId, result.lastSequence || 0); + Promise.all((result.messages || []).map((chat) => cacheRawMessage(chat))).catch(() => {}); + if ((result.messages || []).length) refreshActiveRoom(); + } + ); +}; + +const sendReceipt = (chat, type = 'delivered') => { + if (!chat?._id || !chat?.roomId || chat.userId === activeUserId() || !rawEmit) return; + rawEmit('chat/receipt', { chatId: chat._id, roomId: chat.roomId, type }); +}; + +const installResponseDecryption = () => { + if (interceptorId !== null) return; + interceptorId = axios.interceptors.response.use( + async (response0) => { + try { + if (response0?.data?.payload) { + const rawPayload = response0.data.payload; + await cacheRawPayload(rawPayload); + // eslint-disable-next-line no-param-reassign + response0.data.payload = await decryptChatObject(rawPayload); + } + } catch (error0) { + // Keep the HTTP response usable if optional cache/decryption is unavailable. + } + return response0; + }, + async (error0) => { + const method = String(error0?.config?.method || '').toLowerCase(); + const url = String(error0?.config?.url || ''); + const match = url.match(/^\/chats\/([^/?]+)(?:\?|$)/); + const reserved = new Set([ + 'media', + 'calls', + 'starred', + 'scheduled', + 'upload', + 'send-file', + ]); + if ( + !error0?.response && + method === 'get' && + match && + !reserved.has(match[1]) && + window.indexedDB + ) { + const cachedRaw = await idbGetRoomCache(match[1]).catch(() => []); + const cached = await decryptChatObject(cachedRaw).catch(() => cachedRaw); + if (cached.length) { + return { + data: { + success: true, + payload: cached, + offline: true, + message: `${cached.length} cached messages`, + }, + status: 200, + statusText: 'Offline Cache', + headers: {}, + config: error0.config, + request: error0.request, + }; + } + } + return Promise.reject(error0); + } + ); +}; + +const queueOutgoing = async (source) => { + const clientMessageId = + String(source.clientMessageId || '').trim() || + (crypto.randomUUID ? crypto.randomUUID() : uuidv4()); + const payload = { + ...source, + clientMessageId, + topicId: source.topicId || topicFor(source.roomId), + }; + const isE2ee = isCurrentRoomE2ee(payload.roomId); + const storedPayload = isE2ee ? await sealLocalPayload(payload) : payload; + + await idbPut(OUTBOX, { + clientMessageId, + payload: storedPayload, + roomId: payload.roomId || null, + isE2ee, + status: 'queued', + retry: false, + attempts: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await flushChatOutbox(); +}; + +const installReliableEmit = () => { + if (socket.__syncchatV2EmitWrapped) return; + socket.__syncchatV2EmitWrapped = true; + rawEmit = socket.emit.bind(socket); + // eslint-disable-next-line no-param-reassign + socket.emit = (event, ...args) => { + if (event !== 'chat/insert') return rawEmit(event, ...args); + + const source = args[0] && typeof args[0] === 'object' ? { ...args[0] } : {}; + queueOutgoing(source).catch((error0) => { + window.dispatchEvent( + new CustomEvent('syncchat:outbox-failed', { + detail: { + roomId: source.roomId || null, + message: error0.message, + }, + }) + ); + }); + return socket; + }; +}; + +const installSocketListeners = () => { + socket.on('connect', () => { + ensureDeviceKey().catch(() => {}); + flushChatOutbox().catch(() => {}); + requestCatchUp().catch(() => {}); + }); + + socket.on('chat/ack', async (payload = {}) => { + if (!payload.clientMessageId) return; + if (payload.accepted) { + await idbDelete(OUTBOX, payload.clientMessageId).catch(() => {}); + await saveRoomSequence(payload.roomId, payload.sequence || 0); + window.dispatchEvent( + new CustomEvent('syncchat:outbox-sent', { detail: payload }) + ); + } else { + const current = await idbGet(OUTBOX, payload.clientMessageId).catch(() => null); + if (current) { + await idbPut(OUTBOX, { + ...current, + status: 'failed', + retry: false, + error: payload.message || payload.code || 'Send failed', + updatedAt: new Date().toISOString(), + }).catch(() => {}); + } + window.dispatchEvent( + new CustomEvent('syncchat:outbox-failed', { detail: payload }) + ); + } + }); + + socket.on('chat/meta', async (payload = {}) => { + await saveRoomSequence(payload.roomId, payload.sequence || 0); + if (payload.e2eeEnvelope && activeRoom()?.roomId === payload.roomId) { + refreshActiveRoom(); + } + }); + + socket.on('chat/insert', async (chat) => { + if (!chat?._id) return; + await cacheRawMessage(chat); + sendReceipt(chat, 'delivered'); + if ( + document.visibilityState === 'visible' && + activeRoom()?.roomId === chat.roomId + ) { + sendReceipt(chat, 'read'); + } + }); + + socket.on('chat/sync-result', async (result = {}) => { + if (!result.success) return; + await saveRoomSequence(result.roomId, result.lastSequence || 0); + await Promise.all((result.messages || []).map((chat) => cacheRawMessage(chat))); + if ( + (result.messages || []).length && + activeRoom()?.roomId === result.roomId + ) { + refreshActiveRoom(); + } + }); + + socket.on('message-request/new', () => { + store.dispatch(setRefreshInbox(uuidv4())); + }); + socket.on('message-request/updated', () => { + store.dispatch(setRefreshInbox(uuidv4())); + }); + socket.on('chat/mention', (payload) => { + window.dispatchEvent(new CustomEvent('syncchat:mention', { detail: payload })); + }); + + window.addEventListener('online', () => { + flushChatOutbox().catch(() => {}); + }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + requestCatchUp().catch(() => {}); + } + }); +}; + +const installChatTransportV2 = () => { + if (installed) return; + installed = true; + if (!window.indexedDB) return; + installResponseDecryption(); + installReliableEmit(); + installSocketListeners(); + ensureDeviceKey().catch(() => {}); +}; + +export default installChatTransportV2; diff --git a/frontend/client/helpers/e2eeDirectoryRequestV2.js b/frontend/client/helpers/e2eeDirectoryRequestV2.js new file mode 100644 index 00000000..e02aead9 --- /dev/null +++ b/frontend/client/helpers/e2eeDirectoryRequestV2.js @@ -0,0 +1,28 @@ +import axios from 'axios'; +import store from '../redux/store'; + +let installed = false; + +const installE2eeDirectoryRequestV2 = () => { + if (installed) return; + installed = true; + + axios.interceptors.request.use((config) => { + const method = String(config.method || 'get').toLowerCase(); + const url = String(config.url || ''); + if (method !== 'get' || !url.includes('/chat-v2/e2ee/keys')) return config; + + const roomId = String( + config.params?.roomId || store.getState()?.room?.chat?.data?.roomId || '' + ).trim(); + return { + ...config, + params: { + ...(config.params || {}), + roomId, + }, + }; + }); +}; + +export default installE2eeDirectoryRequestV2; diff --git a/frontend/client/helpers/e2eeRoomSyncV2.js b/frontend/client/helpers/e2eeRoomSyncV2.js new file mode 100644 index 00000000..af6e295e --- /dev/null +++ b/frontend/client/helpers/e2eeRoomSyncV2.js @@ -0,0 +1,44 @@ +import { v4 as uuidv4 } from 'uuid'; +import socket from './socket'; +import store from '../redux/store'; +import { setChatRoom } from '../redux/features/room'; +import { setRefreshInbox } from '../redux/features/chore'; + +let installed = false; + +const installE2eeRoomSyncV2 = () => { + if (installed) return; + installed = true; + + socket.on('e2ee/room', (payload = {}) => { + if (!payload?.roomId) return; + const state = store.getState(); + const chat = state?.room?.chat; + if (chat?.isOpen && chat?.data?.roomId === payload.roomId) { + store.dispatch( + setChatRoom({ + ...chat, + refreshId: uuidv4(), + data: { + ...chat.data, + e2eeEnabled: !!payload.enabled, + e2eeEnabledBy: payload.enabledBy || null, + e2eeVersion: Number(payload.version || 0), + }, + }) + ); + } + store.dispatch(setRefreshInbox(uuidv4())); + window.dispatchEvent( + new CustomEvent('syncchat:e2ee-room-changed', { detail: payload }) + ); + }); + + socket.on('e2ee/key-changed', (payload = {}) => { + window.dispatchEvent( + new CustomEvent('syncchat:e2ee-key-changed', { detail: payload }) + ); + }); +}; + +export default installE2eeRoomSyncV2; diff --git a/frontend/client/helpers/e2eeV2.js b/frontend/client/helpers/e2eeV2.js new file mode 100644 index 00000000..57215e50 --- /dev/null +++ b/frontend/client/helpers/e2eeV2.js @@ -0,0 +1,297 @@ +import axios from 'axios'; + +const DB_NAME = 'syncchat-e2ee-v1'; +const STORE_NAME = 'deviceKeys'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const toBase64 = (input) => { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +const fromBase64 = (value = '') => { + const binary = atob(String(value || '')); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const decodeJwt = () => { + try { + const token = localStorage.getItem('token') || ''; + const payload = token.split('.')[1]; + if (!payload) return {}; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return JSON.parse(atob(padded)); + } catch (error0) { + return {}; + } +}; + +export const getCurrentIdentity = () => { + const payload = decodeJwt(); + return { + userId: String(payload._id || payload.id || payload.userId || ''), + sessionId: String(payload.sid || ''), + }; +}; + +const openDb = () => + new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'sessionId' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + +const dbGet = async (sessionId) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).get(sessionId); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); +}; + +const dbPut = async (value) => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(value); + tx.oncomplete = () => resolve(value); + tx.onerror = () => reject(tx.error); + }); +}; + +const stableJwk = (jwk = {}) => + JSON.stringify({ + crv: jwk.crv || '', + kty: jwk.kty || '', + x: jwk.x || '', + y: jwk.y || '', + }); + +const fingerprintFor = async (publicJwk) => { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(stableJwk(publicJwk))); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +}; + +export const ensureDeviceKey = async ({ forceRegister = false } = {}) => { + if (!window.crypto?.subtle || !window.indexedDB) { + throw new Error('This browser does not support device E2EE'); + } + + const identity = getCurrentIdentity(); + if (!identity.userId || !identity.sessionId) { + throw new Error('A linked device session is required for E2EE'); + } + + let record = await dbGet(identity.sessionId); + if (!record?.privateKey || !record?.publicKey) { + const pair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey); + record = { + sessionId: identity.sessionId, + userId: identity.userId, + privateKey: pair.privateKey, + publicKey: pair.publicKey, + publicJwk, + fingerprint: await fingerprintFor(publicJwk), + createdAt: new Date().toISOString(), + registeredAt: null, + }; + await dbPut(record); + } + + if (forceRegister || !record.registeredAt) { + await axios.put('/chat-v2/e2ee/device-key', { + publicJwk: record.publicJwk, + fingerprint: record.fingerprint, + }); + record = { + ...record, + registeredAt: new Date().toISOString(), + }; + await dbPut(record); + } + + return record; +}; + +const importPeerPublicKey = (jwk) => + crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + +const deriveWrapKey = async ({ privateKey, publicKey, salt, info }) => { + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: publicKey }, + privateKey, + 256 + ); + const hkdfMaterial = await crypto.subtle.importKey( + 'raw', + sharedBits, + 'HKDF', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt, + info: encoder.encode(info), + }, + hkdfMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +}; + +export const encryptTextForRoom = async ({ text, roomId, userIds = [] }) => { + const source = String(text || ''); + if (!source) return null; + const current = await ensureDeviceKey(); + const ids = [...new Set(userIds.filter(Boolean))]; + if (!ids.includes(current.userId)) ids.push(current.userId); + + const { data } = await axios.get('/chat-v2/e2ee/keys', { + params: { userIds: ids.join(',') }, + }); + const peerKeys = Array.isArray(data?.payload) ? data.payload : []; + const usersWithKeys = new Set(peerKeys.map((item) => String(item.userId || ''))); + const missing = ids.filter((id) => !usersWithKeys.has(String(id))); + if (missing.length) { + const error = new Error('Every participant must register an E2EE device before this message can be sent'); + error.code = 'E2EE_MISSING_DEVICE_KEYS'; + error.missingUserIds = missing; + throw error; + } + + const contentKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const rawContentKey = await crypto.subtle.exportKey('raw', contentKey); + const messageIv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: messageIv }, + contentKey, + encoder.encode(source) + ); + + const devices = []; + for (const peer of peerKeys) { + // One ephemeral key per target device prevents reuse across device envelopes. + // eslint-disable-next-line no-await-in-loop + const ephemeral = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + // eslint-disable-next-line no-await-in-loop + const peerPublic = await importPeerPublicKey(peer.publicJwk); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const wrapIv = crypto.getRandomValues(new Uint8Array(12)); + const info = `syncchat-e2ee-v1:${roomId}:${peer.userId}:${peer.sessionId}`; + // eslint-disable-next-line no-await-in-loop + const wrapKey = await deriveWrapKey({ + privateKey: ephemeral.privateKey, + publicKey: peerPublic, + salt, + info, + }); + // eslint-disable-next-line no-await-in-loop + const wrappedKey = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: wrapIv }, + wrapKey, + rawContentKey + ); + // eslint-disable-next-line no-await-in-loop + const ephemeralPublicJwk = await crypto.subtle.exportKey('jwk', ephemeral.publicKey); + devices.push({ + userId: peer.userId, + sessionId: peer.sessionId, + fingerprint: peer.fingerprint, + ephemeralPublicJwk, + salt: toBase64(salt), + wrapIv: toBase64(wrapIv), + wrappedKey: toBase64(wrappedKey), + }); + } + + return { + version: 1, + algorithm: 'ECDH-P256+HKDF-SHA256+AES-256-GCM', + roomId, + messageIv: toBase64(messageIv), + ciphertext: toBase64(ciphertext), + devices, + }; +}; + +export const decryptEnvelope = async ({ envelope, roomId }) => { + if (!envelope || Number(envelope.version) !== 1) return null; + const current = await ensureDeviceKey(); + const target = (envelope.devices || []).find( + (item) => + String(item.userId) === current.userId && + String(item.sessionId) === current.sessionId + ); + if (!target) return null; + + const ephemeralPublic = await importPeerPublicKey(target.ephemeralPublicJwk); + const wrapKey = await deriveWrapKey({ + privateKey: current.privateKey, + publicKey: ephemeralPublic, + salt: fromBase64(target.salt), + info: `syncchat-e2ee-v1:${roomId}:${target.userId}:${target.sessionId}`, + }); + const rawContentKey = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64(target.wrapIv) }, + wrapKey, + fromBase64(target.wrappedKey) + ); + const contentKey = await crypto.subtle.importKey( + 'raw', + rawContentKey, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64(envelope.messageIv) }, + contentKey, + fromBase64(envelope.ciphertext) + ); + return decoder.decode(plaintext); +}; + +export const registerCurrentDeviceE2eeKey = () => ensureDeviceKey({ forceRegister: true }); diff --git a/frontend/client/helpers/mentionAutocompleteV2.js b/frontend/client/helpers/mentionAutocompleteV2.js new file mode 100644 index 00000000..a0fe5511 --- /dev/null +++ b/frontend/client/helpers/mentionAutocompleteV2.js @@ -0,0 +1,225 @@ +import axios from 'axios'; +import store from '../redux/store'; +import resolveUploadUrl from './resolveUploadUrl'; + +let installed = false; +let timer = null; +let requestId = 0; +let dropdown = null; +let activeInput = null; + +const getComposer = () => { + const candidates = [ + ...document.querySelectorAll( + 'textarea[name="text"], input[name="text"], textarea[data-chat-composer], input[data-chat-composer]' + ), + ]; + return ( + candidates.reverse().find((node) => { + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && !node.disabled; + }) || null + ); +}; + +const removeDropdown = () => { + if (dropdown?.parentNode) dropdown.parentNode.removeChild(dropdown); + dropdown = null; + activeInput = null; +}; + +const isGroupAdmin = (room, userId) => { + const group = room?.channel || room?.group || {}; + const admins = [group.adminId, ...(Array.isArray(group.adminsId) ? group.adminsId : [])] + .filter(Boolean) + .map(String); + return admins.includes(String(userId || '')); +}; + +const setNativeValue = (element, value) => { + const prototype = element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + if (descriptor?.set) descriptor.set.call(element, value); + else element.value = value; + element.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const insertMention = (input, username) => { + if (!input) return; + const value = String(input.value || ''); + const cursor = Number.isInteger(input.selectionStart) ? input.selectionStart : value.length; + const before = value.slice(0, cursor); + const after = value.slice(cursor); + const match = before.match(/(^|\s)@([a-z0-9_]*)$/i); + if (!match) return; + const tokenStart = cursor - match[0].length + match[1].length; + const next = `${value.slice(0, tokenStart)}@${username} ${after}`; + setNativeValue(input, next); + const nextCursor = tokenStart + username.length + 2; + requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange?.(nextCursor, nextCursor); + }); + removeDropdown(); +}; + +const createItem = ({ input, username, fullname, avatar, special = false }) => { + const button = document.createElement('button'); + button.type = 'button'; + button.style.cssText = [ + 'display:flex', + 'align-items:center', + 'gap:10px', + 'width:100%', + 'border:0', + 'background:transparent', + 'padding:9px 10px', + 'cursor:pointer', + 'text-align:left', + 'color:inherit', + ].join(';'); + button.onmouseenter = () => { + button.style.background = 'rgba(148,163,184,.15)'; + }; + button.onmouseleave = () => { + button.style.background = 'transparent'; + }; + + const avatarNode = document.createElement(special ? 'div' : 'img'); + avatarNode.style.cssText = + 'width:34px;height:34px;border-radius:999px;flex:0 0 auto;display:grid;place-items:center;background:#e2e8f0;object-fit:cover;font-weight:700;color:#334155'; + if (special) avatarNode.textContent = '@'; + else { + avatarNode.src = resolveUploadUrl(avatar || '') || '/assets/icons/default-avatar.png'; + avatarNode.alt = ''; + } + + const copy = document.createElement('div'); + copy.style.cssText = 'min-width:0;display:grid;gap:1px'; + const title = document.createElement('div'); + title.style.cssText = 'font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; + title.textContent = fullname || `@${username}`; + const subtitle = document.createElement('div'); + subtitle.style.cssText = 'font-size:11px;opacity:.58;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; + subtitle.textContent = `@${username}`; + copy.append(title, subtitle); + button.append(avatarNode, copy); + button.onclick = () => insertMention(input, username); + return button; +}; + +const renderDropdown = ({ input, room, master, query, profiles }) => { + removeDropdown(); + const items = []; + if (room?.roomType === 'group') { + if (isGroupAdmin(room, master?._id) && 'all'.startsWith(query)) { + items.push({ username: 'all', fullname: 'Mention everyone', special: true }); + } + if ('admins'.startsWith(query)) { + items.push({ username: 'admins', fullname: 'Mention admins', special: true }); + } + } + profiles.forEach((profile) => items.push({ ...profile, special: false })); + if (!items.length) return; + + const rect = input.getBoundingClientRect(); + const panel = document.createElement('div'); + panel.style.cssText = [ + 'position:fixed', + `left:${Math.max(8, Math.min(rect.left, window.innerWidth - 300))}px`, + `bottom:${Math.max(8, window.innerHeight - rect.top + 8)}px`, + `width:${Math.min(292, Math.max(220, rect.width))}px`, + 'max-height:280px', + 'overflow:auto', + 'z-index:1200', + 'border:1px solid rgba(148,163,184,.28)', + 'border-radius:12px', + 'background:var(--mention-bg,#fff)', + 'color:#0f172a', + 'box-shadow:0 16px 44px rgba(15,23,42,.22)', + 'padding:5px', + ].join(';'); + items.forEach((item) => panel.appendChild(createItem({ input, ...item }))); + document.body.appendChild(panel); + dropdown = panel; + activeInput = input; +}; + +const loadSuggestions = async (input, query) => { + const state = store.getState(); + const room = state?.room?.chat?.data; + const master = state?.user?.master; + if (!room?.roomId || !master?._id) { + removeDropdown(); + return; + } + + const currentRequest = ++requestId; + try { + const { data } = await axios.get( + `/chat-v2/mention-suggestions/${room.roomId}`, + { params: { q: query } } + ); + if (currentRequest !== requestId) return; + renderDropdown({ + input, + room, + master, + query, + profiles: Array.isArray(data?.payload) ? data.payload : [], + }); + } catch (error0) { + removeDropdown(); + } +}; + +const inspectInput = (input) => { + const value = String(input.value || ''); + const cursor = Number.isInteger(input.selectionStart) ? input.selectionStart : value.length; + const before = value.slice(0, cursor); + const match = before.match(/(^|\s)@([a-z0-9_]*)$/i); + if (!match) { + removeDropdown(); + return; + } + const query = String(match[2] || '').toLowerCase(); + clearTimeout(timer); + timer = setTimeout(() => loadSuggestions(input, query), 180); +}; + +const onInput = (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return; + if (target !== getComposer()) return; + inspectInput(target); +}; + +const onKeyDown = (event) => { + if (!dropdown || event.target !== activeInput) return; + if (event.key === 'Escape') { + event.preventDefault(); + removeDropdown(); + } +}; + +const installMentionAutocompleteV2 = () => { + if (installed) return; + installed = true; + document.addEventListener('input', onInput, true); + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener( + 'click', + (event) => { + if (!dropdown) return; + if (dropdown.contains(event.target) || event.target === activeInput) return; + removeDropdown(); + }, + true + ); + window.addEventListener('resize', removeDropdown); + window.addEventListener('scroll', removeDropdown, true); +}; + +export default installMentionAutocompleteV2; diff --git a/frontend/client/helpers/socket.js b/frontend/client/helpers/socket.js index 5c123483..b8a7899b 100644 --- a/frontend/client/helpers/socket.js +++ b/frontend/client/helpers/socket.js @@ -9,5 +9,16 @@ const socket = io(config.socketUrl, { reconnectionAttempts: 10, reconnectionDelay: 1000, reconnectionDelayMax: 5000, + auth(callback) { + callback({ token: localStorage.getItem('token') || '' }); + }, }); + +socket.on('connect_error', (error) => { + if (error?.data?.code === 'SOCKET_AUTH_INVALID' || error?.data?.code === 'SOCKET_AUTH_REQUIRED') { + // The HTTP auth flow owns redirect/logout. Keep the socket closed until a fresh token exists. + socket.disconnect(); + } +}); + export default socket; diff --git a/frontend/client/helpers/topicFilterV2.js b/frontend/client/helpers/topicFilterV2.js new file mode 100644 index 00000000..29f6f2d4 --- /dev/null +++ b/frontend/client/helpers/topicFilterV2.js @@ -0,0 +1,36 @@ +import axios from 'axios'; + +let installed = false; + +const selectedTopic = (roomId) => + String(localStorage.getItem(`syncchat:topic:${roomId}`) || '').trim(); + +const installTopicFilterV2 = () => { + if (installed) return; + installed = true; + + axios.interceptors.response.use((response0) => { + const method = String(response0?.config?.method || '').toLowerCase(); + const url = String(response0?.config?.url || ''); + const match = url.match(/^\/chats\/([^/?]+)(?:\?|$)/); + if (method !== 'get' || !match || !Array.isArray(response0?.data?.payload)) { + return response0; + } + + const roomId = match[1]; + const topicId = selectedTopic(roomId); + if (!topicId) return response0; + + // Keep topic system/pin messages visible only when they explicitly belong + // to the selected topic. Legacy pre-topic messages stay in "All messages". + // eslint-disable-next-line no-param-reassign + response0.data.payload = response0.data.payload.filter( + (chat) => String(chat?.topicId || '') === topicId + ); + // eslint-disable-next-line no-param-reassign + response0.data.topicId = topicId; + return response0; + }); +}; + +export default installTopicFilterV2; diff --git a/frontend/client/index.jsx b/frontend/client/index.jsx index 1cad774d..f7f55ce0 100644 --- a/frontend/client/index.jsx +++ b/frontend/client/index.jsx @@ -4,9 +4,15 @@ import { Provider } from 'react-redux'; import store from './redux/store'; import App from './app'; import GlobalCallLayer from './components/calling/globalCallLayer'; +import GlobalChatTools from './components/chat/GlobalChatTools'; import installProfileAvatarSync from './helpers/profileAvatarSync'; import installChatLockSync from './helpers/chatLockSync'; import installChatDeletionSync from './helpers/chatDeletionSync'; +import installChatTransportV2 from './helpers/chatTransportV2'; +import installChatDraftV2 from './helpers/chatDraftV2'; +import installTopicFilterV2 from './helpers/topicFilterV2'; +import installChatHttpReliability from './helpers/chatHttpReliability'; +import installMentionAutocompleteV2 from './helpers/mentionAutocompleteV2'; import { registerServiceWorker } from './pwa/registerSW'; import { requestNotificationPermission } from './pwa/notifications'; @@ -17,12 +23,18 @@ root.render( + ); installProfileAvatarSync(); installChatLockSync(); installChatDeletionSync(); +installChatHttpReliability(); +installChatTransportV2(); +installTopicFilterV2(); +installChatDraftV2(); +installMentionAutocompleteV2(); registerServiceWorker(); requestNotificationPermission();