diff --git a/package.json b/package.json index a6fce492e4..e128d09911 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "babel-runtime": "^6.11.6", + "browser-encrypt-attachment": "^0.1.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", "draft-js": "^0.8.1", diff --git a/src/ContentMessages.js b/src/ContentMessages.js index fd18b22d30..8665c40ae0 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -81,6 +81,24 @@ function infoForVideoFile(videoFile) { return deferred.promise; } +/** + * Read the file as an ArrayBuffer. + * @return {Promise} A promise that resolves with an ArrayBuffer when the file + * is read. + */ +function readFileAsArrayBuffer(file) { + const deferred = q.defer(); + const reader = new FileReader(); + reader.onload = function(e) { + deferred.resolve(e.target.result); + }; + reader.onerror = function(e) { + deferred.reject(e); + }; + reader.readAsArrayBuffer(file); + return deferred.promise; +} + class ContentMessages { constructor() { @@ -149,7 +167,19 @@ class ContentMessages { dis.dispatch({action: 'upload_progress', upload: upload}); } }).then(function(url) { - content.url = url; + if (encryptInfo === null) { + // If the attachment isn't encrypted then include the URL directly. + content.url = url; + } else { + // If the attachment is encrypted then bundle the URL along + // with the information needed to decrypt the attachment and + // add it under a file key. + encryptInfo.url = url; + if (file.type) { + encryptInfo.mimetype = file.type; + } + content.file = encryptInfo; + } return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 6113fa7c6c..ff753621c7 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -21,29 +21,67 @@ import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; +import { decryptFile } from '../../../utils/DecryptFile'; export default class MAudioBody extends React.Component { constructor(props) { super(props); this.state = { - playing: false + playing: false, + decryptedUrl: null, } } - onPlayToggle() { this.setState({ playing: !this.state.playing }); } - render() { + _getContentUrl() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + return this.state.decryptedUrl; + } else { + return MatrixClientPeg.get().mxcUrlToHttp(content.url); + } + } + + componentDidMount() { var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); + if (content.file !== undefined && this.state.decryptedUrl === null) { + decryptFile(content.file).done((url) => { + this.setState({ + decryptedUrl: url + }); + }, (err) => { + console.warn("Unable to decrypt attachment: ", err) + // Set a placeholder image when we can't decrypt the image. + this.refs.image.src = "img/warning.svg"; + }); + } + } + + render() { + const content = this.props.mxEvent.getContent(); + + if (content.file !== undefined && this.state.decryptedUrl === null) { + // Need to decrypt the attachment + // The attachment is decrypted in componentDidMount. + // For now add an img tag with a spinner. + return ( + + {content.body} + + ); + } + + const contentUrl = this._getContentUrl(); return ( - ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index c37cd32c4e..e8c97e5f44 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -16,15 +16,22 @@ limitations under the License. 'use strict'; -var React = require('react'); -var filesize = require('filesize'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var sdk = require('../../../index'); -var dis = require("../../../dispatcher"); +import React from 'react'; +import filesize from 'filesize'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import sdk from '../../../index'; +import {decryptFile} from '../../../utils/DecryptFile'; + module.exports = React.createClass({ displayName: 'MFileBody', + getInitialState: function() { + return { + decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null), + }; + }, + presentableTextForFile: function(content) { var linkText = 'Attachment'; if (content.body && content.body.length > 0) { @@ -47,22 +54,88 @@ module.exports = React.createClass({ return linkText; }, - render: function() { - var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); + _getContentUrl: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + return this.state.decryptedUrl; + } else { + return MatrixClientPeg.get().mxcUrlToHttp(content.url); + } + }, - var httpUrl = cli.mxcUrlToHttp(content.url); - var text = this.presentableTextForFile(content); + componentDidMount: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined && this.state.decryptedUrl === null) { + decryptFile(content.file).done((url) => { + this.setState({ + decryptedUrl: url, + }); + }, (err) => { + console.warn("Unable to decrypt attachment: ", err) + // Set a placeholder image when we can't decrypt the image. + this.refs.image.src = "img/warning.svg"; + }); + } + }, + + render: function() { + const content = this.props.mxEvent.getContent(); + + const text = this.presentableTextForFile(content); var TintableSvg = sdk.getComponent("elements.TintableSvg"); + if (content.file !== undefined && this.state.decryptedUrl === null) { - if (httpUrl) { + // Need to decrypt the attachment + // The attachment is decrypted in componentDidMount. + // For now add an img tag with a spinner. + return ( + + {content.body} + + ); + } + + const contentUrl = this._getContentUrl(); + + const fileName = content.body && content.body.length > 0 ? content.body : "Attachment"; + + var downloadAttr = undefined; + if (this.state.decryptedUrl) { + // If the file is encrypted then we MUST download it rather than displaying it + // because Firefox is vunerable to XSS attacks in data:// URLs + // and all browsers are vunerable to XSS attacks in blob: URLs + // created with window.URL.createObjectURL + // See https://bugzilla.mozilla.org/show_bug.cgi?id=255107 + // See https://w3c.github.io/FileAPI/#originOfBlobURL + // + // This is not a problem for unencrypted links because they are + // either fetched from a different domain so are safe because of + // the same-origin policy or they are fetch from the same domain, + // in which case we trust that the homeserver will set a + // Content-Security-Policy that disables script execution. + // It is reasonable to trust the homeserver in that case since + // it is the same domain that controls this javascript. + // + // We can't apply the same workaround for encrypted files because + // we can't supply HTTP headers when the user clicks on a blob: + // or data:// uri. + // + // We should probably provide a download attribute anyway so that + // the file will have the correct name when the user tries to + // download it. We can't provide a Content-Disposition header + // like we would for HTTP. + downloadAttr = fileName; + } + + if (contentUrl) { if (this.props.tileShape === "file_grid") { return (
- - { content.body && content.body.length > 0 ? content.body : "Attachment" } + + { fileName }
{ content.info && content.info.size ? filesize(content.info.size) : "" } @@ -75,7 +148,7 @@ module.exports = React.createClass({ return (
- + Download {text} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 526fc6a3a5..4a5dbab51e 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -16,14 +16,14 @@ limitations under the License. 'use strict'; -var React = require('react'); -var filesize = require('filesize'); - -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var ImageUtils = require('../../../ImageUtils'); -var Modal = require('../../../Modal'); -var sdk = require('../../../index'); -var dis = require("../../../dispatcher"); +import React from 'react'; +import MFileBody from './MFileBody'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import ImageUtils from '../../../ImageUtils'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import {decryptFile} from '../../../utils/DecryptFile'; module.exports = React.createClass({ displayName: 'MImageBody', @@ -33,13 +33,20 @@ module.exports = React.createClass({ mxEvent: React.PropTypes.object.isRequired, }, + getInitialState: function() { + return { + decryptedUrl: null, + }; + }, + + onClick: function onClick(ev) { if (ev.button == 0 && !ev.metaKey) { ev.preventDefault(); - var content = this.props.mxEvent.getContent(); - var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(content.url); - var ImageView = sdk.getComponent("elements.ImageView"); - var params = { + const content = this.props.mxEvent.getContent(); + const httpUrl = this._getContentUrl(); + const ImageView = sdk.getComponent("elements.ImageView"); + const params = { src: httpUrl, mxEvent: this.props.mxEvent }; @@ -55,7 +62,7 @@ module.exports = React.createClass({ }, _isGif: function() { - var content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); return (content && content.info && content.info.mimetype === "image/gif"); }, @@ -64,9 +71,7 @@ module.exports = React.createClass({ return; } var imgElement = e.target; - imgElement.src = MatrixClientPeg.get().mxcUrlToHttp( - this.props.mxEvent.getContent().url - ); + imgElement.src = this._getContentUrl(); }, onImageLeave: function(e) { @@ -77,14 +82,40 @@ module.exports = React.createClass({ imgElement.src = this._getThumbUrl(); }, + _getContentUrl: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + return this.state.decryptedUrl; + } else { + return MatrixClientPeg.get().mxcUrlToHttp(content.url); + } + }, + _getThumbUrl: function() { - var content = this.props.mxEvent.getContent(); - return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600); + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + // TODO: Decrypt and use the thumbnail file if one is present. + return this.state.decryptedUrl; + } else { + return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600); + } }, componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); this.fixupHeight(); + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined && this.state.decryptedUrl === null) { + decryptFile(content.file).done((url) => { + this.setState({ + decryptedUrl: url, + }); + }, (err) => { + console.warn("Unable to decrypt attachment: ", err) + // Set a placeholder image when we can't decrypt the image. + this.refs.image.src = "img/warning.svg"; + }); + } }, componentWillUnmount: function() { @@ -103,14 +134,13 @@ module.exports = React.createClass({ return; } - var content = this.props.mxEvent.getContent(); - - var thumbHeight = null; - var timelineWidth = this.refs.body.offsetWidth; - var maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px. + const content = this.props.mxEvent.getContent(); + const timelineWidth = this.refs.body.offsetWidth; + const maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px. // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box //console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth); + var thumbHeight = null; if (content.info) { thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); } @@ -119,45 +149,35 @@ module.exports = React.createClass({ }, render: function() { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const content = this.props.mxEvent.getContent(); - var download; - if (this.props.tileShape === "file_grid") { - download = ( -
- - {content.body} - -
- { content.info && content.info.size ? filesize(content.info.size) : "" } -
-
- ); - } - else { - download = ( - + if (content.file !== undefined && this.state.decryptedUrl === null) { + + // Need to decrypt the attachment + // The attachment is decrypted in componentDidMount. + // For now add an img tag with a spinner. + return ( + + {content.body} + ); } - var thumbUrl = this._getThumbUrl(); + const contentUrl = this._getContentUrl(); + const thumbUrl = this._getThumbUrl(); + if (thumbUrl) { return ( - + {content.body} - { download } + ); } else if (content.body) { diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index d29a3ea53e..a32348ea1a 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -16,16 +16,24 @@ limitations under the License. 'use strict'; -var React = require('react'); -var filesize = require('filesize'); - -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var Modal = require('../../../Modal'); -var sdk = require('../../../index'); +import React from 'react'; +import MFileBody from './MFileBody'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Model from '../../../Modal'; +import sdk from '../../../index'; +import { decryptFile } from '../../../utils/DecryptFile'; +import q from 'q'; module.exports = React.createClass({ displayName: 'MVideoBody', + getInitialState: function() { + return { + decryptedUrl: null, + decryptedThumbnailUrl: null, + }; + }, + thumbScale: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even @@ -48,59 +56,92 @@ module.exports = React.createClass({ } }, + _getContentUrl: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + return this.state.decryptedUrl; + } else { + return MatrixClientPeg.get().mxcUrlToHttp(content.url); + } + }, + + _getThumbUrl: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined) { + return this.state.decryptedThumbnailUrl; + } else if (content.info.thumbnail_url) { + return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); + } else { + return null; + } + }, + + componentDidMount: function() { + const content = this.props.mxEvent.getContent(); + if (content.file !== undefined && this.state.decryptedUrl === null) { + var thumbnailPromise = q(null); + if (content.info.thumbnail_file) { + thumbnailPromise = decryptFile( + content.info.thumbnail_file + ); + } + thumbnailPromise.then((thumbnailUrl) => { + decryptFile(content.file).then((contentUrl) => { + this.setState({ + decryptedUrl: contentUrl, + decryptedThumbnailUrl: thumbnailUrl, + }); + }); + }).catch((err) => { + console.warn("Unable to decrypt attachment: ", err) + // Set a placeholder image when we can't decrypt the image. + this.refs.image.src = "img/warning.svg"; + }).done(); + } + }, + render: function() { - var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); + const content = this.props.mxEvent.getContent(); + + if (content.file !== undefined && this.state.decryptedUrl === null) { + // Need to decrypt the attachment + // The attachment is decrypted in componentDidMount. + // For now add an img tag with a spinner. + return ( + + {content.body} + + ); + } + + const contentUrl = this._getContentUrl(); + const thumbUrl = this._getThumbUrl(); var height = null; var width = null; var poster = null; var preload = "metadata"; if (content.info) { - var scale = this.thumbScale(content.info.w, content.info.h, 480, 360); + const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); if (scale) { width = Math.floor(content.info.w * scale); height = Math.floor(content.info.h * scale); } - if (content.info.thumbnail_url) { - poster = cli.mxcUrlToHttp(content.info.thumbnail_url); + if (thumbUrl) { + poster = thumbUrl; preload = "none"; } } - var download; - if (this.props.tileShape === "file_grid") { - download = ( -
- - {content.body} - -
- { content.info && content.info.size ? filesize(content.info.size) : "" } -
-
- ); - } - else { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - download = ( - - ); - } - return ( - - { download } + ); }, diff --git a/src/utils/DecryptFile.js b/src/utils/DecryptFile.js new file mode 100644 index 0000000000..38eab1d073 --- /dev/null +++ b/src/utils/DecryptFile.js @@ -0,0 +1,67 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Pull in the encryption lib so that we can decrypt attachments. +import encrypt from 'browser-encrypt-attachment'; +// Pull in a fetch polyfill so we can download encrypted attachments. +import 'isomorphic-fetch'; +// Grab the client so that we can turn mxc:// URLs into https:// URLS. +import MatrixClientPeg from '../MatrixClientPeg'; +import q from 'q'; + + +/** + * Read blob as a data:// URI. + * @return {Promise} A promise that resolves with the data:// URI. + */ +function readBlobAsDataUri(file) { + var deferred = q.defer(); + var reader = new FileReader(); + reader.onload = function(e) { + deferred.resolve(e.target.result); + }; + reader.onerror = function(e) { + deferred.reject(e); + }; + reader.readAsDataURL(file); + return deferred.promise; +} + + +/** + * Decrypt a file attached to a matrix event. + * @param file {Object} The json taken from the matrix event. + * This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments} + * as the encryption info object, so will also have the those keys in addition to + * the keys below. + * @param file.url {string} An mxc:// URL for the encrypted file. + * @param file.mimetype {string} The MIME-type of the plaintext file. + */ +export function decryptFile(file) { + const url = MatrixClientPeg.get().mxcUrlToHttp(file.url); + // Download the encrypted file as an array buffer. + return q(fetch(url)).then(function(response) { + return response.arrayBuffer(); + }).then(function(responseData) { + // Decrypt the array buffer using the information taken from + // the event content. + return encrypt.decryptAttachment(responseData, file); + }).then(function(dataArray) { + // Turn the array into a Blob and give it the correct MIME-type. + var blob = new Blob([dataArray], {type: file.mimetype}); + return readBlobAsDataUri(blob); + }); +}