snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import { api } from '@rocket.chat/core-services';
import { EmojiCustom } from '@rocket.chat/models';
import limax from 'limax';
import { Meteor } from 'meteor/meteor';
import { trim } from '../../../../../lib/utils/stringUtils';
import { hasPermissionAsync } from '../../../authorization/hasPermission';
import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom';
export type EmojiData = {
_id?: string;
name: string;
aliases?: string;
extension: string;
previousName?: string;
previousExtension?: string;
newFile?: boolean;
};
type EmojiDataWithParsedAliases = Omit<EmojiData, 'aliases' | '_id'> & { _id: string; aliases: string[] };
export async function insertOrUpdateEmoji(userId: string | null, emojiData: EmojiData): Promise<EmojiDataWithParsedAliases> {
if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) {
throw new Meteor.Error('not_authorized');
}
if (!trim(emojiData.name)) {
throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', {
method: 'insertOrUpdateEmoji',
field: 'Name',
});
}
emojiData.name = limax(emojiData.name, { replacement: '_' });
// allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, )
// more practical than allowing specific sets of characters; also allows foreign languages
const nameValidation = /[\s,:><&"'\/\\\(\)]/;
const aliasValidation = /[:><&\|"'\/\\\(\)]/;
// silently strip colon; this allows for uploading :emojiname: as emojiname
emojiData.name = emojiData.name.replace(/:/g, '');
emojiData.aliases = emojiData.aliases?.replace(/:/g, '');
if (nameValidation.test(emojiData.name)) {
throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.name} is not a valid name`, {
method: 'insertOrUpdateEmoji',
input: emojiData.name,
field: 'Name',
});
}
let aliases: string[] = [];
if (emojiData.aliases) {
if (aliasValidation.test(emojiData.aliases)) {
throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.aliases} is not a valid alias set`, {
method: 'insertOrUpdateEmoji',
input: emojiData.aliases,
field: 'Alias_Set',
});
}
aliases = emojiData.aliases
.split(/\s*,\s*/)
.filter(Boolean)
.map((alias) => limax(alias, { replacement: '_' }))
.filter((alias) => alias !== emojiData.name);
}
emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension;
const conflictingEmoji = await EmojiCustom.findOneByNamesOrAliases([emojiData.name, ...aliases], emojiData._id, {
projection: { _id: 1 },
});
if (conflictingEmoji) {
throw new Meteor.Error('Custom_Emoji_Error_Name_Or_Alias_Already_In_Use', 'The custom emoji or one of its aliases is already in use', {
method: 'insertOrUpdateEmoji',
});
}
if (typeof emojiData.extension === 'undefined') {
throw new Meteor.Error('error-the-field-is-required', 'The custom emoji file is required', {
method: 'insertOrUpdateEmoji',
});
}
if (!emojiData._id) {
// insert emoji
const createEmoji = {
name: emojiData.name,
aliases,
extension: emojiData.extension,
};
const _id = (await EmojiCustom.create(createEmoji)).insertedId;
void api.broadcast('emoji.updateCustom', createEmoji);
return { ...emojiData, ...createEmoji, _id };
}
// update emoji
if (emojiData.newFile) {
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.extension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`));
await EmojiCustom.setExtension(emojiData._id, emojiData.extension);
} else if (emojiData.name !== emojiData.previousName) {
const rs = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(
encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`),
);
if (rs) {
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
const ws = RocketChatFileEmojiCustomInstance.createWriteStream(
encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`),
rs.contentType,
);
ws.on('end', () =>
RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)),
);
rs.readStream.pipe(ws);
}
}
if (emojiData.name !== emojiData.previousName) {
await EmojiCustom.setName(emojiData._id, emojiData.name);
}
if (emojiData.aliases) {
await EmojiCustom.setAliases(emojiData._id, aliases);
} else {
await EmojiCustom.setAliases(emojiData._id, []);
}
void api.broadcast('emoji.updateCustom', { ...emojiData, aliases });
return { ...emojiData, aliases } as EmojiDataWithParsedAliases;
}

View File

@@ -0,0 +1,82 @@
import { api, Media } from '@rocket.chat/core-services';
import { EmojiCustom } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import limax from 'limax';
import { Meteor } from 'meteor/meteor';
import sharp from 'sharp';
import type { EmojiData } from './insertOrUpdateEmoji';
import { hasPermissionAsync } from '../../../authorization/hasPermission';
import { RocketChatFile } from '../../file';
import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom';
const getFile = async (file: Buffer, extension: string) => {
if (extension !== 'svg+xml') {
return file;
}
return sharp(file).png().toBuffer();
};
export type EmojiDataWithAliases = Omit<EmojiData, 'aliases'> & { aliases?: string | string[] };
export async function uploadEmojiCustom(
userId: string | null,
binaryContent: string,
contentType: string,
emojiData: EmojiDataWithAliases,
) {
return uploadEmojiCustomWithBuffer(userId, Buffer.from(binaryContent, 'binary'), contentType, emojiData);
}
export async function uploadEmojiCustomWithBuffer(
userId: string | null,
buffer: Buffer,
contentType: string,
emojiData: EmojiDataWithAliases,
): Promise<void> {
// technically, since this method doesnt have any datatype validations, users can
// upload videos as emojis. The FE won't play them, but they will waste space for sure.
if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) {
throw new Meteor.Error('not_authorized');
}
if (!Array.isArray(emojiData.aliases)) {
// delete aliases for notification purposes. here, it is a string or undefined rather than an array
delete emojiData.aliases;
}
emojiData.name = limax(emojiData.name, { replacement: '_' });
const file = await getFile(buffer, emojiData.extension);
emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension;
let fileBuffer;
// sharp doesn't support these formats without imagemagick or libvips installed
// so they will be stored as they are :(
if (['gif', 'x-icon', 'bmp', 'webm'].includes(emojiData.extension)) {
fileBuffer = file;
} else {
// This is to support the idea of having "sticker-like" emojis
const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 512, 512, true, false, false, 'inside');
fileBuffer = resizedEmojiBuffer;
}
const rs = RocketChatFile.bufferToStream(fileBuffer);
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
return new Promise((resolve) => {
const ws = RocketChatFileEmojiCustomInstance.createWriteStream(
encodeURIComponent(`${emojiData.name}.${emojiData.extension}`),
contentType,
);
ws.on('end', async () => {
const etag = Random.hexString(6);
await EmojiCustom.setETagByName(emojiData.name, etag);
setTimeout(() => api.broadcast('emoji.updateCustom', { ...emojiData, etag }), 500);
resolve();
});
rs.pipe(ws);
});
}

View File

@@ -0,0 +1,125 @@
import { EmojiCustom } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import _ from 'underscore';
import { settings } from '../../../../settings';
import { SystemLogger } from '../../../logger/system';
import { RocketChatFile } from '../../file';
export let RocketChatFileEmojiCustomInstance;
const writeSvgFallback = (res, req) => {
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Expires', '-1');
res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT');
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader != null) {
if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') {
res.writeHead(304);
res.end();
return;
}
}
const color = '#000';
const initials = '?';
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="50" height="50" style="width: 50px; height: 50px; background-color: ${color};">
<text text-anchor="middle" y="50%" x="50%" dy="0.36em" pointer-events="auto" fill="#ffffff" font-family="Helvetica, Arial, Lucida Grande, sans-serif" style="font-weight: 400; font-size: 28px;">
${initials}
</text>
</svg>`;
res.write(svg);
res.end();
};
const initializeEmojiCustomStorage = () => {
let storeType = 'GridFS';
if (settings.get('EmojiUpload_Storage_Type')) {
storeType = settings.get('EmojiUpload_Storage_Type');
}
const RocketChatStore = RocketChatFile[storeType];
if (RocketChatStore == null) {
throw new Error(`Invalid RocketChatStore type [${storeType}]`);
}
SystemLogger.info({
msg: 'Using custom emoji storage',
storeType,
});
let path = '~/uploads';
if (settings.get('EmojiUpload_FileSystemPath') != null) {
if (settings.get('EmojiUpload_FileSystemPath').trim() !== '') {
path = settings.get('EmojiUpload_FileSystemPath');
}
}
RocketChatFileEmojiCustomInstance = new RocketChatStore({
name: 'custom_emoji',
absolutePath: path,
});
};
Meteor.startup(() => {
initializeEmojiCustomStorage();
return WebApp.connectHandlers.use('/emoji-custom/', async (req, res /* , next*/) => {
const params = { emoji: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')) };
if (_.isEmpty(params.emoji)) {
res.writeHead(403);
res.write('Forbidden');
res.end();
return;
}
res.setHeader('Content-Disposition', 'inline');
const emoji = await EmojiCustom.findOneByName(params.emoji.split('.')[0], { projection: { _id: 1 } });
if (!emoji) {
return writeSvgFallback(res, req);
}
const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji));
if (!file) {
// use code from username initials renderer until file upload is complete
return writeSvgFallback(res, req);
}
const fileUploadDate = file.uploadDate != null ? file.uploadDate.toUTCString() : undefined;
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader != null && reqModifiedHeader === fileUploadDate) {
res.setHeader('Last-Modified', reqModifiedHeader);
res.writeHead(304);
res.end();
return;
}
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.setHeader('Last-Modified', fileUploadDate || new Date().toUTCString());
res.setHeader('Content-Length', file.length);
if (/^svg$/i.test(params.emoji.split('.').pop())) {
res.setHeader('Content-Type', 'image/svg+xml');
} else if (/^png$/i.test(params.emoji.split('.').pop())) {
res.setHeader('Content-Type', 'image/png');
} else {
res.setHeader('Content-Type', 'image/jpeg');
}
file.readStream.pipe(res);
});
});
settings.watchMultiple(['EmojiUpload_Storage_Type', 'EmojiUpload_FileSystemPath'], initializeEmojiCustomStorage);