@jsxc/jsxc
Version:
Real-time XMPP chat application with video calls, file transfer and encrypted communication
330 lines (261 loc) • 10.7 kB
text/typescript
import Contact from '../../Contact';
import Message from '../../Message';
import { AbstractPlugin, IMetaData } from '../../plugin/AbstractPlugin';
import PluginAPI from '../../plugin/PluginAPI';
import JID from '../../JID';
import * as Namespace from '../../connection/xmpp/namespace';
import Attachment from '../../Attachment';
import HttpUploadService from './HttpUploadService';
import { IConnection } from '../../connection/Connection.interface';
import { $iq } from '../../vendor/Strophe';
import Translation from '../../util/Translation';
import { IContact } from '@src/Contact.interface';
import { IMessage } from '@src/Message.interface';
import Client from '@src/Client';
const MIN_VERSION = '4.0.0';
const MAX_VERSION = '99.0.0';
const IMAGE_SUFFIXES = ['jpeg', 'jpg', 'png', 'svg', 'gif'];
export default class HttpUploadPlugin extends AbstractPlugin {
public static getId(): string {
return 'http-upload';
}
public static getName(): string {
return 'HTTP File Upload';
}
public static getMetaData(): IMetaData {
return {
description: Translation.t('setting-http-upload-enable'),
xeps: [
{
id: 'XEP-0363',
name: 'HTTP File Upload',
version: '1.0.0',
},
],
};
}
private services: HttpUploadService[];
constructor(pluginAPI: PluginAPI) {
super(MIN_VERSION, MAX_VERSION, pluginAPI);
Namespace.register('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
pluginAPI.addPreSendMessageProcessor(this.preSendMessageProcessor, 20);
pluginAPI.addPreSendMessageStanzaProcessor(this.addBitsOfBinary);
pluginAPI.addAfterReceiveMessageProcessor(this.extractAttachmentFromStanza);
pluginAPI.addAfterReceiveGroupMessageProcessor(this.extractAttachmentFromStanza);
let connection = pluginAPI.getConnection();
connection.registerHandler(this.onBitsOfBinary, 'urn:xmpp:bob', 'iq');
}
private preSendMessageProcessor = (contact: Contact, message: Message): Promise<[Contact, Message]> => {
if (!message.hasAttachment()) {
return Promise.resolve([contact, message]);
}
let attachment = message.getAttachment();
return this.getServices()
.then(services => {
for (let service of services) {
if (service.isSuitable(attachment)) {
return service;
}
this.pluginAPI.Log.debug(
`${service.getJid()} only supports files up to ${service.getMaxFileSize()} bytes`
);
}
throw new Error('Found no suitable http upload service. File probably too large.');
})
.then(service => {
return service.sendFile(attachment.getFile(), (transferred, total) => {
message.updateProgress(transferred, total);
});
})
.then(downloadUrl => {
this.addUrlToMessage(downloadUrl, attachment, message);
attachment.setProcessed(true);
if (!attachment.setData(downloadUrl)) {
message.setErrorMessage(Translation.t('Attachment_too_large_to_store'));
}
})
.catch(err => {
this.pluginAPI.Log.debug(err);
if (err) {
setTimeout(() => {
contact.addSystemMessage(err.toString());
}, 500);
}
})
.then(() => {
return [contact, message];
});
};
private getServices(): Promise<HttpUploadService[]> {
if (this.services) {
return Promise.resolve(this.services);
}
return this.requestServices().then(services => {
this.services = services;
return services;
});
}
private requestServices(): Promise<HttpUploadService[]> {
let connection = this.getConnection();
let ownJid = connection.getJID();
let serverJid = new JID('', ownJid.domain, '');
let discoInfoRepository = this.pluginAPI.getDiscoInfoRepository();
return connection
.getDiscoService()
.getDiscoItems(serverJid)
.then(stanza => {
let promises = [];
$(stanza)
.find('item')
.each((index, element) => {
let jid = new JID('', $(element).attr('jid'), '');
//@TODO cache
let promise = discoInfoRepository.requestDiscoInfo(jid).then(discoInfo => {
let hasFeature = discoInfo.hasFeature(Namespace.get('HTTPUPLOAD'));
if (hasFeature) {
let maxFileSize = 0;
let form = discoInfo.getFormByType(Namespace.get('HTTPUPLOAD'));
if (form) {
let values = form.getValues('max-file-size') || [];
if (values.length === 1) {
maxFileSize = parseInt(values[0], 10);
}
}
return new HttpUploadService(this.pluginAPI, jid, maxFileSize);
}
});
promises.push(promise);
});
return Promise.all(promises).then(results => {
return results.filter(service => typeof service !== 'undefined');
});
});
}
private getConnection(): IConnection {
return this.pluginAPI.getConnection();
}
private addUrlToMessage(downloadUrl: string, attachment: Attachment, message: Message) {
let plaintext = message.getPlaintextMessage();
message.setPlaintextMessage(downloadUrl + '\n' + plaintext);
let html = $('<div>').append(message.getHtmlMessage());
let linkElement = $('<a>');
linkElement.attr('href', downloadUrl);
let imageElement = $('<img>');
imageElement.attr('src', 'cid:' + attachment.getUid());
imageElement.attr('alt', attachment.getName());
linkElement.append(imageElement);
html.append($('<p>').append(linkElement));
//@TODO html !== empty ???
if (plaintext) {
html.append($('<p>').text(plaintext));
}
message.setHtmlMessage(html.html());
}
private onBitsOfBinary = (stanza: string): boolean => {
let stanzaElement = $(stanza);
let from = new JID(stanzaElement.attr('from'));
let type = stanzaElement.attr('type');
let id = stanzaElement.attr('id');
let cid = stanzaElement.find('data[xmlns="urn:xmpp:bob"]').attr('cid');
if (type !== 'get') {
return true;
}
let attachment = new Attachment(cid); //@REVIEW security
if (attachment.hasThumbnailData()) {
let iq = $iq({
to: from.full,
id,
type: 'result',
})
.c('data', {
xmlns: 'urn:xmpp:bob',
cid: attachment.getUid(),
type: attachment.getMimeType(),
})
.t(attachment.getThumbnailData().replace(/^[^,],+/, ''));
this.pluginAPI.sendIQ(iq);
}
return true;
};
private addBitsOfBinary = (message: Message, xmlStanza: Strophe.Builder): Promise<any> => {
//@TODO check if element with cid exists
if (message.hasAttachment() && message.getAttachment().hasThumbnailData()) {
let attachment = message.getAttachment();
let thumbnailData = attachment.getThumbnailData();
xmlStanza
.c('data', {
xmlns: 'urn:xmpp:bob',
cid: attachment.getUid(),
type: thumbnailData.match(/data:(\w+\/[\w-+\d.]+)(?=;|,)/)[1],
})
.t(thumbnailData.replace(/^[^,],+/, ''))
.up();
}
return Promise.resolve([message, xmlStanza]);
};
private extractAttachmentFromStanza = (
contact: IContact,
message: IMessage,
stanza: Element
): Promise<[IContact, IMessage, Element]> => {
let element = $(stanza);
let bodyElement = element.find('html body[xmlns="' + Strophe.NS.XHTML + '"]').first();
let dataElement = element.find('data[xmlns="urn:xmpp:bob"]');
if (bodyElement.length && dataElement.length === 1 && !message.isEncrypted()) {
let cid = dataElement.attr('cid');
let mimeType = dataElement.attr('type');
if (!/^[a-z]+\/[a-z0-9.\-+]+$/.test(mimeType)) {
return Promise.resolve([contact, message, stanza]);
}
let linkElement = bodyElement.find('a');
let imageElement = linkElement.find('img[src^="cid:"]');
if (imageElement.length === 1 && 'cid:' + cid === imageElement.attr('src')) {
let url = linkElement.attr('href');
let name = imageElement.attr('alt');
let thumbnailData = dataElement.text();
if (
/^data:image\/(jpeg|jpg|gif|png|svg);base64,[/+=a-z0-9]+$/i.test(thumbnailData) &&
/^https?:\/\//.test(url)
) {
let attachment = new Attachment(name, mimeType, url);
attachment.setThumbnailData(thumbnailData);
attachment.setData(url);
message.setAttachment(attachment);
message.setPlaintextMessage(bodyElement.text());
}
}
} else {
this.processLinks(message);
}
return Promise.resolve([contact, message, stanza]);
};
private processLinks(message: IMessage) {
let plaintext = message.getPlaintextMessage();
if (!plaintext) {
return;
}
let pattern = new RegExp(/^(https?:\/\/[^\s]+)/);
let match = plaintext.match(pattern);
if (match) {
let url = match[0];
let extension = this.getFileExtensionFromUrl(url);
if (IMAGE_SUFFIXES.includes(extension)) {
let fileName = this.getFileNameFromUrl(url) || 'image';
let attachment = new Attachment(decodeURIComponent(fileName), 'image/' + extension, url);
attachment.setData(url);
if (Client.isTrustedDomain(new URL(url))) {
attachment.generateThumbnail();
}
message.setAttachment(attachment);
message.setPlaintextMessage(plaintext.replace(url, ''));
}
}
}
private getFileExtensionFromUrl(url: string): string {
return url.split(/[#?]/)[0].split('.').pop().trim().toLowerCase();
}
private getFileNameFromUrl(url: string): string {
let parsedUrl = new URL(url);
return parsedUrl.pathname.substring(parsedUrl.pathname.lastIndexOf('/') + 1);
}
}