Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions app/api/server/v1/emoji-custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Busboy from 'busboy';
import { EmojiCustom } from '../../../models';
import { API } from '../api';
import { findEmojisCustom } from '../lib/emoji-custom';
import { Media } from '../../../../server/sdk';

// DEPRECATED
// Will be removed after v3.0.0
Expand Down Expand Up @@ -97,8 +98,14 @@ API.v1.addRoute('emoji-custom.create', { authRequired: true }, {
fields.newFile = true;
fields.aliases = fields.aliases || '';
try {
const emojiBuffer = Buffer.concat(emojiData);
const isUploadable = Promise.await(Media.isImage(emojiBuffer));
if (!isUploadable) {
throw new Meteor.Error('emoji-is-not-image', 'Emoji file provided cannot be uploaded since it\'s not an image');
}

Meteor.call('insertOrUpdateEmoji', fields);
Meteor.call('uploadEmojiCustom', Buffer.concat(emojiData), emojiMimetype, fields);
Meteor.call('uploadEmojiCustom', emojiBuffer, emojiMimetype, fields);
callback();
} catch (error) {
return callback(error);
Expand Down Expand Up @@ -147,9 +154,15 @@ API.v1.addRoute('emoji-custom.update', { authRequired: true }, {
fields.previousExtension = emojiToUpdate.extension;
fields.aliases = fields.aliases || '';
fields.newFile = Boolean(emojiData.length);
const emojiBuffer = Buffer.concat(emojiData);
const isUploadable = Promise.await(Media.isImage(emojiBuffer));
if (!isUploadable) {
throw new Meteor.Error('emoji-is-not-image', 'Emoji file provided cannot be uploaded since it\'s not an image');
}

Meteor.call('insertOrUpdateEmoji', fields);
if (emojiData.length) {
Meteor.call('uploadEmojiCustom', Buffer.concat(emojiData), emojiMimetype, fields);
Meteor.call('uploadEmojiCustom', emojiBuffer, emojiMimetype, fields);
}
callback();
} catch (error) {
Expand Down
16 changes: 14 additions & 2 deletions app/emoji-custom/server/methods/uploadEmojiCustom.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { hasPermission } from '../../../authorization';
import { RocketChatFile } from '../../../file';
import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom';
import { api } from '../../../../server/sdk/api';
import { Media } from '../../../../server/sdk';

const getFile = async (file, extension) => {
if (extension !== 'svg+xml') {
Expand All @@ -19,6 +20,8 @@ const getFile = async (file, extension) => {

Meteor.methods({
async uploadEmojiCustom(binaryContent, contentType, emojiData) {
// 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 (!hasPermission(this.userId, 'manage-emoji')) {
throw new Meteor.Error('not_authorized');
}
Expand All @@ -28,10 +31,19 @@ Meteor.methods({
delete emojiData.aliases;

const file = await getFile(Buffer.from(binaryContent, 'binary'), emojiData.extension);

emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension;

const rs = RocketChatFile.bufferToStream(file);
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 {
const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 128, 128, true, false, false, 'inside');
fileBuffer = resizedEmojiBuffer;
}

const rs = RocketChatFile.bufferToStream(fileBuffer);
RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${ emojiData.name }.${ emojiData.extension }`));
const ws = RocketChatFileEmojiCustomInstance.createWriteStream(encodeURIComponent(`${ emojiData.name }.${ emojiData.extension }`), contentType);
ws.on('end', Meteor.bindEnvironment(() =>
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
"@types/marked": "^1.2.2",
"@types/mkdirp": "^1.0.1",
"@types/nodemailer": "^6.4.0",
"@types/sharp": "^0.28.0",
"@types/string-strip-html": "^5.0.0",
"@types/underscore.string": "0.0.38",
"@types/use-subscription": "^1.0.0",
Expand Down Expand Up @@ -213,6 +214,7 @@
"image-size": "^0.6.3",
"imap": "^0.8.19",
"ip-range-check": "^0.0.2",
"is-svg": "^4.3.1",
"jquery": "^3.5.1",
"jschardet": "^1.6.0",
"jsdom": "^16.4.0",
Expand Down
2 changes: 2 additions & 0 deletions server/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IBannerService } from './types/IBannerService';
import { INPSService } from './types/INPSService';
import { ITeamService } from './types/ITeamService';
import { IRoomService } from './types/IRoomService';
import { IMediaService } from './types/IMediaService';

// TODO think in a way to not have to pass the service name to proxify here as well
export const Authorization = proxifyWithWait<IAuthorization>('authorization');
Expand All @@ -25,6 +26,7 @@ export const UiKitCoreApp = proxifyWithWait<IUiKitCoreAppService>('uikit-core-ap
export const NPS = proxifyWithWait<INPSService>('nps');
export const Team = proxifyWithWait<ITeamService>('team');
export const Room = proxifyWithWait<IRoomService>('room');
export const Media = proxifyWithWait<IMediaService>('media');

// Calls without wait. Means that the service is optional and the result may be an error
// of service/method not available
Expand Down
15 changes: 15 additions & 0 deletions server/sdk/types/IMediaService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Readable } from 'stream';

import sharp from 'sharp';

export type ResizeResult = {
data: Buffer;
width: number;
height: number;
}

export interface IMediaService {
resizeFromBuffer(input: Buffer, width: number, height: number, keepType: boolean, blur: boolean, enlarge: boolean, fit?: keyof sharp.FitEnum | undefined): Promise<ResizeResult>;
resizeFromStream(input: Readable, width: number, height: number, keepType: boolean, blur: boolean, enlarge: boolean, fit?: keyof sharp.FitEnum | undefined): Promise<ResizeResult>;
isImage(buff: Buffer): boolean;
}
89 changes: 89 additions & 0 deletions server/services/image/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Readable } from 'stream';

import fileType from 'file-type';
import sharp from 'sharp';
import isSvg from 'is-svg';

import { ServiceClass } from '../../sdk/types/ServiceClass';
import { IMediaService, ResizeResult } from '../../sdk/types/IMediaService';

export class MediaService extends ServiceClass implements IMediaService {
protected name = 'media';

private imageExts = new Set([
'jpg',
'png',
'gif',
'webp',
'flif',
'cr2',
'tif',
'bmp',
'jxr',
'psd',
'ico',
'bpg',
'jp2',
'jpm',
'jpx',
'heic',
'cur',
'dcm',
]);

async resizeFromBuffer(input: Buffer, width: number, height: number, keepType: boolean, blur: boolean, enlarge: boolean, fit?: keyof sharp.FitEnum | undefined): Promise<ResizeResult> {
const transformer = sharp(input)
.resize({ width, height, fit, withoutEnlargement: !enlarge });

if (!keepType) {
transformer.jpeg();
}

if (blur) {
transformer.blur();
}

const { data, info: { width: widthInfo, height: heightInfo } } = await transformer.toBuffer({ resolveWithObject: true });

return {
data,
width: widthInfo,
height: heightInfo,
};
}

async resizeFromStream(input: Readable, width: number, height: number, keepType: boolean, blur: boolean, enlarge: boolean, fit?: keyof sharp.FitEnum | undefined): Promise<ResizeResult> {
const transformer = sharp()
.resize({ width, height, fit, withoutEnlargement: !enlarge });

if (!keepType) {
transformer.jpeg();
}

if (blur) {
transformer.blur();
}

const result = transformer.toBuffer({ resolveWithObject: true });
input.pipe(transformer);

const { data, info: { width: widthInfo, height: heightInfo } } = await result;
return {
data,
width: widthInfo,
height: heightInfo,
};
}

isImage(buff: Buffer): boolean {
const data = fileType(buff);
if (!data?.ext) {
return false || this.isSvgImage(buff);
}
return this.imageExts.has(data.ext) || this.isSvgImage(buff);
}

isSvgImage(buff: Buffer): boolean {
return isSvg(buff);
}
}
2 changes: 2 additions & 0 deletions server/services/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NPSService } from './nps/service';
import { RoomService } from './room/service';
import { TeamService } from './team/service';
import { UiKitCoreApp } from './uikit-core-app/service';
import { MediaService } from './image/service';

const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;

Expand All @@ -18,3 +19,4 @@ api.registerService(new UiKitCoreApp());
api.registerService(new NPSService(db));
api.registerService(new RoomService(db));
api.registerService(new TeamService(db));
api.registerService(new MediaService());