Merge pull request #561 from BeaudanBrown/loki-attachment

Update attachment pipeline to upload and download from loki fileserver
pull/567/head
Beaudan Campbell-Brown 6 years ago committed by GitHub
commit 2adc98a929
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -136,7 +136,7 @@
<div class='capture-audio hide'>
<button class='microphone' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
</div>
<div class='choose-file hide'>
<div class='choose-file'>
<button class='paperclip thumbnail' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<input type='file' class='file-input' multiple='multiple'>
</div>
@ -667,7 +667,6 @@
<script type='text/javascript' src='js/expire.js'></script>
<script type='text/javascript' src='js/conversation_controller.js'></script>
<script type='text/javascript' src='js/blocked_number_controller.js'></script>
<script type='text/javascript' src='js/link_previews_helper.js'></script>
<script type='text/javascript' src='js/message_controller.js'></script>
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script>

@ -1,168 +0,0 @@
/* global
Signal,
textsecure,
StringView
*/
/* eslint-disable no-bitwise */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Signal = window.Signal || {};
window.Signal.LinkPreviews = window.Signal.LinkPreviews || {};
// A cache mapping url to fetched previews
const previewCache = {};
async function makeChunkedRequest(url) {
const PARALLELISM = 3;
const size = await textsecure.messaging.getProxiedSize(url);
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
let results = [];
const jobs = chunks.map(chunk => async () => {
const { start, end } = chunk;
const result = await textsecure.messaging.makeProxiedRequest(url, {
start,
end,
returnArrayBuffer: true,
});
return {
...chunk,
...result,
};
});
while (jobs.length > 0) {
const activeJobs = [];
for (let i = 0, max = PARALLELISM; i < max; i += 1) {
if (!jobs.length) {
break;
}
const job = jobs.shift();
activeJobs.push(job());
}
// eslint-disable-next-line no-await-in-loop
results = results.concat(await Promise.all(activeJobs));
}
if (!results.length) {
throw new Error('No responses received');
}
const { contentType } = results[0];
const data = Signal.LinkPreviews.assembleChunks(results);
return {
contentType,
data,
};
}
async function sha256(string) {
const arraybuffer = new TextEncoder('utf-8').encode(string);
const digest = await window.crypto.subtle.digest('SHA-256', arraybuffer);
return StringView.arrayBufferToHex(digest);
}
// Wrapper function which utilizes cache
async function getPreview(url, skipCache = false) {
// If we have a request cached then use that
if (!skipCache && url in previewCache) {
return previewCache[url];
}
// Start the request
const promise = _getPreview(url).catch(e => {
window.log.error(e);
// If we get an error then we can purge the cache
if (url in previewCache) {
delete previewCache[url];
}
return null;
});
previewCache[url] = promise;
return promise;
}
async function _getPreview(url) {
let html;
try {
html = await textsecure.messaging.makeProxiedRequest(url);
} catch (error) {
if (error.code >= 300) {
throw new Error(`Failed to fetch html: ${error}`);
}
}
const title = Signal.LinkPreviews.getTitleMetaTag(html);
const imageUrl = Signal.LinkPreviews.getImageMetaTag(html);
let image;
let objectUrl;
try {
if (imageUrl) {
if (!Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) {
const primaryDomain = Signal.LinkPreviews.getDomain(url);
const imageDomain = Signal.LinkPreviews.getDomain(imageUrl);
throw new Error(
`imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}`
);
}
const data = await makeChunkedRequest(imageUrl);
// Calculate dimensions
const file = new Blob([data.data], {
type: data.contentType,
});
objectUrl = URL.createObjectURL(file);
const dimensions = await Signal.Types.VisualAttachment.getImageDimensions(
{
objectUrl,
logger: window.log,
}
);
image = {
...data,
...dimensions,
contentType: file.type,
};
}
} catch (error) {
// We still want to show the preview if we failed to get an image
window.log.error(
'getPreview failed to get image for link preview:',
error.message
);
} finally {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}
const hash = await sha256(url);
return {
title,
url,
image,
hash,
};
}
window.Signal.LinkPreviews.helper = {
getPreview,
};
})();

