Outbound link previews

This commit is contained in:
Evan Hahn 2020-09-28 18:46:31 -05:00 committed by Josh Perez
parent bb3ab816dd
commit 313faab774
25 changed files with 2136 additions and 641 deletions

View File

@ -22,5 +22,9 @@ module.exports = ({ config }) => {
config.resolve.extensions = ['.tsx', '.ts', '.jsx', '.js'];
config.externals = {
net: 'net',
};
return config;
};

View File

@ -42,6 +42,30 @@ Signal Desktop makes use of the following open source projects.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## abort-controller
MIT License
Copyright (c) 2017 Toru Nagashima
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## array-move
MIT License
@ -1145,29 +1169,6 @@ Signal Desktop makes use of the following open source projects.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## he
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## history
MIT License

View File

@ -1,23 +1,16 @@
/* global URL */
const { isNumber, compact, isEmpty } = require('lodash');
const he = require('he');
const { isIP } = require('net');
const nodeUrl = require('url');
const LinkifyIt = require('linkify-it');
const linkify = LinkifyIt();
const { concatenateBytes, getViewOfArrayBuffer } = require('../../ts/Crypto');
module.exports = {
assembleChunks,
findLinks,
getChunkPattern,
getDomain,
getTitleMetaTag,
getImageMetaTag,
isLinkSafeToPreview,
isLinkInWhitelist,
isMediaLinkInWhitelist,
isLinkSneaky,
isStickerPack,
};
@ -32,101 +25,10 @@ function isLinkSafeToPreview(link) {
return url.protocol === 'https:' && !isLinkSneaky(link);
}
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',
'pinterest.com',
'www.pinterest.com',
'pin.it',
'signal.art',
];
// This function will soon be removed in favor of `isLinkSafeToPreview`. It is
// currently used because outbound-from-Desktop link previews only support a
// few domains (see the list above). We will soon remove this restriction to
// allow link previews from all domains, making this function obsolete.
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;
}
}
function isStickerPack(link) {
return (link || '').startsWith('https://signal.art/addstickers/');
}
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg\.com|cdninstagram\.com|redd\.it|imgur\.com|fbcdn\.net|pinimg\.com)$/i;
// This function will soon be removed. See the comment in `isLinkInWhitelist`
// for more info.
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"[^>]+?content="([\s\S]+?)"[^>]*>/im;
const META_IMAGE = /<meta\s+property="og:image"[^>]+?content="([\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, caretLocation) {
const haveCaretLocation = isNumber(caretLocation);
const textLength = text ? text.length : 0;
@ -169,81 +71,6 @@ function getDomain(url) {
}
}
const MB = 1024 * 1024;
const KB = 1024;
function getChunkPattern(size, initialOffset) {
if (size > MB) {
return _getRequestPattern(size, MB, initialOffset);
}
if (size > 500 * KB) {
return _getRequestPattern(size, 500 * KB, initialOffset);
}
if (size > 100 * KB) {
return _getRequestPattern(size, 100 * KB, initialOffset);
}
if (size > 50 * KB) {
return _getRequestPattern(size, 50 * KB, initialOffset);
}
if (size > 10 * KB) {
return _getRequestPattern(size, 10 * KB, initialOffset);
}
if (size > KB) {
return _getRequestPattern(size, KB, initialOffset);
}
return {
start: {
start: initialOffset,
end: size - 1,
},
};
}
function _getRequestPattern(size, increment, initialOffset) {
const results = [];
let offset = initialOffset || 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);
}
const ASCII_PATTERN = new RegExp('[\\u0020-\\u007F]', 'g');
function isLinkSneaky(link) {
@ -272,6 +99,11 @@ function isLinkSneaky(link) {
return true;
}
// Domain cannot be an IP address.
if (isIP(domain)) {
return true;
}
// There must be at least 2 domain labels, and none of them can be empty.
const labels = domain.split('.');
if (labels.length < 2 || labels.some(isEmpty)) {

View File

@ -63,6 +63,7 @@
"dependencies": {
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#b10f232fac62ba7f8775c9e086bb5558fe7d948b",
"@sindresorhus/is": "0.8.0",
"abort-controller": "3.0.0",
"array-move": "2.1.0",
"backbone": "1.3.3",
"blob-util": "1.3.0",
@ -88,7 +89,6 @@
"glob": "7.1.6",
"google-libphonenumber": "3.2.6",
"got": "8.2.0",
"he": "1.2.0",
"history": "4.9.0",
"intl-tel-input": "12.1.15",
"jquery": "3.5.0",

View File

@ -555,8 +555,20 @@ try {
});
if (config.environment === 'test') {
// This is a hack to let us run TypeScript tests in the renderer process. See the
// code in `test/index.html`.
const pendingDescribeCalls = [];
window.describe = (...args) => {
pendingDescribeCalls.push(args);
};
/* eslint-disable global-require, import/no-extraneous-dependencies */
require('./ts/test-electron/linkPreviews/linkPreviewFetch_test');
delete window.describe;
window.test = {
pendingDescribeCalls,
fastGlob: require('fast-glob'),
normalizePath: require('normalize-path'),
fse: require('fs-extra'),

View File

@ -352,8 +352,9 @@ message SyncMessage {
optional bool readReceipts = 1;
optional bool unidentifiedDeliveryIndicators = 2;
optional bool typingIndicators = 3;
optional bool linkPreviews = 4;
// 4 is reserved
optional uint32 provisioningVersion = 5;
optional bool linkPreviews = 6;
}
message StickerPackOperation {

View File

@ -98,5 +98,7 @@ message AccountRecord {
optional bool readReceipts = 6;
optional bool sealedSenderIndicators = 7;
optional bool typingIndicators = 8;
optional bool linkPreviews = 9;
optional bool proxiedLinkPreviews = 9;
optional bool noteToSelfUnread = 10;
optional bool linkPreviews = 11;
}

View File

@ -5048,7 +5048,7 @@ button.module-image__border-overlay:focus {
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
align-items: stretch;
min-height: 65px;
}
@ -5073,6 +5073,8 @@ button.module-image__border-overlay:focus {
margin-right: 8px;
}
.module-staged-link-preview__content {
display: flex;
flex-direction: column;
margin-right: 20px;
}
.module-staged-link-preview__title {
@ -5087,14 +5089,46 @@ button.module-image__border-overlay:focus {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.module-staged-link-preview__description {
@include font-body-1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.module-staged-link-preview__footer {
@include font-body-2;
display: flex;
flex-flow: row wrap;
align-items: center;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
> *:not(:first-child) {
display: flex;
&:before {
content: '';
font-size: 50%;
margin-left: 0.2rem;
margin-right: 0.2rem;
}
}
}
.module-staged-link-preview__location {
@include font-body-2;
margin-top: 4px;
text-transform: uppercase;
text-transform: lowercase;
@include light-theme {
color: $color-gray-60;

View File

@ -395,6 +395,11 @@
<!-- Uncomment to start tests without code coverage enabled -->
<script type="text/javascript">
window.Signal.conversationControllerStart();
window.test.pendingDescribeCalls.forEach(args => {
describe(...args);
});
mocha.run();
</script>
</body>

View File

@ -2,12 +2,8 @@ const { assert } = require('chai');
const {
findLinks,
getTitleMetaTag,
getImageMetaTag,
isLinkSafeToPreview,
isLinkInWhitelist,
isLinkSneaky,
isMediaLinkInWhitelist,
} = require('../../js/modules/link_previews');
describe('Link previews', () => {
@ -41,261 +37,6 @@ describe('Link previews', () => {
});
});
describe('#isLinkInWhitelist', () => {
it('returns true for valid links', () => {
assert.strictEqual(isLinkInWhitelist('https://youtube.com/blah'), true);
assert.strictEqual(
isLinkInWhitelist('https://www.youtube.com/blah'),
true
);
assert.strictEqual(isLinkInWhitelist('https://m.youtube.com/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://youtu.be/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://reddit.com/blah'), true);
assert.strictEqual(
isLinkInWhitelist('https://www.reddit.com/blah'),
true
);
assert.strictEqual(isLinkInWhitelist('https://m.reddit.com/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://imgur.com/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://www.imgur.com/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://m.imgur.com/blah'), true);
assert.strictEqual(isLinkInWhitelist('https://instagram.com/blah'), true);
assert.strictEqual(
isLinkInWhitelist('https://www.instagram.com/blah'),
true
);
assert.strictEqual(
isLinkInWhitelist('https://m.instagram.com/blah'),
true
);
assert.strictEqual(isLinkInWhitelist('https://pinterest.com/blah'), true);
assert.strictEqual(
isLinkInWhitelist('https://www.pinterest.com/blah'),
true
);
assert.strictEqual(isLinkInWhitelist('https://pin.it/blah'), true);
});
it('returns false for subdomains', () => {
assert.strictEqual(
isLinkInWhitelist('https://any.subdomain.youtube.com/blah'),
false
);
assert.strictEqual(
isLinkInWhitelist('https://any.subdomain.instagram.com/blah'),
false
);
});
it('returns false for http links', () => {
assert.strictEqual(isLinkInWhitelist('http://instagram.com/blah'), false);
assert.strictEqual(isLinkInWhitelist('http://youtube.com/blah'), false);
});
it('returns false for links with no protocol', () => {
assert.strictEqual(isLinkInWhitelist('instagram.com/blah'), false);
assert.strictEqual(isLinkInWhitelist('youtube.com/blah'), false);
});
it('returns false for link to root path', () => {
assert.strictEqual(isLinkInWhitelist('https://instagram.com'), false);
assert.strictEqual(isLinkInWhitelist('https://youtube.com'), false);
assert.strictEqual(isLinkInWhitelist('https://instagram.com/'), false);
assert.strictEqual(isLinkInWhitelist('https://youtube.com/'), false);
});
it('returns false for other well-known sites', () => {
assert.strictEqual(isLinkInWhitelist('https://facebook.com/blah'), false);
assert.strictEqual(isLinkInWhitelist('https://twitter.com/blah'), false);
});
it('returns false for links that look like our target links', () => {
assert.strictEqual(
isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'),
false
);
assert.strictEqual(
isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'),
false
);
assert.strictEqual(
isLinkInWhitelist('https://sinstagram.com/blah'),
false
);
});
});
describe('#isMediaLinkInWhitelist', () => {
it('returns true for valid links', () => {
assert.strictEqual(
isMediaLinkInWhitelist(
'https://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg'
),
true
);
assert.strictEqual(
isMediaLinkInWhitelist('https://random.cdninstagram.com/blah'),
true
);
assert.strictEqual(
isMediaLinkInWhitelist('https://preview.redd.it/something'),
true
);
assert.strictEqual(
isMediaLinkInWhitelist('https://i.imgur.com/something'),
true
);
assert.strictEqual(
isMediaLinkInWhitelist('https://pinimg.com/something'),
true
);
});
it('returns false for insecure protocol', () => {
assert.strictEqual(
isMediaLinkInWhitelist(
'http://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg'
),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('http://random.cdninstagram.com/blah'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('http://preview.redd.it/something'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('http://i.imgur.com/something'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('http://pinimg.com/something'),
false
);
});
it('returns false for other domains', () => {
assert.strictEqual(
isMediaLinkInWhitelist('https://www.youtube.com/something'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('https://youtu.be/something'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('https://www.instagram.com/something'),
false
);
assert.strictEqual(
isMediaLinkInWhitelist('https://cnn.com/something'),
false
);
});
});
describe('#_getMetaTag', () => {
it('returns html-decoded tag contents from Youtube', () => {
const youtube = `
<meta property="og:site_name" content="YouTube">
<meta property="og:url" content="https://www.youtube.com/watch?v=tP-Ipsat90c">
<meta property="og:type" content="video.other">
<meta property="og:title" content="Randomness is Random - Numberphile">
<meta property="og:image" content="https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg">
`;
assert.strictEqual(
'Randomness is Random - Numberphile',
getTitleMetaTag(youtube)
);
assert.strictEqual(
'https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg',
getImageMetaTag(youtube)
);
});
it('returns html-decoded tag contents from Instagram', () => {
const instagram = `
<meta property="og:site_name" content="Instagram" />
<meta property="og:url" content="https://www.instagram.com/p/BrgpsUjF9Jo/" />
<meta property="og:type" content="instapp:photo" />
<meta property="og:title" content="Walter &#34;MFPallytime&#34; on Instagram: “Lol gg”" />
<meta property="og:description" content="632 Likes, 56 Comments - Walter &#34;MFPallytime&#34; (@mfpallytime) on Instagram: “Lol gg ”" />
<meta property="og:image" content="https://scontent-lax3-1.cdninstagram.com/vp/1c69aa381c2201720c29a6c28de42ffd/5CD49B5B/t51.2885-15/e35/47690175_2275988962411653_1145978227188801192_n.jpg?_nc_ht=scontent-lax3-1.cdninstagram.com" />
`;
assert.strictEqual(
'Walter "MFPallytime" on Instagram: “Lol gg”',
getTitleMetaTag(instagram)
);
assert.strictEqual(
'https://scontent-lax3-1.cdninstagram.com/vp/1c69aa381c2201720c29a6c28de42ffd/5CD49B5B/t51.2885-15/e35/47690175_2275988962411653_1145978227188801192_n.jpg?_nc_ht=scontent-lax3-1.cdninstagram.com',
getImageMetaTag(instagram)
);
});
it('returns html-decoded tag contents from Imgur', () => {
const imgur = `
<meta property="og:site_name" content="Imgur">
<meta property="og:url" content="https://imgur.com/gallery/KFCL8fm">
<meta property="og:type" content="article">
<meta property="og:title" content="&nbsp;">
<meta property="og:description" content="13246 views and 482 votes on Imgur">
<meta property="og:image" content="https://i.imgur.com/Y3wjlwY.jpg?fb">
<meta property="og:image:width" content="600">
<meta property="og:image:height" content="315">
`;
assert.strictEqual('', getTitleMetaTag(imgur));
assert.strictEqual(
'https://i.imgur.com/Y3wjlwY.jpg?fb',
getImageMetaTag(imgur)
);
});
it('returns html-decoded tag contents from Pinterest', () => {
const pinterest = `
<meta property="og:image" name="og:image" content="https://i.pinimg.com/736x/9a/9e/64/9a9e64ed6b42b0a0e480dded4579d940--yard-sale-mulches.jpg" data-app>
<meta property="og:image:height" name="og:image:height" content="200" data-app>
<meta property="og:image:width" name="og:image:width" content="300" data-app>
<meta property="og:title" name="og:title" content="Inexpensive Landscaping Ideas" data-app>
<meta property="og:type" name="og:type" content="pinterestapp:pin" data-app>
<meta property="og:url" name="og:url" content="https://www.pinterest.com/pin/3166662212807634/" data-app>
`;
assert.strictEqual(
'Inexpensive Landscaping Ideas',
getTitleMetaTag(pinterest)
);
assert.strictEqual(
'https://i.pinimg.com/736x/9a/9e/64/9a9e64ed6b42b0a0e480dded4579d940--yard-sale-mulches.jpg',
getImageMetaTag(pinterest)
);
});
it('returns only the first tag', () => {
const html = `
<meta property="og:title" content="First&nbsp;Second&nbsp;Third"><meta property="og:title" content="Fourth&nbsp;Fifth&nbsp;Sixth">
`;
assert.strictEqual('First Second Third', getTitleMetaTag(html));
});
it('handles a newline in attribute value', () => {
const html = `
<meta property="og:title" content="First thing\r\nSecond thing\nThird thing">
`;
assert.strictEqual(
'First thing\r\nSecond thing\nThird thing',
getTitleMetaTag(html)
);
});
});
describe('#findLinks', () => {
it('returns all links if no caretLocation is provided', () => {
const text =
@ -434,6 +175,21 @@ describe('Link previews', () => {
assert.isTrue(isLinkSneaky('https://localhost:3000'));
});
it('returns true if the domain is an IPv4 address', () => {
assert.isTrue(isLinkSneaky('https://127.0.0.1/path'));
assert.isTrue(isLinkSneaky('https://127.0.0.1:1234/path'));
assert.isTrue(isLinkSneaky('https://13.249.138.50/path'));
assert.isTrue(isLinkSneaky('https://13.249.138.50:1234/path'));
});
it('returns true if the domain is an IPv6 address', () => {
assert.isTrue(
isLinkSneaky('https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]/path')
);
assert.isTrue(isLinkSneaky('https://[2001::]/path'));
assert.isTrue(isLinkSneaky('https://[::]/path'));
});
it('returns true if the domain has any empty labels', () => {
assert.isTrue(isLinkSneaky('https://example.'));
assert.isTrue(isLinkSneaky('https://example.com.'));

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import moment, { Moment } from 'moment';
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
interface Props {
date: null | number;
className?: string;
}
export const LinkPreviewDate: React.FC<Props> = ({
date,
className = '',
}: Props) => {
const dateMoment: Moment | null = isLinkPreviewDateValid(date)
? moment(date)
: null;
return dateMoment ? (
<time className={className} dateTime={dateMoment.toISOString()}>
{dateMoment.format('ll')}
</time>
) : null;
};

View File

@ -5,7 +5,6 @@ import Measure from 'react-measure';
import { drop, groupBy, orderBy, take } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper';
import moment, { Moment } from 'moment';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
@ -23,6 +22,7 @@ import {
} from './ReactionViewer';
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate';
import {
AttachmentType,
@ -51,10 +51,8 @@ interface Trigger {
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const MINIMUM_LINK_PREVIEW_DATE = new Date(1990, 0, 1).valueOf();
const STICKER_SIZE = 200;
const SELECTED_TIMEOUT = 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
interface LinkPreviewType {
title: string;
@ -804,14 +802,7 @@ export class Message extends React.PureComponent<Props, State> {
width &&
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
// Don't show old dates or dates too far in the future. This is predicated on the
// idea that showing an invalid dates is worse than hiding valid ones.
const maximumLinkPreviewDate = Date.now() + ONE_DAY;
const isDateValid: boolean =
typeof first.date === 'number' &&
first.date > MINIMUM_LINK_PREVIEW_DATE &&
first.date < maximumLinkPreviewDate;
const dateMoment: Moment | null = isDateValid ? moment(first.date) : null;
const linkPreviewDate = first.date || null;
return (
<button
@ -892,14 +883,10 @@ export class Message extends React.PureComponent<Props, State> {
<div className="module-message__link-preview__location">
{first.domain}
</div>
{dateMoment && (
<time
className="module-message__link-preview__date"
dateTime={dateMoment.toISOString()}
>
{dateMoment.format('ll')}
</time>
)}
<LinkPreviewDate
date={linkPreviewDate}
className="module-message__link-preview__date"
/>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, text, withKnobs } from '@storybook/addon-knobs';
import { boolean, date, text, withKnobs } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { AttachmentType } from '../../types/Attachment';
@ -9,6 +9,11 @@ import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { Props, StagedLinkPreview } from './StagedLinkPreview';
const LONG_TITLE =
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
const LONG_DESCRIPTION =
"You're gonna love this description. Not only does it have a lot of characters, but it will also be truncated in the UI. How cool is that??";
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/StagedLinkPreview', module);
@ -29,8 +34,20 @@ const createAttachment = (
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isLoaded: boolean('isLoaded', overrideProps.isLoaded !== false),
title: text('title', overrideProps.title || ''),
domain: text('domain', overrideProps.domain || ''),
title: text(
'title',
typeof overrideProps.title === 'string'
? overrideProps.title
: 'This is a super-sweet site'
),
description: text(
'description',
typeof overrideProps.description === 'string'
? overrideProps.description
: 'This is a description'
),
date: date('date', new Date(overrideProps.date || 0)),
domain: text('domain', overrideProps.domain || 'signal.org'),
image: overrideProps.image,
i18n,
onClose: action('onClose'),
@ -45,17 +62,28 @@ story.add('Loading', () => {
});
story.add('No Image', () => {
const props = createProps({
title: 'This is a super-sweet site',
domain: 'instagram.com',
});
return <StagedLinkPreview {...createProps()} />;
});
return <StagedLinkPreview {...props} />;
story.add('No Image', () => {
return <StagedLinkPreview {...createProps()} />;
});
story.add('Image', () => {
const props = createProps({
title: 'This is a super-sweet site',
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,
}),
});
return <StagedLinkPreview {...props} />;
});
story.add('Image, No Title Or Description', () => {
const props = createProps({
title: '',
description: '',
domain: 'instagram.com',
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
@ -66,9 +94,17 @@ story.add('Image', () => {
return <StagedLinkPreview {...props} />;
});
story.add('Image, No Title', () => {
story.add('No Image, Long Title With Description', () => {
const props = createProps({
domain: 'instagram.com',
title: LONG_TITLE,
});
return <StagedLinkPreview {...props} />;
});
story.add('Image, Long Title With Description', () => {
const props = createProps({
title: LONG_TITLE,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,
@ -78,21 +114,45 @@ story.add('Image, No Title', () => {
return <StagedLinkPreview {...props} />;
});
story.add('No Image, Long Title', () => {
story.add('No Image, Long Title Without Description', () => {
const props = createProps({
title:
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?",
domain: 'instagram.com',
title: LONG_TITLE,
description: '',
});
return <StagedLinkPreview {...props} />;
});
story.add('Image, Long Title', () => {
story.add('Image, Long Title With Description', () => {
const props = createProps({
title:
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?",
domain: 'instagram.com',
title: LONG_TITLE,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,
}),
});
return <StagedLinkPreview {...props} />;
});
story.add('Image, Long Title And Description', () => {
const props = createProps({
title: LONG_TITLE,
description: LONG_DESCRIPTION,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,
}),
});
return <StagedLinkPreview {...props} />;
});
story.add('Everything: image, title, description, and date', () => {
const props = createProps({
title: LONG_TITLE,
description: LONG_DESCRIPTION,
date: Date.now(),
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,

View File

@ -2,6 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import { Image } from './Image';
import { LinkPreviewDate } from './LinkPreviewDate';
import { AttachmentType, isImageAttachment } from '../../types/Attachment';
import { LocalizerType } from '../../types/Util';
@ -9,6 +10,8 @@ import { LocalizerType } from '../../types/Util';
export interface Props {
isLoaded: boolean;
title: string;
description: null | string;
date: null | number;
domain: string;
image?: AttachmentType;
@ -16,14 +19,16 @@ export interface Props {
onClose?: () => void;
}
export const StagedLinkPreview = ({
export const StagedLinkPreview: React.FC<Props> = ({
isLoaded,
onClose,
i18n,
title,
description,
image,
date,
domain,
}: Props): JSX.Element => {
}: Props) => {
const isImage = image && isImageAttachment(image);
return (
@ -54,7 +59,18 @@ export const StagedLinkPreview = ({
{isLoaded ? (
<div className="module-staged-link-preview__content">
<div className="module-staged-link-preview__title">{title}</div>
<div className="module-staged-link-preview__location">{domain}</div>
{description && (
<div className="module-staged-link-preview__description">
{description}
</div>
)}
<div className="module-staged-link-preview__footer">
<div className="module-staged-link-preview__location">{domain}</div>
<LinkPreviewDate
date={date}
className="module-message__link-preview__date"
/>
</div>
</div>
) : null}
<button

View File

@ -0,0 +1,11 @@
const ONE_DAY = 24 * 60 * 60 * 1000;
export function isLinkPreviewDateValid(value: unknown): value is number {
const maximumLinkPreviewDate = Date.now() + ONE_DAY;
return (
typeof value === 'number' &&
value !== 0 &&
Number.isFinite(value) &&
value < maximumLinkPreviewDate
);
}

View File

@ -0,0 +1,506 @@
import { RequestInit, Response } from 'node-fetch';
import { AbortSignal } from 'abort-controller';
import {
IMAGE_GIF,
IMAGE_ICO,
IMAGE_JPEG,
IMAGE_PNG,
IMAGE_WEBP,
MIMEType,
} from '../types/MIME';
const MAX_CONTENT_TYPE_LENGTH_TO_PARSE = 100;
// Though we'll accept HTML of any Content-Length (including no specified length), we
// will only load some of the HTML. So we might start loading a 99 gigabyte HTML page
// but only parse the first 100 kilobytes. However, if the Content-Length is less than
// this, we won't waste space.
const MAX_HTML_BYTES_TO_LOAD = 100 * 1024;
// `<title>x` is 8 bytes. Nothing else (meta tags, etc) will even fit, so we can ignore
// it. This is mostly to protect us against empty response bodies.
const MIN_HTML_CONTENT_LENGTH = 8;
// Similar to the above. We don't want to show tiny images (even though the more likely
// case is that the Content-Length is 0).
const MIN_IMAGE_CONTENT_LENGTH = 8;
const MAX_IMAGE_CONTENT_LENGTH = 1024 * 1024;
const VALID_IMAGE_MIME_TYPES: Set<MIMEType> = new Set([
IMAGE_GIF,
IMAGE_ICO,
IMAGE_JPEG,
IMAGE_PNG,
IMAGE_WEBP,
]);
// We want to discard unreasonable dates. Update this in ~950 years. (This may discard
// some reasonable dates, which is okay because it is only for link previews.)
const MIN_DATE = 0;
const MAX_DATE = new Date(3000, 0, 1).valueOf();
const emptyContentType = { type: null, charset: null };
type FetchFn = (href: string, init: RequestInit) => Promise<Response>;
export interface LinkPreviewMetadata {
title: string;
description: null | string;
date: null | number;
imageHref: null | string;
}
export interface LinkPreviewImage {
data: ArrayBuffer;
contentType: MIMEType;
}
type ParsedContentType =
| { type: null; charset: null }
| { type: MIMEType; charset: null | string };
/**
* Parses a Content-Type header value. Refer to [RFC 2045][0] for details (though this is
* a simplified version for link previews.
* [0]: https://tools.ietf.org/html/rfc2045
*/
const parseContentType = (headerValue: string | null): ParsedContentType => {
if (!headerValue || headerValue.length > MAX_CONTENT_TYPE_LENGTH_TO_PARSE) {
return emptyContentType;
}
const [rawType, ...rawParameters] = headerValue
.toLowerCase()
.split(/;/g)
.map(part => part.trim())
.filter(Boolean);
if (!rawType) {
return emptyContentType;
}
let charset: null | string = null;
for (let i = 0; i < rawParameters.length; i += 1) {
const rawParameter = rawParameters[i];
const parsed = new URLSearchParams(rawParameter);
const parsedCharset = parsed.get('charset')?.trim();
if (parsedCharset) {
charset = parsedCharset;
break;
}
}
return {
type: rawType as MIMEType,
charset,
};
};
const isInlineContentDisposition = (headerValue: string | null): boolean =>
!headerValue || headerValue.split(';', 1)[0] === 'inline';
const parseContentLength = (headerValue: string | null): number => {
// No need to parse gigantic Content-Lengths; only parse the first 10 digits.
if (typeof headerValue !== 'string' || !/^\d{1,10}$/g.test(headerValue)) {
return Infinity;
}
const result = parseInt(headerValue, 10);
return Number.isNaN(result) ? Infinity : result;
};
const emptyHtmlDocument = (): HTMLDocument =>
new DOMParser().parseFromString('', 'text/html');
// The charset behavior here follows the [W3 guidelines][0]. The priority is BOM, HTTP
// header, `http-equiv` meta tag, `charset` meta tag, and finally a UTF-8 fallback.
// (This fallback could, perhaps, be smarter based on user locale.)
// [0]: https://www.w3.org/International/questions/qa-html-encoding-declarations.en
const parseHtmlBytes = (
bytes: Readonly<Uint8Array>,
httpCharset: string | null
): HTMLDocument => {
const hasBom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
let isSureOfCharset: boolean;
let decoder: TextDecoder;
if (hasBom) {
decoder = new TextDecoder();
isSureOfCharset = true;
} else if (httpCharset) {
try {
decoder = new TextDecoder(httpCharset);
isSureOfCharset = true;
} catch (err) {
decoder = new TextDecoder();
isSureOfCharset = false;
}
} else {
decoder = new TextDecoder();
isSureOfCharset = false;
}
let decoded: string;
try {
decoded = decoder.decode(bytes);
} catch (err) {
decoded = '';
}
let document: HTMLDocument;
try {
document = new DOMParser().parseFromString(decoded, 'text/html');
} catch (err) {
document = emptyHtmlDocument();
}
if (!isSureOfCharset) {
const httpEquiv = document
.querySelector('meta[http-equiv="content-type"]')
?.getAttribute('content');
if (httpEquiv) {
const httpEquivCharset = parseContentType(httpEquiv).charset;
if (httpEquivCharset) {
return parseHtmlBytes(bytes, httpEquivCharset);
}
}
const metaCharset = document
.querySelector('meta[charset]')
?.getAttribute('charset');
if (metaCharset) {
return parseHtmlBytes(bytes, metaCharset);
}
}
return document;
};
const getHtmlDocument = async (
body: AsyncIterable<string | Uint8Array>,
contentLength: number,
httpCharset: string | null,
abortSignal: AbortSignal
): Promise<HTMLDocument> => {
let result: HTMLDocument = emptyHtmlDocument();
const maxHtmlBytesToLoad = Math.min(contentLength, MAX_HTML_BYTES_TO_LOAD);
const buffer = new Uint8Array(new ArrayBuffer(maxHtmlBytesToLoad));
let bytesLoadedSoFar = 0;
try {
// `for ... of` is much cleaner here, so we allow it.
/* eslint-disable no-restricted-syntax */
for await (let chunk of body) {
if (abortSignal.aborted) {
break;
}
// This check exists to satisfy TypeScript; chunk should always be a Buffer.
if (typeof chunk === 'string') {
chunk = Buffer.from(chunk, httpCharset || 'utf8');
}
const truncatedChunk = chunk.slice(
0,
maxHtmlBytesToLoad - bytesLoadedSoFar
);
buffer.set(truncatedChunk, bytesLoadedSoFar);
bytesLoadedSoFar += truncatedChunk.byteLength;
result = parseHtmlBytes(buffer.slice(0, bytesLoadedSoFar), httpCharset);
const hasLoadedMaxBytes = bytesLoadedSoFar >= maxHtmlBytesToLoad;
if (hasLoadedMaxBytes) {
break;
}
const hasFinishedLoadingHead = result.body.innerHTML.length > 0;
if (hasFinishedLoadingHead) {
break;
}
}
/* eslint-enable no-restricted-syntax */
} catch (err) {
window.log.warn(
'getHtmlDocument: error when reading body; continuing with what we got'
);
}
return result;
};
const getOpenGraphContent = (
document: HTMLDocument,
properties: ReadonlyArray<string>
): string | null => {
for (let i = 0; i < properties.length; i += 1) {
const property = properties[i];
const content = document
.querySelector(`meta[property="${property}"]`)
?.getAttribute('content')
?.trim();
if (content) {
return content;
}
}
return null;
};
const getLinkHrefAttribute = (
document: HTMLDocument,
rels: ReadonlyArray<string>
): string | null => {
for (let i = 0; i < rels.length; i += 1) {
const rel = rels[i];
const href = document
.querySelector(`link[rel="${rel}"]`)
?.getAttribute('href')
?.trim();
if (href) {
return href;
}
}
return null;
};
const parseMetadata = (
document: HTMLDocument,
href: string
): LinkPreviewMetadata | null => {
const title =
getOpenGraphContent(document, ['og:title']) || document.title.trim();
if (!title) {
window.log.warn(
"parseMetadata: HTML document doesn't have a title; bailing"
);
return null;
}
const description =
getOpenGraphContent(document, ['og:description']) ||
document
.querySelector('meta[name="description"]')
?.getAttribute('content')
?.trim() ||
null;
const rawImageHref =
getOpenGraphContent(document, ['og:image', 'og:image:url']) ||
getLinkHrefAttribute(document, [
'shortcut icon',
'icon',
'apple-touch-icon',
]);
let imageHref: null | string;
if (rawImageHref) {
try {
imageHref = new URL(rawImageHref, href).href;
} catch (err) {
imageHref = null;
}
} else {
imageHref = null;
}
let date: number | null = null;
const rawDate = getOpenGraphContent(document, [
'og:published_time',
'article:published_time',
'og:modified_time',
'article:modified_time',
]);
if (rawDate) {
const parsed = Date.parse(rawDate);
if (parsed > MIN_DATE && parsed < MAX_DATE) {
date = parsed;
}
}
return {
title,
description,
imageHref,
date,
};
};
/**
* This attempts to fetch link preview metadata, returning `null` if it cannot be found
* for any reason.
*
* NOTE: This does NOT validate the incoming URL for safety. For example, it may fetch an
* insecure HTTP href. It also does not offer a timeout; that is up to the caller.
*
* At a high level, it:
*
* 1. Makes a GET request, following up to 20 redirects (`fetch`'s default).
* 2. Checks the response status code and headers to make sure it's a normal HTML
* response.
* 3. Streams up to `MAX_HTML_BYTES_TO_LOAD`, stopping when (1) it has loaded all of the
* HTML (2) loaded the maximum number of bytes (3) finished loading the `<head>`.
* 4. Parses the resulting HTML with `DOMParser`.
* 5. Grabs the title, description, image URL, and date.
*/
export async function fetchLinkPreviewMetadata(
fetchFn: FetchFn,
href: string,
abortSignal: AbortSignal
): Promise<null | LinkPreviewMetadata> {
let response: Response;
try {
response = await fetchFn(href, {
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'WhatsApp',
},
redirect: 'follow',
signal: abortSignal,
});
} catch (err) {
window.log.warn(
'fetchLinkPreviewMetadata: failed to fetch link preview HTML; bailing'
);
return null;
}
if (!response.ok) {
window.log.warn(
`fetchLinkPreviewMetadata: got a ${response.status} status code; bailing`
);
return null;
}
if (!response.body) {
window.log.warn('fetchLinkPreviewMetadata: no response body; bailing');
return null;
}
if (
!isInlineContentDisposition(response.headers.get('Content-Disposition'))
) {
window.log.warn(
'fetchLinkPreviewMetadata: Content-Disposition header is not inline; bailing'
);
return null;
}
if (abortSignal.aborted) {
return null;
}
const contentLength = parseContentLength(
response.headers.get('Content-Length')
);
if (contentLength < MIN_HTML_CONTENT_LENGTH) {
window.log.warn(
'fetchLinkPreviewMetadata: Content-Length is too short; bailing'
);
return null;
}
const contentType = parseContentType(response.headers.get('Content-Type'));
if (contentType.type !== 'text/html') {
window.log.warn(
'fetchLinkPreviewMetadata: Content-Type is not HTML; bailing'
);
return null;
}
const document = await getHtmlDocument(
response.body,
contentLength,
contentType.charset,
abortSignal
);
// [The Node docs about `ReadableStream.prototype[Symbol.asyncIterator]`][0] say that
// the stream will be destroyed if you `break` out of the loop, but I could not
// reproduce this. Also [`destroy` is a documented method][1] but it is not in the
// Node types, which is why we do this cast to `any`.
// [0]: https://nodejs.org/docs/latest-v12.x/api/stream.html#stream_readable_symbol_asynciterator
// [1]: https://nodejs.org/docs/latest-v12.x/api/stream.html#stream_readable_destroy_error
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response.body as any).destroy();
} catch (err) {
// Ignored.
}
if (abortSignal.aborted) {
return null;
}
return parseMetadata(document, response.url);
}
/**
* This attempts to fetch an image, returning `null` if it fails for any reason.
*
* NOTE: This does NOT validate the incoming URL for safety. For example, it may fetch an
* insecure HTTP href. It also does not offer a timeout; that is up to the caller.
*/
export async function fetchLinkPreviewImage(
fetchFn: FetchFn,
href: string,
abortSignal: AbortSignal
): Promise<null | LinkPreviewImage> {
let response: Response;
try {
response = await fetchFn(href, {
headers: {
'User-Agent': 'WhatsApp',
},
size: MAX_IMAGE_CONTENT_LENGTH,
redirect: 'follow',
signal: abortSignal,
});
} catch (err) {
window.log.warn('fetchLinkPreviewImage: failed to fetch image; bailing');
return null;
}
if (abortSignal.aborted) {
return null;
}
if (!response.ok) {
window.log.warn(
`fetchLinkPreviewImage: got a ${response.status} status code; bailing`
);
return null;
}
const contentLength = parseContentLength(
response.headers.get('Content-Length')
);
if (contentLength < MIN_IMAGE_CONTENT_LENGTH) {
window.log.warn(
'fetchLinkPreviewImage: Content-Length is too short; bailing'
);
return null;
}
if (contentLength > MAX_IMAGE_CONTENT_LENGTH) {
window.log.warn(
'fetchLinkPreviewImage: Content-Length is too large or is unset; bailing'
);
return null;
}
const { type: contentType } = parseContentType(
response.headers.get('Content-Type')
);
if (!contentType || !VALID_IMAGE_MIME_TYPES.has(contentType)) {
window.log.warn(
'fetchLinkPreviewImage: Content-Type is not an image; bailing'
);
return null;
}
let data: ArrayBuffer;
try {
data = await response.arrayBuffer();
} catch (err) {
window.log.warn('fetchLinkPreviewImage: failed to read body; bailing');
return null;
}
return { data, contentType };
}

View File

@ -0,0 +1,11 @@
module.exports = {
rules: {
// We still get the value of this rule, it just allows for dev deps
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
},
};

1
ts/test-electron/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
!.eslintrc.js

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
import { assert } from 'chai';
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
describe('isLinkPreviewDateValid', () => {
it('returns false for non-numbers', () => {
assert.isFalse(isLinkPreviewDateValid(null));
assert.isFalse(isLinkPreviewDateValid(undefined));
assert.isFalse(isLinkPreviewDateValid(Date.now().toString()));
assert.isFalse(isLinkPreviewDateValid(new Date()));
});
it('returns false for zero', () => {
assert.isFalse(isLinkPreviewDateValid(0));
assert.isFalse(isLinkPreviewDateValid(-0));
});
it('returns false for NaN', () => {
assert.isFalse(isLinkPreviewDateValid(0 / 0));
});
it('returns false for any infinite value', () => {
assert.isFalse(isLinkPreviewDateValid(Infinity));
assert.isFalse(isLinkPreviewDateValid(-Infinity));
});
it('returns false for timestamps more than a day from now', () => {
const twoDays = 2 * 24 * 60 * 60 * 1000;
assert.isFalse(isLinkPreviewDateValid(Date.now() + twoDays));
});
it('returns true for timestamps before tomorrow', () => {
assert.isTrue(isLinkPreviewDateValid(Date.now()));
assert.isTrue(isLinkPreviewDateValid(Date.now() + 123));
assert.isTrue(isLinkPreviewDateValid(Date.now() - 123));
assert.isTrue(isLinkPreviewDateValid(new Date(1995, 3, 20).valueOf()));
assert.isTrue(isLinkPreviewDateValid(new Date(1970, 3, 20).valueOf()));
assert.isTrue(isLinkPreviewDateValid(new Date(1969, 3, 20).valueOf()));
assert.isTrue(isLinkPreviewDateValid(1));
});
});

View File

@ -7,6 +7,7 @@
import { Dictionary, without } from 'lodash';
import PQueue from 'p-queue';
import { AbortSignal } from 'abort-controller';
import {
GroupCredentialsType,
@ -37,6 +38,10 @@ import {
} from '../textsecure.d';
import { MessageError, SignedPreKeyRotationError } from './Errors';
import { BodyRangesType } from '../types/Util';
import {
LinkPreviewImage,
LinkPreviewMetadata,
} from '../linkPreviews/linkPreviewFetch';
function stringToArrayBuffer(str: string): ArrayBuffer {
if (typeof str !== 'string') {
@ -272,6 +277,8 @@ class Message {
const item = new window.textsecure.protobuf.DataMessage.Preview();
item.title = preview.title;
item.url = preview.url;
item.description = preview.description || null;
item.date = preview.date || null;
item.image = preview.image || null;
return item;
});
@ -1723,6 +1730,20 @@ export default class MessageSender {
);
}
async fetchLinkPreviewMetadata(
href: string,
abortSignal: AbortSignal
): Promise<null | LinkPreviewMetadata> {
return this.server.fetchLinkPreviewMetadata(href, abortSignal);
}
async fetchLinkPreviewImage(
href: string,
abortSignal: AbortSignal
): Promise<null | LinkPreviewImage> {
return this.server.fetchLinkPreviewImage(href, abortSignal);
}
async makeProxiedRequest(
url: string,
options?: ProxiedRequestOptionsType

View File

@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import fetch, { Response } from 'node-fetch';
import { AbortSignal } from 'abort-controller';
import ProxyAgent from 'proxy-agent';
import { Agent } from 'https';
import pProps from 'p-props';
@ -39,6 +40,8 @@ import {
getRandomValue,
splitUuids,
} from '../Crypto';
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
import {
AvatarUploadAttributesClass,
GroupChangeClass,
@ -761,6 +764,14 @@ export type WebAPIType = {
getUuidsForE164s: (
e164s: ReadonlyArray<string>
) => Promise<Dictionary<string | null>>;
fetchLinkPreviewMetadata: (
href: string,
abortSignal: AbortSignal
) => Promise<null | linkPreviewFetch.LinkPreviewMetadata>;
fetchLinkPreviewImage: (
href: string,
abortSignal: AbortSignal
) => Promise<null | linkPreviewFetch.LinkPreviewImage>;
makeProxiedRequest: (
targetUrl: string,
options?: ProxiedRequestOptionsType
@ -942,6 +953,8 @@ export function initialize({
getStorageManifest,
getStorageRecords,
getUuidsForE164s,
fetchLinkPreviewMetadata,
fetchLinkPreviewImage,
makeProxiedRequest,
modifyGroup,
modifyStorageRecords,
@ -1750,6 +1763,24 @@ export function initialize({
return characters;
}
async function fetchLinkPreviewMetadata(
href: string,
abortSignal: AbortSignal
) {
return linkPreviewFetch.fetchLinkPreviewMetadata(
fetch,
href,
abortSignal
);
}
async function fetchLinkPreviewImage(
href: string,
abortSignal: AbortSignal
) {
return linkPreviewFetch.fetchLinkPreviewImage(fetch, href, abortSignal);
}
async function makeProxiedRequest(
targetUrl: string,
options: ProxiedRequestOptionsType = {}

View File

@ -13057,7 +13057,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.audioRef = react_1.default.createRef();",
"lineNumber": 60,
"lineNumber": 58,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z"
},
@ -13065,7 +13065,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.focusRef = react_1.default.createRef();",
"lineNumber": 61,
"lineNumber": 59,
"reasonCategory": "usageTrusted",
"updated": "2020-09-11T17:24:56.124Z",
"reasonDetail": "Used for managing focus only"
@ -13074,7 +13074,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.reactionsContainerRef = react_1.default.createRef();",
"lineNumber": 62,
"lineNumber": 60,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z",
"reasonDetail": "Used for detecting clicks outside reaction viewer"
@ -13083,7 +13083,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 213,
"lineNumber": 211,
"reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z"
},
@ -13091,7 +13091,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 215,
"lineNumber": 213,
"reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z"
},
@ -13099,7 +13099,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 219,
"lineNumber": 217,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T19:36:40.817Z"
},
@ -13148,6 +13148,24 @@
"updated": "2019-11-21T06:13:49.384Z",
"reasonDetail": "Used for setting focus only"
},
{
"rule": "DOM-innerHTML",
"path": "ts/linkPreviews/linkPreviewFetch.js",
"line": " const hasFinishedLoadingHead = result.body.innerHTML.length > 0;",
"lineNumber": 164,
"reasonCategory": "usageTrusted",
"updated": "2020-09-09T21:20:16.643Z",
"reasonDetail": "This only deals with a fake DOM used when parsing link preview HTML, and it doesn't even change innerHTML."
},
{
"rule": "DOM-innerHTML",
"path": "ts/linkPreviews/linkPreviewFetch.ts",
"line": " const hasFinishedLoadingHead = result.body.innerHTML.length > 0;",
"lineNumber": 215,
"reasonCategory": "usageTrusted",
"updated": "2020-09-09T21:20:16.643Z",
"reasonDetail": "This only deals with a fake DOM used when parsing link preview HTML, and it doesn't even change innerHTML."
},
{
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.js",
@ -13314,7 +13332,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.js",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
"lineNumber": 1212,
"lineNumber": 1228,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
@ -13322,7 +13340,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2068,
"lineNumber": 2099,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}

View File

@ -1,5 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface GetLinkPreviewResult {
title: string;
url: string;
image: {
data: ArrayBuffer;
size: number;
contentType: string;
width: number;
height: number;
};
description: string | null;
date: number | null;
}
const FIVE_MINUTES = 1000 * 60 * 5;
const LINK_PREVIEW_TIMEOUT = 60 * 1000;
window.Whisper = window.Whisper || {};
@ -357,6 +372,8 @@ Whisper.ConversationView = Whisper.View.extend({
this.setupHeader();
this.setupTimeline();
this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] });
this.linkPreviewAbortController = null;
},
events: {
@ -1348,7 +1365,7 @@ Whisper.ConversationView = Whisper.View.extend({
};
},
arrayBufferFromFile(file: any) {
arrayBufferFromFile(file: any): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const FR = new FileReader();
FR.onload = (e: any) => {
@ -2944,7 +2961,7 @@ Whisper.ConversationView = Whisper.View.extend({
const link = links.find(
item =>
window.Signal.LinkPreviews.isLinkInWhitelist(item) &&
window.Signal.LinkPreviews.isLinkSafeToPreview(item) &&
!this.excludedPreviewUrls.includes(item)
);
if (!link) {
@ -2952,7 +2969,6 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
this.currentlyMatchedLink = link;
this.addLinkPreview(link);
},
@ -2969,80 +2985,12 @@ Whisper.ConversationView = Whisper.View.extend({
}
});
this.preview = null;
this.previewLoading = null;
this.currentlyMatchedLink = false;
this.currentlyMatchedLink = null;
this.linkPreviewAbortController?.abort();
this.linkPreviewAbortController = null;
this.renderLinkPreview();
},
async makeChunkedRequest(url: any) {
const PARALLELISM = 3;
const first = await window.textsecure.messaging.makeProxiedRequest(url, {
start: 0,
end: window.Signal.Crypto.getRandomValue(1023, 2047),
returnArrayBuffer: true,
});
const { totalSize, result } = first;
const initialOffset = result.data.byteLength;
const firstChunk = {
start: 0,
end: initialOffset,
...result,
};
const chunks = await window.Signal.LinkPreviews.getChunkPattern(
totalSize,
initialOffset
);
let results: Array<any> = [];
const jobs = chunks.map((chunk: any) => async () => {
const { start, end } = chunk;
const jobResult = await window.textsecure.messaging.makeProxiedRequest(
url,
{
start,
end,
returnArrayBuffer: true,
}
);
return {
...chunk,
...jobResult.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 = window.Signal.LinkPreviews.assembleChunks(
[firstChunk].concat(results)
);
return {
contentType,
data,
};
},
async getStickerPackPreview(url: any) {
const isPackDownloaded = (pack: any) =>
pack && (pack.status === 'downloaded' || pack.status === 'installed');
@ -3105,55 +3053,60 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
async getPreview(url: any) {
async getPreview(
url: string,
abortSignal: any
): Promise<null | GetLinkPreviewResult> {
if (window.Signal.LinkPreviews.isStickerPack(url)) {
return this.getStickerPackPreview(url);
}
let html;
try {
html = await window.textsecure.messaging.makeProxiedRequest(url);
} catch (error) {
if (error.code >= 300) {
return null;
}
// This is already checked elsewhere, but we want to be extra-careful.
if (!window.Signal.LinkPreviews.isLinkSafeToPreview(url)) {
return null;
}
const title = window.Signal.LinkPreviews.getTitleMetaTag(html);
const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html);
const linkPreviewMetadata = await window.textsecure.messaging.fetchLinkPreviewMetadata(
url,
abortSignal
);
if (!linkPreviewMetadata) {
return null;
}
const { title, imageHref, description, date } = linkPreviewMetadata;
let image;
let objectUrl;
try {
if (imageUrl) {
if (!window.Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) {
const primaryDomain = window.Signal.LinkPreviews.getDomain(url);
const imageDomain = window.Signal.LinkPreviews.getDomain(imageUrl);
throw new Error(
`imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}`
);
if (
!abortSignal.aborted &&
imageHref &&
window.Signal.LinkPreviews.isLinkSafeToPreview(imageHref)
) {
let objectUrl: void | string;
try {
const fullSizeImage = await window.textsecure.messaging.fetchLinkPreviewImage(
imageHref,
abortSignal
);
if (!fullSizeImage) {
throw new Error('Failed to fetch link preview image');
}
const chunked = await this.makeChunkedRequest(imageUrl);
// Ensure that this file is either small enough or is resized to meet our
// requirements for attachments
const withBlob = await this.autoScale({
contentType: chunked.contentType,
file: new Blob([chunked.data], {
type: chunked.contentType,
contentType: fullSizeImage.contentType,
file: new Blob([fullSizeImage.data], {
type: fullSizeImage.contentType,
}),
});
const data = await this.arrayBufferFromFile(withBlob.file);
objectUrl = URL.createObjectURL(withBlob.file);
const dimensions = await window.Signal.Types.VisualAttachment.getImageDimensions(
{
objectUrl,
logger: window.log,
}
);
const dimensions = await VisualAttachment.getImageDimensions({
objectUrl,
logger: window.log,
});
image = {
data,
@ -3161,16 +3114,16 @@ Whisper.ConversationView = Whisper.View.extend({
...dimensions,
contentType: withBlob.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);
} 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);
}
}
}
@ -3178,10 +3131,19 @@ Whisper.ConversationView = Whisper.View.extend({
title,
url,
image,
description,
date,
};
},
async addLinkPreview(url: any) {
async addLinkPreview(url: string) {
if (this.currentlyMatchedLink === url) {
window.log.warn(
'addLinkPreview should not be called with the same URL like this'
);
return;
}
(this.preview || []).forEach((item: any) => {
if (item.url) {
URL.revokeObjectURL(item.url);
@ -3189,26 +3151,46 @@ Whisper.ConversationView = Whisper.View.extend({
});
this.preview = null;
// Cancel other in-flight link preview requests.
if (this.linkPreviewAbortController) {
window.log.info(
'addLinkPreview: canceling another in-flight link preview request'
);
this.linkPreviewAbortController.abort();
}
const thisRequestAbortController = new AbortController();
this.linkPreviewAbortController = thisRequestAbortController;
const timeout = setTimeout(() => {
thisRequestAbortController.abort();
}, LINK_PREVIEW_TIMEOUT);
this.currentlyMatchedLink = url;
this.previewLoading = this.getPreview(url);
const promise = this.previewLoading;
this.renderLinkPreview();
try {
const result = await promise;
const result = await this.getPreview(
url,
thisRequestAbortController.signal
);
if (
url !== this.currentlyMatchedLink ||
promise !== this.previewLoading
) {
// another request was started, or this was canceled
return;
}
// If we couldn't pull down the initial URL
if (!result) {
this.excludedPreviewUrls.push(url);
this.removeLinkPreview();
window.log.info(
'addLinkPreview: failed to load preview (not necessarily a problem)'
);
// This helps us disambiguate between two kinds of failure:
//
// 1. We failed to fetch the preview because of (1) a network failure (2) an
// invalid response (3) a timeout
// 2. We failed to fetch the preview because we aborted the request because the
// user changed the link (e.g., by continuing to type the URL)
const failedToFetch = this.currentlyMatchedLink === url;
if (failedToFetch) {
this.excludedPreviewUrls.push(url);
this.removeLinkPreview();
}
return;
}
@ -3232,6 +3214,8 @@ Whisper.ConversationView = Whisper.View.extend({
);
this.disableLinkPreviews = true;
this.removeLinkPreview();
} finally {
clearTimeout(timeout);
}
},

View File

@ -2863,6 +2863,13 @@ abbrev@^1.1.1:
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
abort-controller@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
dependencies:
event-target-shim "^5.0.0"
accepts@~1.3.4, accepts@~1.3.5:
version "1.3.5"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
@ -6913,6 +6920,11 @@ event-loop-spinner@1.1.0, event-loop-spinner@^1.1.0:
dependencies:
tslib "^1.10.0"
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
eventemitter2@~0.4.13:
version "0.4.14"
resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-0.4.14.tgz#8f61b75cde012b2e9eb284d4545583b5643b61ab"