Link Previews

This commit is contained in:
Scott Nonnenberg 2019-01-15 19:03:56 -08:00
parent 91ef39e482
commit 813924685e
36 changed files with 2298 additions and 134 deletions

View file

@ -616,6 +616,50 @@ async function writeContactAvatars(contact, options) {
}
}
async function writePreviewImage(preview, options) {
const { image } = preview || {};
if (!image || !image.path) {
return;
}
const { dir, message, index, key, newKey } = options;
const name = _getAnonymousAttachmentFileName(message, index);
const filename = `${name}-preview`;
const target = path.join(dir, filename);
await writeEncryptedAttachment(target, image.path, {
key,
newKey,
filename,
dir,
});
}
async function writePreviews(preview, options) {
const { name } = options;
try {
await Promise.all(
_.map(preview, (item, index) =>
writePreviewImage(
item,
Object.assign({}, options, {
index,
})
)
)
);
} catch (error) {
window.log.error(
'writePreviews: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
throw error;
}
}
async function writeEncryptedAttachment(target, source, options = {}) {
const { key, newKey, filename, dir } = options;
@ -752,6 +796,18 @@ async function exportConversation(conversation, options = {}) {
newKey,
});
}
const { preview } = message;
if (preview && preview.length > 0) {
// eslint-disable-next-line no-await-in-loop
await writePreviews(preview, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
}
}
const last = messages.length > 0 ? messages[messages.length - 1] : null;
@ -925,7 +981,18 @@ async function loadAttachments(dir, getName, options) {
})
);
// TODO: Handle video screenshots, and image/video thumbnails
const { preview } = message;
await Promise.all(
_.map(preview, (item, index) => {
const image = item && item.image;
if (!image) {
return null;
}
const name = `${getName(message, index)}-preview`;
return readEncryptedAttachment(dir, image, name, options);
})
);
}
function saveMessage(message) {
@ -1013,8 +1080,9 @@ async function importConversation(dir, options) {
message.quote.attachments &&
message.quote.attachments.length > 0;
const hasContacts = message.contact && message.contact.length;
const hasPreviews = message.preview && message.preview.length;
if (hasAttachments || hasQuotedAttachments || hasContacts) {
if (hasAttachments || hasQuotedAttachments || hasContacts || hasPreviews) {
const importMessage = async () => {
const getName = attachmentsDir
? _getAnonymousAttachmentFileName

176
js/modules/link_previews.js Normal file
View file

@ -0,0 +1,176 @@
/* global URL */
const he = require('he');
const LinkifyIt = require('linkify-it');
const linkify = LinkifyIt();
const { concatenateBytes, getViewOfArrayBuffer } = require('./crypto');
module.exports = {
assembleChunks,
findLinks,
getChunkPattern,
getDomain,
getTitleMetaTag,
getImageMetaTag,
isLinkInWhitelist,
isMediaLinkInWhitelist,
};
const SUPPORTED_DOMAINS = [
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'reddit.com',
'www.reddit.com',
'm.reddit.com',
'imgur.com',
'www.imgur.com',
'm.imgur.com',
'instagram.com',
'www.instagram.com',
'm.instagram.com',
];
function isLinkInWhitelist(link) {
try {
const url = new URL(link);
if (url.protocol !== 'https:') {
return false;
}
if (!url.pathname || url.pathname.length < 2) {
return false;
}
const lowercase = url.host.toLowerCase();
if (!SUPPORTED_DOMAINS.includes(lowercase)) {
return false;
}
return true;
} catch (error) {
return false;
}
}
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com)$/i;
function isMediaLinkInWhitelist(link) {
try {
const url = new URL(link);
if (url.protocol !== 'https:') {
return false;
}
if (!url.pathname || url.pathname.length < 2) {
return false;
}
if (!SUPPORTED_MEDIA_DOMAINS.test(url.host)) {
return false;
}
return true;
} catch (error) {
return false;
}
}
const META_TITLE = /<meta\s+property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
const META_IMAGE = /<meta\s+property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
function _getMetaTag(html, regularExpression) {
const match = regularExpression.exec(html);
if (match && match[1]) {
return he.decode(match[1]).trim();
}
return null;
}
function getTitleMetaTag(html) {
return _getMetaTag(html, META_TITLE);
}
function getImageMetaTag(html) {
return _getMetaTag(html, META_IMAGE);
}
function findLinks(text) {
const matches = linkify.match(text || '') || [];
return matches.map(match => match.text);
}
function getDomain(url) {
try {
const urlObject = new URL(url);
return urlObject.hostname;
} catch (error) {
return null;
}
}
const MB = 1024 * 1024;
const KB = 1024;
function getChunkPattern(size) {
if (size > MB) {
return _getRequestPattern(size, MB);
} else if (size > 500 * KB) {
return _getRequestPattern(size, 500 * KB);
} else if (size > 100 * KB) {
return _getRequestPattern(size, 100 * KB);
} else if (size > 50 * KB) {
return _getRequestPattern(size, 50 * KB);
} else if (size > 10 * KB) {
return _getRequestPattern(size, 10 * KB);
} else if (size > KB) {
return _getRequestPattern(size, KB);
}
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
}
function _getRequestPattern(size, increment) {
const results = [];
let offset = 0;
while (size - offset > increment) {
results.push({
start: offset,
end: offset + increment - 1,
overlap: 0,
});
offset += increment;
}
if (size - offset > 0) {
results.push({
start: size - increment,
end: size - 1,
overlap: increment - (size - offset),
});
}
return results;
}
function assembleChunks(chunkDescriptors) {
const chunks = chunkDescriptors.map((chunk, index) => {
if (index !== chunkDescriptors.length - 1) {
return chunk.data;
}
if (!chunk.overlap) {
return chunk.data;
}
return getViewOfArrayBuffer(
chunk.data,
chunk.overlap,
chunk.data.byteLength
);
});
return concatenateBytes(...chunks);
}

View file

@ -13,6 +13,7 @@ const Util = require('../../ts/util');
const { migrateToSQL } = require('./migrate_to_sql');
const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate');
const LinkPreviews = require('./link_previews');
// Components
const {
@ -55,6 +56,9 @@ const {
const {
SafetyNumberNotification,
} = require('../../ts/components/conversation/SafetyNumberNotification');
const {
StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview');
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
@ -120,6 +124,7 @@ function initializeMigrations({
const attachmentsPath = getPath(userDataPath);
const readAttachmentData = createReader(attachmentsPath);
const loadAttachmentData = Type.loadData(readAttachmentData);
const loadPreviewData = MessageType.loadPreviewData(readAttachmentData);
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
@ -135,8 +140,9 @@ function initializeMigrations({
getPlaceholderMigrations,
getCurrentVersion,
loadAttachmentData,
loadQuoteData,
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
loadPreviewData,
loadQuoteData,
readAttachmentData,
run,
upgradeMessageSchema: (message, options = {}) => {
@ -196,6 +202,7 @@ exports.setup = (options = {}) => {
Quote,
ResetSessionNotification,
SafetyNumberNotification,
StagedLinkPreview,
TimerNotification,
Types: {
Message: MediaGalleryMessage,
@ -226,7 +233,6 @@ exports.setup = (options = {}) => {
};
return {
Metadata,
Backbone,
Components,
Crypto,
@ -234,6 +240,9 @@ exports.setup = (options = {}) => {
Database,
Emoji,
IndexedDB,
LinkPreviews,
Metadata,
migrateToSQL,
Migrations,
Notifications,
OS,
@ -243,6 +252,5 @@ exports.setup = (options = {}) => {
Util,
Views,
Workflow,
migrateToSQL,
};
};

View file

@ -47,6 +47,8 @@ const PRIVATE = 'private';
// Version 9
// - Attachments: Expand the set of unicode characters we filter out of
// attachment filenames
// Version 10
// - Preview: A new type of attachment can be included in a message.
const INITIAL_SCHEMA_VERSION = 0;
@ -232,6 +234,46 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
});
};
// _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) ->
// (Message, Context) ->
// Promise Message
exports._mapPreviewAttachments = upgradeAttachment => async (
message,
context
) => {
if (!message.preview) {
return message;
}
if (!context || !isObject(context.logger)) {
throw new Error('_mapPreviewAttachments: context must have logger object');
}
const { logger } = context;
const upgradeWithContext = async preview => {
const { image } = preview;
if (!image) {
return preview;
}
if (!image.data && !image.path) {
logger.warn('Preview did not have image data; removing it');
return omit(preview, ['image']);
}
const upgradedImage = await upgradeAttachment(image, context);
return Object.assign({}, preview, {
image: upgradedImage,
});
};
const preview = await Promise.all(
(message.preview || []).map(upgradeWithContext)
);
return Object.assign({}, message, {
preview,
});
};
const toVersion0 = async (message, context) =>
exports.initializeSchemaVersion({ message, logger: context.logger });
const toVersion1 = exports._withSchemaVersion({
@ -277,6 +319,10 @@ const toVersion9 = exports._withSchemaVersion({
schemaVersion: 9,
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
});
const toVersion10 = exports._withSchemaVersion({
schemaVersion: 10,
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
});
const VERSIONS = [
toVersion0,
@ -289,9 +335,13 @@ const VERSIONS = [
toVersion7,
toVersion8,
toVersion9,
toVersion10,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// We need dimensions and screenshots for images for proper display
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
// UpgradeStep
exports.upgradeSchema = async (
rawMessage,
@ -408,6 +458,31 @@ exports.loadQuoteData = loadAttachmentData => {
};
};
exports.loadPreviewData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadPreviewData: loadAttachmentData is required');
}
return async preview => {
if (!preview || !preview.length) {
return [];
}
return Promise.all(
preview.map(async () => {
if (!preview.image) {
return preview;
}
return {
...preview,
image: await loadAttachmentData(preview.image),
};
})
);
};
};
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
if (!isFunction(deleteAttachmentData)) {
throw new TypeError(
@ -422,7 +497,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
}
return async message => {
const { attachments, quote, contact } = message;
const { attachments, quote, contact, preview } = message;
if (attachments && attachments.length) {
await Promise.all(attachments.map(deleteAttachmentData));
@ -451,6 +526,18 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
})
);
}
if (preview && preview.length) {
await Promise.all(
preview.map(async item => {
const { image } = item;
if (image && image.path) {
await deleteOnDisk(image.path);
}
})
);
}
};
};
@ -480,11 +567,12 @@ exports.createAttachmentDataWriter = ({
logger,
});
const { attachments, quote, contact } = message;
const { attachments, quote, contact, preview } = message;
const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0) ||
(contact && contact.length > 0);
(contact && contact.length > 0) ||
(preview && preview.length > 0);
if (!hasFilesToWrite) {
return message;
@ -545,11 +633,25 @@ exports.createAttachmentDataWriter = ({
});
};
const writePreviewImage = async item => {
const { image } = item;
if (!image) {
return omit(item, ['image']);
}
await writeExistingAttachmentData(image);
return Object.assign({}, item, {
image: omit(image, ['data']),
});
};
const messageWithoutAttachmentData = Object.assign(
{},
await writeThumbnails(message, { logger }),
{
contact: await Promise.all((contact || []).map(writeContactAvatar)),
preview: await Promise.all((preview || []).map(writePreviewImage)),
attachments: await Promise.all(
(attachments || []).map(async attachment => {
await writeExistingAttachmentData(attachment);

View file

@ -5,9 +5,7 @@ const { Agent } = require('https');
const is = require('@sindresorhus/is');
/* global Buffer: false */
/* global setTimeout: false */
/* global log: false */
/* global Buffer, setTimeout, log, _ */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
@ -166,12 +164,28 @@ const agents = {
auth: null,
};
function getContentType(response) {
if (response.headers && response.headers.get) {
return response.headers.get('content-type');
}
return null;
}
function _promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`;
log.info(
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
);
if (options.disableLogs) {
log.info(
`${options.type} [REDACTED_URL]${
options.unauthenticated ? ' (unauth)' : ''
}`
);
} else {
log.info(
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
);
}
const timeout =
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
@ -195,7 +209,12 @@ function _promiseAjax(providedUrl, options) {
const fetchOptions = {
method: options.type,
body: options.data || null,
headers: { 'X-Signal-Agent': 'OWD' },
headers: {
'User-Agent': 'Signal Desktop (+https://signal.org/download)',
'X-Signal-Agent': 'OWD',
...options.headers,
},
redirect: options.redirect,
agent,
ca: options.certificateAuthority,
timeout,
@ -238,13 +257,20 @@ function _promiseAjax(providedUrl, options) {
response.headers.get('Content-Type') === 'application/json'
) {
resultPromise = response.json();
} else if (options.responseType === 'arraybuffer') {
} else if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
resultPromise = response.buffer();
} else {
resultPromise = response.text();
}
return resultPromise.then(result => {
if (options.responseType === 'arraybuffer') {
if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
// eslint-disable-next-line no-param-reassign
result = result.buffer.slice(
result.byteOffset,
@ -254,8 +280,17 @@ function _promiseAjax(providedUrl, options) {
if (options.responseType === 'json') {
if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) {
log.error(options.type, url, response.status, 'Error');
reject(
if (options.disableLogs) {
log.info(
options.type,
'[REDACTED_URL]',
response.status,
'Error'
);
} else {
log.error(options.type, url, response.status, 'Error');
}
return reject(
HTTPError(
'promiseAjax: invalid response',
response.status,
@ -267,23 +302,47 @@ function _promiseAjax(providedUrl, options) {
}
}
if (response.status >= 0 && response.status < 400) {
log.info(options.type, url, response.status, 'Success');
resolve(result, response.status);
if (options.disableLogs) {
log.info(
options.type,
'[REDACTED_URL]',
response.status,
'Success'
);
} else {
log.info(options.type, url, response.status, 'Success');
}
if (options.responseType === 'arraybufferwithdetails') {
return resolve({
data: result,
contentType: getContentType(response),
response,
});
}
return resolve(result, response.status);
}
if (options.disableLogs) {
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
} else {
log.error(options.type, url, response.status, 'Error');
reject(
HTTPError(
'promiseAjax: error response',
response.status,
result,
options.stack
)
);
}
return reject(
HTTPError(
'promiseAjax: error response',
response.status,
result,
options.stack
)
);
});
})
.catch(e => {
log.error(options.type, url, 0, 'Error');
if (options.disableLogs) {
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
} else {
log.error(options.type, url, 0, 'Error');
}
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
});
@ -342,7 +401,13 @@ module.exports = {
};
// We first set up the data that won't change during this session of the app
function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
function initialize({
url,
cdnUrl,
certificateAuthority,
contentProxyUrl,
proxyUrl,
}) {
if (!is.string(url)) {
throw new Error('WebAPI.initialize: Invalid server url');
}
@ -352,6 +417,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
if (!is.string(certificateAuthority)) {
throw new Error('WebAPI.initialize: Invalid certificateAuthority');
}
if (!is.string(contentProxyUrl)) {
throw new Error('WebAPI.initialize: Invalid contentProxyUrl');
}
// Thanks to function-hoisting, we can put this return statement before all of the
// below function definitions.
@ -372,8 +440,6 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
getAttachment,
getAvatar,
getDevices,
getSenderCertificate,
registerSupportForUnauthenticatedDelivery,
getKeysForNumber,
getKeysForNumberUnauth,
getMessageSocket,
@ -381,15 +447,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
getProfile,
getProfileUnauth,
getProvisioningSocket,
getProxiedSize,
getSenderCertificate,
makeProxiedRequest,
putAttachment,
registerKeys,
registerSupportForUnauthenticatedDelivery,
removeSignalingKey,
requestVerificationSMS,
requestVerificationVoice,
sendMessages,
sendMessagesUnauth,
setSignedPreKey,
updateDeviceName,
removeSignalingKey,
};
function _ajax(param) {
@ -799,6 +869,47 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
);
}
// eslint-disable-next-line no-shadow
async function getProxiedSize(url) {
const result = await _outerAjax(url, {
processData: false,
responseType: 'arraybufferwithdetails',
proxyUrl: contentProxyUrl,
type: 'HEAD',
disableLogs: true,
});
const { response } = result;
if (!response.headers || !response.headers.get) {
throw new Error('getProxiedSize: Problem retrieving header value');
}
const size = response.headers.get('content-length');
return parseInt(size, 10);
}
// eslint-disable-next-line no-shadow
function makeProxiedRequest(url, options = {}) {
const { returnArrayBuffer, start, end } = options;
let headers;
if (_.isNumber(start) && _.isNumber(end)) {
headers = {
Range: `bytes=${start}-${end}`,
};
}
return _outerAjax(url, {
processData: false,
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
proxyUrl: contentProxyUrl,
type: 'GET',
redirect: 'follow',
disableLogs: true,
headers,
});
}
function getMessageSocket() {
log.info('opening message socket', url);
const fixedScheme = url