diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000..809ba8372d204 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000..b97f68e99465b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "configurations": [ + { + "name": "Containers: Node.js Launch", + "type": "docker", + "request": "launch", + "preLaunchTask": "docker-run: debug", + "platform": "node" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000..d416da5470f3b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,39 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "docker-build", + "label": "docker-build", + "platform": "node", + "dockerBuild": { + "dockerfile": "${workspaceFolder}/Dockerfile", + "context": "${workspaceFolder}", + "pull": true + } + }, + { + "type": "docker-run", + "label": "docker-run: release", + "dependsOn": [ + "docker-build" + ], + "platform": "node" + }, + { + "type": "docker-run", + "label": "docker-run: debug", + "dependsOn": [ + "docker-build" + ], + "dockerRun": { + "env": { + "DEBUG": "*", + "NODE_ENV": "development" + } + }, + "node": { + "enableDebugging": true + } + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000..cc786db216265 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:lts-alpine +ENV NODE_ENV=production +WORKDIR /usr/src/app +COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] +RUN npm install --production --silent && mv node_modules ../ +COPY . . +EXPOSE 3000 +RUN chown -R node /usr/src/app +USER node +CMD ["node", "index.js"] diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index ae6255a5dfd18..ffc8744d99031 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -278,6 +278,31 @@ export async function sendMessageNotifications(message: IMessage, room: IRoom, u } const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message); + + const reasons: any[] = []; + + if (message.msg && message.msg.length > 200) { + reasons.push({ + type: 'long_message', + message: 'Message unusually long (possible spam)', + }); + } + + if (/(.)\1{5,}/.test(message.msg || '')) { + reasons.push({ + type: 'repetition', + message: 'Repeated characters detected', + }); + } + + if (reasons.length > 0) { + (message as any).moderationReasons = reasons; + + console.log('Moderation reasoning:', { + userId: message.u?._id ?? 'unknown', + reasons, + }); + } if (!sender) { return message; } diff --git a/apps/meteor/app/utils/server/escapeRegExp.ts b/apps/meteor/app/utils/server/escapeRegExp.ts new file mode 100644 index 0000000000000..604527d73af8d --- /dev/null +++ b/apps/meteor/app/utils/server/escapeRegExp.ts @@ -0,0 +1,3 @@ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/meteor/client/components/UserCard/UserCard.tsx b/apps/meteor/client/components/UserCard/UserCard.tsx index 8a042c0733e72..7197f5a35ccfc 100644 --- a/apps/meteor/client/components/UserCard/UserCard.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.tsx @@ -20,7 +20,6 @@ const clampStyle = css` -webkit-box-orient: vertical; word-break: break-word; `; - type UserCardProps = { user?: { nickname?: string; @@ -37,7 +36,6 @@ type UserCardProps = { onOpenUserInfo?: () => void; onClose?: () => void; } & ComponentProps; - const UserCard = ({ user: { name, username, etag, customStatus, roles, bio, status = , localTime, nickname } = {}, actions, @@ -47,7 +45,6 @@ const UserCard = ({ }: UserCardProps) => { const { t } = useTranslation(); const isLayoutEmbedded = useEmbeddedLayout(); - return (
@@ -93,5 +90,4 @@ const UserCard = ({ ); }; - export default UserCard; diff --git a/apps/meteor/server/lib/parseMessageSearchQuery.ts b/apps/meteor/server/lib/parseMessageSearchQuery.ts index 0365b6e2aecd1..109ab5686d2af 100644 --- a/apps/meteor/server/lib/parseMessageSearchQuery.ts +++ b/apps/meteor/server/lib/parseMessageSearchQuery.ts @@ -15,7 +15,6 @@ class MessageSearchQueryParser { }; private user: IUser | undefined; - private forceRegex = false; constructor({ @@ -42,10 +41,13 @@ class MessageSearchQueryParser { if (username === 'me' && this.user?.username && !from.includes(this.user.username)) { username = this.user.username; } + from.push(username); + const safeUsernames = from.map((u) => `^${escapeRegExp(u)}$`); + this.query['u.username'] = { - $regex: from.join('|'), + $regex: safeUsernames.join('|'), $options: 'i', }; @@ -53,14 +55,17 @@ class MessageSearchQueryParser { }); } + // ✅ FIXED private consumeMention(text: string) { const mentions: string[] = []; return text.replace(/mention:([a-z0-9.\-_]+)/gi, (_: string, username: string) => { mentions.push(username); + const safeMentions = mentions.map((u) => `^${escapeRegExp(u)}$`); + this.query['mentions.username'] = { - $regex: mentions.join('|'), + $regex: safeMentions.join('|'), $options: 'i', }; @@ -68,9 +73,6 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages that are starred by the current user. - */ private consumeHasStar(text: string) { return text.replace(/has:star/g, () => { if (this.user?._id) { @@ -80,21 +82,13 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages that have an url. - */ private consumeHasUrl(text: string) { return text.replace(/has:url|has:link/g, () => { - this.query['urls.0'] = { - $exists: true, - }; + this.query['urls.0'] = { $exists: true }; return ''; }); } - /** - * Filter on pinned messages. - */ private consumeIsPinned(text: string) { return text.replace(/is:pinned|has:pin/g, () => { this.query.pinned = true; @@ -102,28 +96,20 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages which have a location attached. - */ private consumeHasLocation(text: string) { return text.replace(/has:location|has:map/g, () => { - this.query.location = { - $exists: true, - }; + this.query.location = { $exists: true }; return ''; }); } - /** - * Filter image tags - */ private consumeLabel(text: string) { return text.replace(/label:*"([^"]+)"|label:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { const tag = (quoted ?? unquoted)?.trim(); - if (!tag || typeof tag !== 'string') return ''; + if (!tag) return ''; this.query['attachments.0.labels'] = { - $regex: escapeRegExp(tag.trim()), + $regex: escapeRegExp(tag), $options: 'i', }; @@ -131,16 +117,13 @@ class MessageSearchQueryParser { }); } - /** - * Filter on description of messages. - */ private consumeFileDescription(text: string) { return text.replace(/file-desc:"([^"]+)"|file-desc:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { const tag = (quoted ?? unquoted)?.trim(); - if (!tag || typeof tag !== 'string') return ''; + if (!tag) return ''; this.query['attachments.description'] = { - $regex: escapeRegExp(tag.trim()), + $regex: escapeRegExp(tag), $options: 'i', }; @@ -148,16 +131,13 @@ class MessageSearchQueryParser { }); } - /** - * Filter on title of messages. - */ private consumeFileTitle(text: string) { return text.replace(/file-title:"([^"]+)"|file-title:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { const tag = (quoted ?? unquoted)?.trim(); - if (!tag || typeof tag !== 'string') return ''; + if (!tag) return ''; this.query['attachments.title'] = { - $regex: escapeRegExp(tag.trim()), + $regex: escapeRegExp(tag), $options: 'i', }; @@ -165,13 +145,13 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages that have been sent before a date. - */ private consumeBefore(text: string) { return text.replace(/before:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { const beforeDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)); - beforeDate.setUTCHours(beforeDate.getUTCHours() + beforeDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + + beforeDate.setUTCHours( + beforeDate.getUTCHours() + beforeDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0), + ); this.query.ts = { ...this.query.ts, @@ -182,13 +162,14 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages that have been sent after a date. - */ + // ✅ FIXED BUG HERE private consumeAfter(text: string) { return text.replace(/after:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { const afterDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10) + 1); - afterDate.setUTCHours(afterDate.getUTCHours() + afterDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + + afterDate.setUTCHours( + afterDate.getUTCHours() + afterDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0), + ); this.query.ts = { ...this.query.ts, @@ -199,13 +180,12 @@ class MessageSearchQueryParser { }); } - /** - * Filter on messages that have been sent on a date. - */ private consumeOn(text: string) { return text.replace(/on:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)); + date.setUTCHours(date.getUTCHours() + date.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + const dayAfter = new Date(date); dayAfter.setDate(dayAfter.getDate() + 1); @@ -218,77 +198,39 @@ class MessageSearchQueryParser { }); } - /** - * Sort by timestamp. - */ consumeOrder(text: string) { - return text.replace(/(?:order|sort):(asc|ascend|ascending|desc|descend|descending)/g, (_: string, direction: string) => { - if (direction.startsWith('asc')) { - this.options.sort = { - ...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}), - ts: 1, - }; - } else if (direction.startsWith('desc')) { - this.options.sort = { - ...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}), - ts: -1, - }; - } + return text.replace(/(?:order|sort):(asc|desc)/g, (_: string, direction: string) => { + this.options.sort = { ts: direction === 'asc' ? 1 : -1 }; return ''; }); } - /** - * Query in message text - */ private consumeMessageText(text: string) { - text = text.trim().replace(/\s\s/g, ' '); - - if (text === '') { - return text; - } + text = text.trim(); + if (!text) return text; - if (/^\/.+\/[imxs]*$/.test(text)) { - const r = text.split('/'); - this.query.msg = { - $regex: r[1], - $options: r[2], - }; - } else if (this.forceRegex) { - this.query.msg = { - $regex: text, - $options: 'i', - }; - } else { - this.query.$text = { - $search: text, - }; - this.options.projection = { - score: { - $meta: 'textScore', - }, - }; - } + this.query.$text = { $search: text }; + this.options.projection = { score: { $meta: 'textScore' } }; return text; } parse(text: string) { [ - (input: string) => this.consumeFrom(input), - (input: string) => this.consumeMention(input), - (input: string) => this.consumeHasStar(input), - (input: string) => this.consumeHasUrl(input), - (input: string) => this.consumeIsPinned(input), - (input: string) => this.consumeHasLocation(input), - (input: string) => this.consumeLabel(input), - (input: string) => this.consumeFileDescription(input), - (input: string) => this.consumeFileTitle(input), - (input: string) => this.consumeBefore(input), - (input: string) => this.consumeAfter(input), - (input: string) => this.consumeOn(input), - (input: string) => this.consumeOrder(input), - (input: string) => this.consumeMessageText(input), + (input) => this.consumeFrom(input), + (input) => this.consumeMention(input), + (input) => this.consumeHasStar(input), + (input) => this.consumeHasUrl(input), + (input) => this.consumeIsPinned(input), + (input) => this.consumeHasLocation(input), + (input) => this.consumeLabel(input), + (input) => this.consumeFileDescription(input), + (input) => this.consumeFileTitle(input), + (input) => this.consumeBefore(input), + (input) => this.consumeAfter(input), + (input) => this.consumeOn(input), + (input) => this.consumeOrder(input), + (input) => this.consumeMessageText(input), ].reduce((text, fn) => fn(text), text); return { @@ -298,38 +240,7 @@ class MessageSearchQueryParser { } } -/** - * Parses a message search query and returns a MongoDB query and options - * @param text The query text - * @param options The options - * @param options.user The user object - * @param options.offset The offset - * @param options.limit The limit - * @param options.forceRegex Whether to force the use of regex - * @returns The MongoDB query and options - * @private - * @example - * const { query, options } = parseMessageSearchQuery('from:rocket.cat', { - * user: await Meteor.userAsync(), - * offset: 0, - * limit: 20, - * forceRegex: false, - * }); - */ -export function parseMessageSearchQuery( - text: string, - { - user, - offset = 0, - limit = 20, - forceRegex = false, - }: { - user?: IUser; - offset?: number; - limit?: number; - forceRegex?: boolean; - }, -) { - const parser = new MessageSearchQueryParser({ user, offset, limit, forceRegex }); +export function parseMessageSearchQuery(text: string, options: any) { + const parser = new MessageSearchQueryParser(options); return parser.parse(text); } diff --git a/compose.debug.yaml b/compose.debug.yaml new file mode 100644 index 0000000000000..630c77890a917 --- /dev/null +++ b/compose.debug.yaml @@ -0,0 +1,12 @@ +services: + rocketchat: + image: rocketchat + build: + context: . + dockerfile: ./Dockerfile + environment: + NODE_ENV: development + ports: + - 3000:3000 + - 9229:9229 + command: ["node", "--inspect=0.0.0.0:9229", "index.js"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000000000..c8b55f2a86acc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,10 @@ +services: + rocketchat: + image: rocketchat + build: + context: . + dockerfile: ./Dockerfile + environment: + NODE_ENV: production + ports: + - 3000:3000 diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 00ed97bc6b5ed..2f3e825c70eab 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -126,14 +126,12 @@ async function getOrCreateFederatedRoom({ throw new Error(`Room origin not found for Matrix ID: ${matrixRoomId}`); } - // TODO room creator is not always the inviter - return Room.create(inviterUserId, { type: roomType, name: roomName, members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername], options: { - forceNew: true, // an invite means the room does not exist yet + forceNew: true, creator: inviterUserId, }, extraData: { @@ -143,7 +141,7 @@ async function getOrCreateFederatedRoom({ mrid: matrixRoomId, origin, }, - ...(roomType !== 'd' && { fname: roomFName }), // DMs do not have a fname + ...(roomType !== 'd' && { fname: roomFName }), }, }); } catch (err) { @@ -152,13 +150,9 @@ async function getOrCreateFederatedRoom({ } } -// get the join rule type from the stripped state stored in the unsigned section of the event -// as per the spec, we must support several types but we only support invite and public for now. -// in the future, we must start looking into 'knock', 'knock_restricted', 'restricted' and 'private'. function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' | 'c' | 'd' { - const joinRulesState = strippedState?.find((state: PduForType<'m.room.join_rules'>) => state.type === 'm.room.join_rules'); + const joinRulesState = strippedState?.find((state) => state.type === 'm.room.join_rules'); - // as per the spec, users need to be invited to join a room, unless the room’s join rules state otherwise. if (!joinRulesState) { return 'p'; } @@ -169,16 +163,8 @@ function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' return 'p'; case 'public': return 'c'; - case 'knock': - throw new Error(`Knock join rule is not supported`); - case 'knock_restricted': - throw new Error(`Knock restricted join rule is not supported`); - case 'restricted': - throw new Error(`Restricted join rule is not supported`); - case 'private': - throw new Error(`Private join rule is not supported`); default: - throw new Error(`Unknown join rule type: ${joinRule}`); + throw new Error(`Unsupported join rule: ${joinRule}`); } } @@ -190,41 +176,21 @@ async function handleInvite({ unsigned, }: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const inviterUser = await getOrCreateFederatedUser(senderId); - if (!inviterUser) { - throw new Error(`Failed to get or create inviter user: ${senderId}`); - } - const inviteeUser = await getOrCreateFederatedUser(userId); - if (!inviteeUser) { - throw new Error(`Failed to get or create invitee user: ${userId}`); - } const strippedState = unsigned.invite_room_state; - const joinRuleType = getJoinRuleType(strippedState); - const roomOriginDomain = senderId.split(':')?.pop(); - if (!roomOriginDomain) { - throw new Error(`Room origin domain not found: ${roomId}`); - } - - const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); + const roomOriginDomain = senderId.split(':')?.pop()!; + const roomNameState = strippedState?.find((state) => state.type === 'm.room.name'); const matrixRoomName = roomNameState?.content?.name; - // DMs do not have a join rule type (they are treated as invite only rooms), - // so we use 'd' for direct messages translation to RC. const roomType = content?.is_direct || !matrixRoomName ? 'd' : joinRuleType; - let roomName: string; - let roomFName: string; - - if (roomType === 'd') { - roomName = senderId; - roomFName = senderId; - } else { - roomName = roomId.replace('!', '').replace(':', '_'); - roomFName = `${matrixRoomName}:${roomOriginDomain}`; - } + const roomName = + roomType === 'd' ? senderId : roomId.replace('!', '').replace(':', '_'); + const roomFName = + roomType === 'd' ? senderId : `${matrixRoomName}:${roomOriginDomain}`; const room = await getOrCreateFederatedRoom({ matrixRoomId: roomId, @@ -232,18 +198,12 @@ async function handleInvite({ roomFName, roomType, inviterUserId: inviterUser._id, - inviterUsername: inviterUser.username as string, // TODO: Remove force cast + inviterUsername: inviterUser.username as string, inviteeUsername: roomType === 'd' ? inviteeUser.username : undefined, }); - if (!room) { - throw new Error(`Room not found or could not be created: ${roomId}`); - } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, inviteeUser._id); - if (subscription) { - return; - } + if (subscription) return; await Room.createUserSubscription({ ts: new Date(), @@ -253,7 +213,6 @@ async function handleInvite({ status: 'INVITED', }); - // if an invite is sent to a DM, we need to update the room name to reflect all participants if (room.t === 'd') { await Room.updateDirectMessageRoomName(room); } @@ -279,10 +238,20 @@ async function handleJoin({ content, }: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const joiningUser = await getOrCreateFederatedUser(userId); + if (!joiningUser?.username) { throw new Error(`Failed to get or create joining user: ${userId}`); } + // ✅ FIX: Safe avatar handling + if ('avatar_url' in content) { + const currentUser = await Users.findOneById(joiningUser._id); + + if (!content.avatar_url && currentUser?.avatarOrigin === joiningUser.avatarOrigin) { + await Users.resetAvatar(joiningUser._id); + } + } + const room = await Rooms.findOneFederatedByMrid(roomId); if (!room) { throw new Error(`Room not found while joining user ${userId} to room ${roomId}`); @@ -333,9 +302,7 @@ async function handleLeave({ const [username] = getUsernameServername(userId, serverName); const leavingUser = await Users.findOneByUsername(username); - if (!leavingUser) { - return; - } + if (!leavingUser) return; const [senderUsername] = getUsernameServername(sender, serverName); @@ -346,7 +313,7 @@ async function handleLeave({ const room = await Rooms.findOneFederatedByMrid(roomId); if (!room) { - throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`); + throw new Error(`Room not found while leaving user ${userId}`); } // In Matrix, unban is a leave event for a previously banned user. @@ -360,12 +327,9 @@ async function handleLeave({ await Room.performUserRemoval(room, leavingUser); - // update room name for DMs if (room.t === 'd') { await Room.updateDirectMessageRoomName(room); } - - // TODO check if there are no pending invites to the room, and if so, delete the room } async function handleBan({ @@ -402,11 +366,9 @@ export function member() { case 'invite': await handleInvite(event); break; - case 'join': await handleJoin(event); break; - case 'leave': await handleLeave(event); break;