[IMPROVE] Create thumbnails from uploaded images (#20907)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/21779/head^2
Kevin Aleman 4 years ago committed by GitHub
parent 3c362a0d60
commit 3e68e780bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 39
      app/file-upload/server/lib/FileUpload.js
  2. 91
      app/file-upload/server/methods/sendFileMessage.js
  3. 130
      app/file-upload/server/methods/sendFileMessage.ts
  4. 29
      app/lib/server/functions/deleteMessage.ts
  5. 29
      app/lib/server/startup/settings.js
  6. 38
      app/ui/client/views/app/photoswipe.js
  7. 1
      client/components/Message/Attachments/Files/ImageAttachment.tsx
  8. 12
      client/components/Message/Attachments/components/Image.tsx
  9. 5
      definition/IMessage/IMessage.ts
  10. 4
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -266,6 +266,45 @@ export const FileUpload = {
return result; return result;
}, },
createImageThumbnail(file) {
if (!settings.get('Message_Attachments_Thumbnails_Enabled')) {
return;
}
const width = settings.get('Message_Attachments_Thumbnails_Width');
const height = settings.get('Message_Attachments_Thumbnails_Height');
if (file.identify.size && file.identify.size.height < height && file.identify.size.width < width) {
return;
}
file = Uploads.findOneById(file._id);
file = FileUpload.addExtensionTo(file);
const store = FileUpload.getStore('Uploads');
const image = store._store.getReadStream(file._id, file);
const transformer = sharp()
.resize({ width, height, fit: 'inside' });
const result = transformer.toBuffer({ resolveWithObject: true }).then(({ data, info: { width, height } }) => ({ data, width, height }));
image.pipe(transformer);
return result;
},
uploadImageThumbnail(file, buffer, rid, userId) {
const store = FileUpload.getStore('Uploads');
const details = {
name: `thumb-${ file.name }`,
size: buffer.length,
type: file.type,
rid,
userId,
};
return store.insertSync(details, buffer);
},
uploadsOnValidate(file) { uploadsOnValidate(file) {
if (!/^image\/((x-windows-)?bmp|p?jpeg|png|gif)$/.test(file.type)) { if (!/^image\/((x-windows-)?bmp|p?jpeg|png|gif)$/.test(file.type)) {
return; return;

@ -1,91 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
import _ from 'underscore';
import { Uploads } from '../../../models';
import { Rooms } from '../../../models/server/raw';
import { callbacks } from '../../../callbacks';
import { FileUpload } from '../lib/FileUpload';
import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom';
Meteor.methods({
async sendFileMessage(roomId, store, file, msgData = {}) {
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' });
}
const room = await Rooms.findOneById(roomId);
const user = Meteor.user();
if (user?.type !== 'app' && canAccessRoom(room, user) !== true) {
return false;
}
check(msgData, {
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
});
Uploads.updateFileComplete(file._id, Meteor.userId(), _.omit(file, '_id'));
const fileUrl = FileUpload.getPath(`${ file._id }/${ encodeURI(file.name) }`);
const attachment = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
};
if (/^image\/.+/.test(file.type)) {
attachment.image_url = fileUrl;
attachment.image_type = file.type;
attachment.image_size = file.size;
if (file.identify && file.identify.size) {
attachment.image_dimensions = file.identify.size;
}
try {
attachment.image_preview = await FileUpload.resizeImagePreview(file);
} catch (e) {
delete attachment.image_url;
delete attachment.image_type;
delete attachment.image_size;
delete attachment.image_dimensions;
}
} else if (/^audio\/.+/.test(file.type)) {
attachment.audio_url = fileUrl;
attachment.audio_type = file.type;
attachment.audio_size = file.size;
} else if (/^video\/.+/.test(file.type)) {
attachment.video_url = fileUrl;
attachment.video_type = file.type;
attachment.video_size = file.size;
}
let msg = Object.assign({
_id: Random.id(),
rid: roomId,
ts: new Date(),
msg: '',
file: {
_id: file._id,
name: file.name,
type: file.type,
},
groupable: false,
attachments: [attachment],
}, msgData);
msg = Meteor.call('sendMessage', msg);
Meteor.defer(() => callbacks.run('afterFileUpload', { user, room, message: msg }));
return msg;
},
});

@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/camelcase */
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import _ from 'underscore';
import { Uploads } from '../../../models/server';
import { Rooms } from '../../../models/server/raw';
import { callbacks } from '../../../callbacks/server';
import { FileUpload } from '../lib/FileUpload';
import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom';
import { MessageAttachment } from '../../../../definition/IMessage/MessageAttachment/MessageAttachment';
import { FileAttachmentProps } from '../../../../definition/IMessage/MessageAttachment/Files/FileAttachmentProps';
import { IUser } from '../../../../definition/IUser';
Meteor.methods({
async sendFileMessage(roomId, _store, file, msgData = {}) {
const user = Meteor.user() as IUser | undefined;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' } as any);
}
const room = await Rooms.findOneById(roomId);
if (user?.type !== 'app' && !canAccessRoom(room, user)) {
return false;
}
check(msgData, {
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
});
Uploads.updateFileComplete(file._id, user._id, _.omit(file, '_id'));
const fileUrl = FileUpload.getPath(`${ file._id }/${ encodeURI(file.name) }`);
const attachments: MessageAttachment[] = [];
const files = [{
_id: file._id,
name: file.name,
type: file.type,
}];
if (/^image\/.+/.test(file.type)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
image_url: fileUrl,
image_type: file.type,
image_size: file.size,
};
if (file.identify && file.identify.size) {
attachment.image_dimensions = file.identify.size;
}
try {
attachment.image_preview = await FileUpload.resizeImagePreview(file);
const thumbResult = await FileUpload.createImageThumbnail(file);
if (thumbResult) {
const { data: thumbBuffer, width, height } = thumbResult;
const thumbnail = FileUpload.uploadImageThumbnail(file, thumbBuffer, roomId, user._id);
const thumbUrl = FileUpload.getPath(`${ thumbnail._id }/${ encodeURI(file.name) }`);
attachment.image_url = thumbUrl;
attachment.image_type = thumbnail.type;
attachment.image_dimensions = {
width,
height,
};
files.push({
_id: thumbnail._id,
name: file.name,
type: thumbnail.type,
});
}
} catch (e) {
console.error(e);
}
attachments.push(attachment);
} else if (/^audio\/.+/.test(file.type)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
audio_url: fileUrl,
audio_type: file.type,
audio_size: file.size,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(file.type)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
video_url: fileUrl,
video_type: file.type,
video_size: file.size,
};
attachments.push(attachment);
}
const msg = Meteor.call('sendMessage', {
rid: roomId,
ts: new Date(),
msg: '',
file: files[0],
files,
groupable: false,
attachments,
...msgData,
});
callbacks.runAsync('afterFileUpload', { user, room, message: msg });
return msg;
},
});

