From c6b7e4fd82c4f898964321834d1a5a62867dbc88 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 20 Apr 2021 15:03:22 -0600 Subject: [PATCH] [IMPROVE] Resize custom emojis on upload instead of saving at max res (#21593) --- app/api/server/v1/emoji-custom.js | 17 +++- .../server/methods/uploadEmojiCustom.js | 16 +++- package-lock.json | 21 +++++ package.json | 2 + server/sdk/index.ts | 2 + server/sdk/types/IMediaService.ts | 15 ++++ server/services/image/service.ts | 89 +++++++++++++++++++ server/services/startup.ts | 2 + 8 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 server/sdk/types/IMediaService.ts create mode 100644 server/services/image/service.ts diff --git a/app/api/server/v1/emoji-custom.js b/app/api/server/v1/emoji-custom.js index 249331a3e83..d5f489b9798 100644 --- a/app/api/server/v1/emoji-custom.js +++ b/app/api/server/v1/emoji-custom.js @@ -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) { diff --git a/app/emoji-custom/server/methods/uploadEmojiCustom.js b/app/emoji-custom/server/methods/uploadEmojiCustom.js index 685d4f0270a..fe793c9925f 100644 --- a/app/emoji-custom/server/methods/uploadEmojiCustom.js +++ b/app/emoji-custom/server/methods/uploadEmojiCustom.js @@ -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(() => diff --git a/package-lock.json b/package-lock.json index c5d516952bd..a700607cccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3be0b0b463f..b368d871bd7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/sdk/index.ts b/server/sdk/index.ts index a4705558bd5..1b5c18b0405 100644 --- a/server/sdk/index.ts +++ b/server/sdk/index.ts @@ -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('authorization'); @@ -25,6 +26,7 @@ export const UiKitCoreApp = proxifyWithWait('uikit-core-ap export const NPS = proxifyWithWait('nps'); export const Team = proxifyWithWait('team'); export const Room = proxifyWithWait('room'); +export const Media = proxifyWithWait('media'); // Calls without wait. Means that the service is optional and the result may be an error // of service/method not available diff --git a/server/sdk/types/IMediaService.ts b/server/sdk/types/IMediaService.ts new file mode 100644 index 00000000000..f5cee814277 --- /dev/null +++ b/server/sdk/types/IMediaService.ts @@ -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; + resizeFromStream(input: Readable, width: number, height: number, keepType: boolean, blur: boolean, enlarge: boolean, fit?: keyof sharp.FitEnum | undefined): Promise; + isImage(buff: Buffer): boolean; +} diff --git a/server/services/image/service.ts b/server/services/image/service.ts new file mode 100644 index 00000000000..1ba32d28b71 --- /dev/null +++ b/server/services/image/service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/server/services/startup.ts b/server/services/startup.ts index 45a574fcc0f..e2757c0fab5 100644 --- a/server/services/startup.ts +++ b/server/services/startup.ts @@ -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());