@ -21,13 +21,7 @@
window.Whisper = window.Whisper || {};
const {
Message: TypedMessage,
Contact,
PhoneNumber,
Attachment,
Errors,
} = Signal.Types;
const { Message: TypedMessage, Contact, PhoneNumber, Errors } = Signal.Types;
const {
deleteExternalMessageFiles,
@ -35,7 +29,6 @@
loadAttachmentData,
loadQuoteData,
loadPreviewData,
writeAttachment,
upgradeMessageSchema,
} = window.Signal.Migrations;
const { bytesFromString } = window.Signal.Crypto;
@ -96,9 +89,6 @@
this.on('unload', this.unload);
this.on('expired', this.onExpired);
this.setToExpire();
this.updatePreview();
// Keep props ready
const generateProps = () => {
if (this.isExpirationTimerUpdate()) {
@ -161,73 +151,6 @@
// eslint-disable-next-line no-bitwise
return !!(this.get('flags') & flag);
},
async updatePreview() {
// Don't generate link previews if user has turned them off
if (!storage.get('linkPreviews', false)) {
return;
}
if (this.updatingPreview) {
return;
}
// Only update the preview if we don't have any set
const preview = this.get('preview');
if (!_.isEmpty(preview)) {
return;
}
// Make sure we have links we can preview
const links = Signal.LinkPreviews.findLinks(this.get('body'));
const firstLink = links.find(link =>
Signal.LinkPreviews.isLinkInWhitelist(link)
);
if (!firstLink) {
return;
}
this.updatingPreview = true;
try {
const result = await Signal.LinkPreviews.helper.getPreview(firstLink);
const { image, title, hash } = result;
// A link preview isn't worth showing unless we have either a title or an image
if (!result || !(image || title)) {
this.updatingPreview = false;
return;
}
// Save the image to disk
const { data } = image;
const extension = Attachment.getFileExtension(image);
if (data && extension) {
const hash32 = hash.substring(0, 32);
try {
const filePath = await writeAttachment({
data,
path: `previews/${hash32}.${extension}`,
});
// return the image without the data
result.image = _.omit({ ...image, path: filePath }, 'data');
} catch (e) {
window.log.warn('Failed to write preview to disk', e);
}
}
// Save it!!
this.set({ preview: [result] });
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
} catch (e) {
window.log.warn(`Failed to load previews for message: ${this.id}`);
} finally {
this.updatingPreview = false;
}
},
getEndSessionTranslationKey() {
const sessionType = this.get('endSessionType');
if (sessionType === 'ongoing') {
@ -1872,9 +1795,6 @@
schemaVersion: dataMessage.schemaVersion,
});
// Update the previews if we need to
message.updatePreview();
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
conversation,

@ -220,7 +220,13 @@ class LokiAppDotNetServerAPI {
// make a request to the server
async serverRequest(endpoint, options = {}) {
const { params = {}, method, objBody, forceFreshToken = false } = options;
const {
params = {},
method,
rawBody,
objBody,
forceFreshToken = false,
} = options;
const url = new URL(`${this.baseServerUrl}/${endpoint}`);
if (params) {
url.search = new URLSearchParams(params);
@ -247,6 +253,8 @@ class LokiAppDotNetServerAPI {
if (objBody) {
headers['Content-Type'] = 'application/json';
fetchOptions.body = JSON.stringify(objBody);
} else if (rawBody) {
fetchOptions.body = rawBody;
}
fetchOptions.headers = new Headers(headers);
result = await nodeFetch(url, fetchOptions || undefined);

@ -1,5 +1,7 @@
const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
/* global log */
const DEVICE_MAPPING_ANNOTATION_KEY = 'network.loki.messenger.devicemapping';
// returns the LokiFileServerAPI constructor with the serverUrl already consumed
@ -31,6 +33,30 @@ class LokiFileServerAPI {
content
);
}
async uploadData(data) {
const endpoint = 'files';
const options = {
method: 'POST',
rawBody: data,
};
const { statusCode, response } = await this._server.serverRequest(
endpoint,
options
);
if (statusCode !== 200) {
log.warn('Failed to upload data to fileserver');
return null;
}
const url = response.data && response.data.url;
const id = response.data && response.data.id;
return {
url,
id,
};
}
}
module.exports = LokiFileServerAPIWrapper;

@ -2,10 +2,11 @@ const WebSocket = require('websocket').w3cwebsocket;
const fetch = require('node-fetch');
const ProxyAgent = require('proxy-agent');
const { Agent } = require('https');
const FormData = require('form-data');
const is = require('@sindresorhus/is');
/* global Buffer, setTimeout, log, _ */
/* global Buffer, setTimeout, log, _, lokiFileServerAPI */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
@ -843,41 +844,27 @@ function initialize({
});
}
function getAttachment(id) {
return _ajax({
call: 'attachment',
httpType: 'GET',
urlParameters: `/${id}`,
responseType: 'json',
validateResponse: { location: 'string' },
}).then(response =>
// Using _outerAJAX, since it's not hardcoded to the Signal Server
_outerAjax(response.location, {
contentType: 'application/octet-stream',
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
})
);
function getAttachment(fileUrl) {
return _outerAjax(fileUrl, {
contentType: 'application/octet-stream',
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
});
}
function putAttachment(encryptedBin) {
return _ajax({
call: 'attachment',
httpType: 'GET',
responseType: 'json',
}).then(response =>
// Using _outerAJAX, since it's not hardcoded to the Signal Server
_outerAjax(response.location, {
contentType: 'application/octet-stream',
data: encryptedBin,
processData: false,
proxyUrl,
timeout: 0,
type: 'PUT',
}).then(() => response.idString)
);
const formData = new FormData();
const buffer = Buffer.from(encryptedBin);
formData.append('type', 'network.loki');
formData.append('content', buffer, {
contentType: 'application/octet-stream',
name: 'content',
filename: 'attachment',
});
return lokiFileServerAPI.uploadData(formData);
}
// eslint-disable-next-line no-shadow

@ -6,6 +6,7 @@
Multibase,
TextEncoder,
TextDecoder,
crypto,
dcodeIO
*/
@ -180,6 +181,8 @@
const snodeCipher = new LokiSnodeChannel();
const sha512 = data => crypto.subtle.digest('SHA-512', data);
window.libloki.crypto = {
DHEncrypt,
DHDecrypt,
@ -190,5 +193,6 @@
// for testing
_LokiSnodeChannel: LokiSnodeChannel,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,
sha512,
};
})();

@ -18,6 +18,7 @@
/* global lokiMessageAPI: false */
/* global lokiP2pAPI: false */
/* global feeds: false */
/* global WebAPI: false */
/* eslint-disable more/no-then */
/* eslint-disable no-unreachable */
@ -28,6 +29,7 @@ function MessageReceiver(username, password, signalingKey, options = {}) {
this.signalingKey = signalingKey;
this.username = username;
this.password = password;
this.server = WebAPI.connect({ username, password });
if (!options.serverTrustRoot) {
throw new Error('Server trust root is required!');
@ -1435,10 +1437,8 @@ MessageReceiver.prototype.extend({
};
},
async downloadAttachment(attachment) {
window.log.info('Not downloading attachments.');
return Promise.reject();
const encrypted = await this.server.getAttachment(attachment.id);
// The attachment id is actually just the absolute url of the attachment
const encrypted = await this.server.getAttachment(attachment.url);
const { key, digest, size } = attachment;
const data = await textsecure.crypto.decryptAttachment(

@ -191,32 +191,35 @@ MessageSender.prototype = {
return textsecure.crypto
.encryptAttachment(attachment.data, proto.key, iv)
.then(result =>
this.server.putAttachment(result.ciphertext).then(id => {
proto.id = id;
proto.contentType = attachment.contentType;
proto.digest = result.digest;
if (attachment.size) {
proto.size = attachment.size;
}
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.flags) {
proto.flags = attachment.flags;
}
if (attachment.width) {
proto.width = attachment.width;
}
if (attachment.height) {
proto.height = attachment.height;
}
if (attachment.caption) {
proto.caption = attachment.caption;
}
return proto;
})
this.server
.putAttachment(result.ciphertext)
.then(async ({ url, id }) => {
proto.id = id;
proto.url = url;
proto.contentType = attachment.contentType;
proto.digest = result.digest;
if (attachment.size) {
proto.size = attachment.size;
}
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.flags) {
proto.flags = attachment.flags;
}
if (attachment.width) {
proto.width = attachment.width;
}
if (attachment.height) {
proto.height = attachment.height;
}
if (attachment.caption) {
proto.caption = attachment.caption;
}
return proto;
})
);
},
@ -309,14 +312,6 @@ MessageSender.prototype = {
const message = new Message(attrs);
const silent = false;
// Remove this when we add support for attachments
message.attachments = [];
message.attachmentPointers = [];
message.preview = [];
if (message.quote) {
message.quote.attachments = [];
}
return Promise.all([
this.uploadAttachments(message),
this.uploadThumbnails(message),

@ -320,6 +320,7 @@ message AttachmentPointer {
optional uint32 width = 9;
optional uint32 height = 10;
optional string caption = 11;
optional string url = 101;
}
message GroupContext {

Loading…
Cancel
Save