@ -6,15 +6,18 @@ import { Messages, Uploads, Rooms } from '../../../models/server';
import { Notifications } from '../../../notifications/server'; import { Notifications } from '../../../notifications/server';
import { callbacks } from '../../../callbacks/server'; import { callbacks } from '../../../callbacks/server';
import { Apps } from '../../../apps/server'; import { Apps } from '../../../apps/server';
import { IMessage } from '../../../../definition/IMessage';
import { IUser } from '../../../../definition/IUser';
export const deleteMessage = function(message, user) { export const deleteMessage = function(message: IMessage, user: IUser): void {
const deletedMsg = Messages.findOneById(message._id); const deletedMsg = Messages.findOneById(message._id);
const isThread = deletedMsg.tcount > 0; const isThread = deletedMsg.tcount > 0;
const keepHistory = settings.get('Message_KeepHistory') || isThread; const keepHistory = settings.get('Message_KeepHistory') || isThread;
const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread; const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread;
const bridges = Apps && Apps.isLoaded() && Apps.getBridges();
if (deletedMsg && Apps && Apps.isLoaded()) { if (deletedMsg && bridges) {
const prevent = Promise.await(Apps.getBridges().getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg)); const prevent = Promise.await(bridges.getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg));
if (prevent) { if (prevent) {
throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.'); throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.');
} }
@ -24,6 +27,8 @@ export const deleteMessage = function(message, user) {
Messages.decreaseReplyCountById(deletedMsg.tmid, -1); Messages.decreaseReplyCountById(deletedMsg.tmid, -1);
} }
const files = (message.files || [message.file]).filter(Boolean); // Keep compatibility with old messages
if (keepHistory) { if (keepHistory) {
if (showDeletedStatus) { if (showDeletedStatus) {
Messages.cloneAndSaveAsHistoryById(message._id, user); Messages.cloneAndSaveAsHistoryById(message._id, user);
@ -31,21 +36,21 @@ export const deleteMessage = function(message, user) {
Messages.setHiddenById(message._id, true); Messages.setHiddenById(message._id, true);
} }
if (message.file && message.file._id) { files.forEach((file) => {
Uploads.update(message.file._id, { $set: { _hidden: true } }); file?._id && Uploads.update(file._id, { $set: { _hidden: true } });
} });
} else { } else {
if (!showDeletedStatus) { if (!showDeletedStatus) {
Messages.removeById(message._id); Messages.removeById(message._id);
} }
if (message.file && message.file._id) { files.forEach((file) => {
FileUpload.getStore('Uploads').deleteById(message.file._id); file?._id && FileUpload.getStore('Uploads').deleteById(file._id);
} });
} }
const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } }); const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } });
callbacks.run('afterDeleteMessage', deletedMsg, room, user); callbacks.run('afterDeleteMessage', deletedMsg, room);
// update last message // update last message
if (settings.get('Store_Last_Message')) { if (settings.get('Store_Last_Message')) {
@ -63,7 +68,7 @@ export const deleteMessage = function(message, user) {
Notifications.notifyRoom(message.rid, 'deleteMessage', { _id: message._id }); Notifications.notifyRoom(message.rid, 'deleteMessage', { _id: message._id });
} }
if (Apps && Apps.isLoaded()) { if (bridges) {
Apps.getBridges().getListenerBridge().messageEvent('IPostMessageDeleted', deletedMsg); bridges.getListenerBridge().messageEvent('IPostMessageDeleted', deletedMsg);
} }
}; };

