This repository has been archived by the owner on Sep 11, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 828
/
MFileBody.js
379 lines (345 loc) · 14.8 KB
/
MFileBody.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
/*
Copyright 2015, 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.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import filesize from 'filesize';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {decryptFile} from '../../../utils/DecryptFile';
import Tinter from '../../../Tinter';
import request from 'browser-request';
import Modal from '../../../Modal';
// A cached tinted copy of "img/download.svg"
let tintedDownloadImageURL;
// Track a list of mounted MFileBody instances so that we can update
// the "img/download.svg" when the tint changes.
let nextMountId = 0;
const mounts = {};
/**
* Updates the tinted copy of "img/download.svg" when the tint changes.
*/
function updateTintedDownloadImage() {
// Download the svg as an XML document.
// We could cache the XML response here, but since the tint rarely changes
// it's probably not worth it.
// Also note that we can't use fetch here because fetch doesn't support
// file URLs, which the download image will be if we're running from
// the filesystem (like in an Electron wrapper).
request({uri: "img/download.svg"}, (err, response, body) => {
if (err) return;
const svg = new DOMParser().parseFromString(body, "image/svg+xml");
// Apply the fixups to the XML.
const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]);
Tinter.applySvgFixups(fixups);
// Encoded the fixed up SVG as a data URL.
const svgString = new XMLSerializer().serializeToString(svg);
tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString);
// Notify each mounted MFileBody that the URL has changed.
Object.keys(mounts).forEach(function(id) {
mounts[id].tint();
});
});
}
Tinter.registerTintable(updateTintedDownloadImage);
// User supplied content can contain scripts, we have to be careful that
// we don't accidentally run those script within the same origin as the
// client. Otherwise those scripts written by remote users can read
// the access token and end-to-end keys that are in local storage.
//
// For attachments downloaded directly from the homeserver we can use
// Content-Security-Policy headers to disable script execution.
//
// But attachments with end-to-end encryption are more difficult to handle.
// We need to decrypt the attachment on the client and then display it.
// To display the attachment we need to turn the decrypted bytes into a URL.
//
// There are two ways to turn bytes into URLs, data URL and blob URLs.
// Data URLs aren't suitable for downloading a file because Chrome has a
// 2MB limit on the size of URLs that can be viewed in the browser or
// downloaded. This limit does not seem to apply when the url is used as
// the source attribute of an image tag.
//
// Blob URLs are generated using window.URL.createObjectURL and unfortunately
// for our purposes they inherit the origin of the page that created them.
// This means that any scripts that run when the URL is viewed will be able
// to access local storage.
//
// The easiest solution is to host the code that generates the blob URL on
// a different domain to the client.
// Another possibility is to generate the blob URL within a sandboxed iframe.
// The downside of using a second domain is that it complicates hosting,
// the downside of using a sandboxed iframe is that the browers are overly
// restrictive in what you are allowed to do with the generated URL.
//
// For now given how unusable the blobs generated in sandboxed iframes are we
// default to using a renderer hosted on "usercontent.riot.im". This is
// overridable so that people running their own version of the client can
// choose a different renderer.
//
// To that end the first version of the blob generation will be the following
// html:
//
// <html><head><script>
// window.onmessage=function(e){eval("("+e.data.code+")")(e)}
// </script></head><body></body></html>
//
// This waits to receive a message event sent using the window.postMessage API.
// When it receives the event it evals a javascript function in data.code and
// runs the function passing the event as an argument.
//
// In particular it means that the rendering function can be written as a
// ordinary javascript function which then is turned into a string using
// toString().
//
const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html";
/**
* Render the attachment inside the iframe.
* We can't use imported libraries here so this has to be vanilla JS.
*/
function remoteRender(event) {
const data = event.data;
const img = document.createElement("img");
img.id = "img";
img.src = data.imgSrc;
const a = document.createElement("a");
a.id = "a";
a.rel = data.rel;
a.target = data.target;
a.download = data.download;
a.style = data.style;
a.href = window.URL.createObjectURL(data.blob);
a.appendChild(img);
a.appendChild(document.createTextNode(data.textContent));
const body = document.body;
// Don't display scrollbars if the link takes more than one line
// to display.
body.style = "margin: 0px; overflow: hidden";
body.appendChild(a);
}
/**
* Update the tint inside the iframe.
* We can't use imported libraries here so this has to be vanilla JS.
*/
function remoteSetTint(event) {
const data = event.data;
const img = document.getElementById("img");
img.src = data.imgSrc;
img.style = data.imgStyle;
const a = document.getElementById("a");
a.style = data.style;
}
/**
* Get the current CSS style for a DOMElement.
* @param {HTMLElement} element The element to get the current style of.
* @return {string} The CSS style encoded as a string.
*/
function computedStyle(element) {
if (!element) {
return "";
}
const style = window.getComputedStyle(element, null);
let cssText = style.cssText;
if (cssText == "") {
// Firefox doesn't implement ".cssText" for computed styles.
// https://bugzilla.mozilla.org/show_bug.cgi?id=137687
for (let i = 0; i < style.length; i++) {
cssText += style[i] + ":";
cssText += style.getPropertyValue(style[i]) + ";";
}
}
return cssText;
}
module.exports = React.createClass({
displayName: 'MFileBody',
getInitialState: function() {
return {
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
},
contextTypes: {
appConfig: PropTypes.object,
},
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @params {Object} content The "content" key of the matrix event.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile: function(content) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
if (content.info && content.info.size) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
},
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
},
componentDidMount: function() {
// Add this to the list of mounted components to receive notifications
// when the tint changes.
this.id = nextMountId++;
mounts[this.id] = this;
this.tint();
},
componentWillUnmount: function() {
// Remove this from the list of mounted components
delete mounts[this.id];
},
tint: function() {
// Update our tinted copy of "img/download.svg"
if (this.refs.downloadImage) {
this.refs.downloadImage.src = tintedDownloadImageURL;
}
if (this.refs.iframe) {
// If the attachment is encrypted then the download image
// will be inside the iframe so we wont be able to update
// it directly.
this.refs.iframe.contentWindow.postMessage({
code: remoteSetTint.toString(),
imgSrc: tintedDownloadImageURL,
style: computedStyle(this.refs.dummyLink),
}, "*");
}
},
render: function() {
const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content);
const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl();
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
if (isEncrypted) {
if (this.state.decryptedBlob === null) {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
let decrypting = false;
const decrypt = () => {
if (decrypting) {
return false;
}
decrypting = true;
decryptFile(content.file).then((blob) => {
this.setState({
decryptedBlob: blob,
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}).finally(() => {
decrypting = false;
return;
});
};
return (
<span className="mx_MFileBody" ref="body">
<div className="mx_MFileBody_download">
<a href="javascript:void(0)" onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
</a>
</div>
</span>
);
}
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
code: remoteRender.toString(),
imgSrc: tintedDownloadImageURL,
style: computedStyle(this.refs.dummyLink),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files 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.
download: fileName,
target: "_blank",
textContent: _t("Download %(text)s", { text: text }),
}, "*");
};
// If the attachment is encryped then put the link inside an iframe.
let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER;
if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) {
renderer_url = this.context.appConfig.cross_origin_renderer_url;
}
return (
<span className="mx_MFileBody">
<div className="mx_MFileBody_download">
<div style={{display: "none"}}>
{ /*
* Add dummy copy of the "a" tag
* We'll use it to learn how the download link
* would have been styled if it was rendered inline.
*/ }
<a ref="dummyLink" />
</div>
<iframe src={renderer_url} onLoad={onIframeLoad} ref="iframe" />
</div>
</span>
);
} else if (contentUrl) {
// If the attachment is not encrypted then we check whether we
// are being displayed in the room timeline or in a list of
// files in the right hand side of the screen.
if (this.props.tileShape === "file_grid") {
return (
<span className="mx_MFileBody">
<div className="mx_MFileBody_download">
<a className="mx_ImageBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
{ fileName }
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
</span>
);
} else {
return (
<span className="mx_MFileBody">
<div className="mx_MFileBody_download">
<a href={contentUrl} download={fileName} target="_blank" rel="noopener">
<img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage" />
{ _t("Download %(text)s", { text: text }) }
</a>
</div>
</span>
);
}
} else {
const extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">
{ _t("Invalid file%(extra)s", { extra: extra }) }
</span>;
}
},
});