[IMPROVE] Resize custom emojis on upload instead of saving at max res (#21593)

pull/21360/head^2
Kevin Aleman 4 years ago committed by GitHub
parent 880546bbf8
commit c6b7e4fd82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      app/api/server/v1/emoji-custom.js
  2. 16
      app/emoji-custom/server/methods/uploadEmojiCustom.js
  3. 21
      package-lock.json
  4. 2
      package.json
  5. 2
      server/sdk/index.ts
  6. 15
      server/sdk/types/IMediaService.ts
  7. 89
      server/services/image/service.ts
  8. 2
      server/services/startup.ts

@ -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
@ -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);
@ -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) {

@ -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') {
@ -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');
}
@ -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(() =>

21
package-lock.json generated

@ -14275,6 +14275,14 @@
"@types/mime": "*"
}
},
"@types/sharp": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.28.0.tgz",
"integrity": "sha512-YvRFnQM44wAihAKzBDzu3BxnEohpqWd/5KXkwsSUl3qFTb51NyKHCKHX1D62YAy0jZij5aXgm/33v/Cv6VVsdA==",
"requires": {
"@types/node": "*"
}
},
"@types/sinonjs__fake-timers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz",
@ -22193,6 +22201,11 @@
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz",
"integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig=="
},
"fast-xml-parser": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz",
"integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg=="
},
"fastest-validator": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/fastest-validator/-/fastest-validator-1.7.0.tgz",
@ -26531,6 +26544,14 @@
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz",
"integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ=="
},
"is-svg": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.1.tgz",
"integrity": "sha512-h2CGs+yPUyvkgTJQS9cJzo9lYK06WgRiXUqBBHtglSzVKAuH4/oWsqk7LGfbSa1hGk9QcZ0SyQtVggvBA8LZXA==",
"requires": {
"fast-xml-parser": "^3.19.0"
}
},
"is-symbol": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz",

@ -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",
@ -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",

@ -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');
@ -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

@ -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;
}

@ -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);
}
}

@ -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;
@ -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());

Loading…
Cancel
Save