@ -1107,6 +1107,35 @@ settings.addGroup('Message', function() {
public: true, public: true,
i18nDescription: 'Message_Attachments_GroupAttachDescription', i18nDescription: 'Message_Attachments_GroupAttachDescription',
}); });
this.add('Message_Attachments_Thumbnails_Enabled', true, {
type: 'boolean',
public: true,
i18nDescription: 'Message_Attachments_Thumbnails_EnabledDesc',
});
this.add('Message_Attachments_Thumbnails_Width', 480, {
type: 'int',
public: true,
enableQuery: [
{
_id: 'Message_Attachments_Thumbnails_Enabled',
value: true,
},
],
});
this.add('Message_Attachments_Thumbnails_Height', 360, {
type: 'int',
public: true,
enableQuery: [
{
_id: 'Message_Attachments_Thumbnails_Enabled',
value: true,
},
],
});
this.add('Message_Attachments_Strip_Exif', false, { this.add('Message_Attachments_Strip_Exif', false, {
type: 'boolean', type: 'boolean',
public: true, public: true,

@ -11,7 +11,7 @@ Meteor.startup(() => {
if (!currentGallery) { if (!currentGallery) {
const PhotoSwipe = PhotoSwipeImport.default; const PhotoSwipe = PhotoSwipeImport.default;
const PhotoSwipeUI_Default = PhotoSwipeUI_DefaultImport.default; const PhotoSwipeUI_Default = PhotoSwipeUI_DefaultImport.default;
currentGallery = await new PhotoSwipe(document.getElementById('pswp'), PhotoSwipeUI_Default, items, options); currentGallery = new PhotoSwipe(document.getElementById('pswp'), PhotoSwipeUI_Default, items, options);
currentGallery.listen('destroy', () => { currentGallery.listen('destroy', () => {
currentGallery = null; currentGallery = null;
}); });
@ -46,7 +46,25 @@ Meteor.startup(() => {
galleryOptions.index = i; galleryOptions.index = i;
} }
const item = {
src: element.src,
w: element.naturalWidth,
h: element.naturalHeight,
title: element.dataset.title || element.title,
description: element.dataset.description,
};
if (element.dataset.src || element.href) { if (element.dataset.src || element.href) {
// use stored sizes if available
if (element.dataset.width && element.dataset.height) {
return {
...item,
h: element.dataset.height,
w: element.dataset.width,
src: element.dataset.src || element.href,
};
}
const img = new Image(); const img = new Image();
img.addEventListener('load', () => { img.addEventListener('load', () => {
@ -54,6 +72,10 @@ Meteor.startup(() => {
return; return;
} }
// stores loaded sizes on original image element
element.dataset.width = img.naturalWidth;
element.dataset.height = img.naturalHeight;
delete currentGallery.items[i].html; delete currentGallery.items[i].html;
currentGallery.items[i].src = img.src; currentGallery.items[i].src = img.src;
currentGallery.items[i].w = img.naturalWidth; currentGallery.items[i].w = img.naturalWidth;
@ -65,19 +87,13 @@ Meteor.startup(() => {
img.src = element.dataset.src || element.href; img.src = element.dataset.src || element.href;
return { return {
html: '', ...item,
title: element.dataset.title || element.title, msrc: element.src,
description: element.dataset.description, src: element.dataset.src || element.href,
}; };
} }
return { return item;
src: element.src,
w: element.naturalWidth,
h: element.naturalHeight,
title: element.dataset.title || element.title,
description: element.dataset.description,
};
}); });
initGallery(items, galleryOptions); initGallery(items, galleryOptions);

@ -40,6 +40,7 @@ export const ImageAttachment: FC<ImageAttachmentProps> = ({
{...imageDimensions} {...imageDimensions}
loadImage={loadImage} loadImage={loadImage}
setLoadImage={setLoadImage} setLoadImage={setLoadImage}
dataSrc={getURL(link || url)}
src={getURL(url)} src={getURL(url)}
previewUrl={`data:image/png;base64,${imagePreview}`} previewUrl={`data:image/png;base64,${imagePreview}`}
/> />

@ -8,6 +8,7 @@ import Retry from './Retry';
type ImageProps = { type ImageProps = {
previewUrl?: string; previewUrl?: string;
dataSrc?: string;
src: string; src: string;
loadImage?: boolean; loadImage?: boolean;
setLoadImage: () => void; setLoadImage: () => void;
@ -31,7 +32,14 @@ const getDimensions = (
return { width: (height / originalHeight) * originalWidth, height }; return { width: (height / originalHeight) * originalWidth, height };
}; };
const Image: FC<ImageProps> = ({ previewUrl, loadImage = true, setLoadImage, src, ...size }) => { const Image: FC<ImageProps> = ({
previewUrl,
dataSrc,
loadImage = true,
setLoadImage,
src,
...size
}) => {
const limits = useAttachmentDimensions(); const limits = useAttachmentDimensions();
const { width = limits.width, height = limits.height } = size; const { width = limits.width, height = limits.height } = size;
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -63,7 +71,7 @@ const Image: FC<ImageProps> = ({ previewUrl, loadImage = true, setLoadImage, src
{...dimensions} {...dimensions}
is='picture' is='picture'
> >
<img className='gallery-item' src={src} {...dimensions} /> <img className='gallery-item' data-src={dataSrc || src} src={src} {...dimensions} />
</ImageBox> </ImageBox>
); );
}; };

@ -5,6 +5,7 @@ import { IRocketChatRecord } from '../IRocketChatRecord';
import { IUser } from '../IUser'; import { IUser } from '../IUser';
import { ChannelName, RoomID } from '../IRoom'; import { ChannelName, RoomID } from '../IRoom';
import { MessageAttachment } from './MessageAttachment/MessageAttachment'; import { MessageAttachment } from './MessageAttachment/MessageAttachment';
import { FileProp } from './MessageAttachment/Files/FileProp';
type MentionType = 'user' | 'team'; type MentionType = 'user' | 'team';
@ -67,6 +68,8 @@ export interface IMessage extends IRocketChatRecord {
e2e?: 'pending'; e2e?: 'pending';
urls: any; urls: any;
file: any; /** @deprecated Deprecated in favor of files */
file?: FileProp;
files?: FileProp[];
attachments: MessageAttachment[]; attachments: MessageAttachment[];
} }

@ -2820,6 +2820,10 @@
"Message_Attachments": "Message Attachments", "Message_Attachments": "Message Attachments",
"Message_Attachments_GroupAttach": "Group Attachment Buttons", "Message_Attachments_GroupAttach": "Group Attachment Buttons",
"Message_Attachments_GroupAttachDescription": "This groups the icons under an expandable menu. Takes up less screen space.", "Message_Attachments_GroupAttachDescription": "This groups the icons under an expandable menu. Takes up less screen space.",
"Message_Attachments_Thumbnails_Enabled": "Enable image thumbnails to save bandwith",
"Message_Attachments_Thumbnails_Width": "Thumbnail's max width (in pixels)",
"Message_Attachments_Thumbnails_Height": "Thumbnail's max height (in pixels)",
"Message_Attachments_Thumbnails_EnabledDesc": "Thumbnails will be served instead of the original image to reduce bandwith usage. Images at original resolution can be downloaded using the icon next to the attachment's name.",
"Message_Attachments_Strip_Exif": "Remove EXIF metadata from supported files", "Message_Attachments_Strip_Exif": "Remove EXIF metadata from supported files",
"Message_Attachments_Strip_ExifDescription": "Strips out EXIF metadata from image files (jpeg, tiff, etc). This setting is not retroactive, so files uploaded while disabled will have EXIF data", "Message_Attachments_Strip_ExifDescription": "Strips out EXIF metadata from image files (jpeg, tiff, etc). This setting is not retroactive, so files uploaded while disabled will have EXIF data",
"Message_Audio": "Audio Message", "Message_Audio": "Audio Message",

Loading…
Cancel
Save