99b2bc304e
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
624 lines
17 KiB
TypeScript
624 lines
17 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { RequestInit, Response } from 'node-fetch';
|
|
import { blobToArrayBuffer } from 'blob-util';
|
|
|
|
import type { MIMEType } from '../types/MIME';
|
|
import {
|
|
IMAGE_GIF,
|
|
IMAGE_ICO,
|
|
IMAGE_JPEG,
|
|
IMAGE_PNG,
|
|
IMAGE_WEBP,
|
|
stringToMIMEType,
|
|
} from '../types/MIME';
|
|
import type { LoggerType } from '../types/Logging';
|
|
import { scaleImageToLevel } from '../util/scaleImageToLevel';
|
|
import * as log from '../logging/log';
|
|
|
|
const USER_AGENT = 'WhatsApp/2';
|
|
|
|
const MAX_REQUEST_COUNT_WITH_REDIRECTS = 20;
|
|
|
|
// Lifted from the `fetch` spec [here][0].
|
|
// [0]: https://fetch.spec.whatwg.org/#redirect-status
|
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
|
|
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 1000 kilobytes. However, if the Content-Length is less than
|
|
// this, we won't waste space.
|
|
const MAX_HTML_BYTES_TO_LOAD = 1000 * 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 };
|
|
|
|
export type FetchFn = (href: string, init: RequestInit) => Promise<Response>;
|
|
|
|
export type LinkPreviewMetadata = {
|
|
title: string;
|
|
description: null | string;
|
|
date: null | number;
|
|
imageHref: null | string;
|
|
};
|
|
|
|
export type LinkPreviewImage = {
|
|
data: Uint8Array;
|
|
contentType: MIMEType;
|
|
};
|
|
|
|
type ParsedContentType =
|
|
| { type: null; charset: null }
|
|
| { type: MIMEType; charset: null | string };
|
|
|
|
// This throws non-helpful errors because (1) it logs (2) it will be immediately caught.
|
|
async function fetchWithRedirects(
|
|
fetchFn: FetchFn,
|
|
href: string,
|
|
options: RequestInit,
|
|
logger: Pick<LoggerType, 'warn'> = log
|
|
): Promise<Response> {
|
|
const urlsSeen = new Set<string>();
|
|
|
|
let nextHrefToLoad = href;
|
|
for (let i = 0; i < MAX_REQUEST_COUNT_WITH_REDIRECTS; i += 1) {
|
|
if (urlsSeen.has(nextHrefToLoad)) {
|
|
logger.warn('fetchWithRedirects: found a redirect loop');
|
|
throw new Error('redirect loop');
|
|
}
|
|
urlsSeen.add(nextHrefToLoad);
|
|
|
|
// This `await` is deliberately inside of a loop.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const response = await fetchFn(nextHrefToLoad, {
|
|
...options,
|
|
redirect: 'manual',
|
|
});
|
|
|
|
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
return response;
|
|
}
|
|
|
|
const location = response.headers.get('location');
|
|
if (!location) {
|
|
logger.warn(
|
|
'fetchWithRedirects: got a redirect status code but no Location header; bailing'
|
|
);
|
|
throw new Error('no location with redirect');
|
|
}
|
|
|
|
const newUrl = maybeParseUrl(location, nextHrefToLoad);
|
|
if (newUrl?.protocol !== 'https:') {
|
|
logger.warn(
|
|
'fetchWithRedirects: got a redirect status code and an invalid Location header'
|
|
);
|
|
throw new Error('invalid location');
|
|
}
|
|
|
|
nextHrefToLoad = newUrl.href;
|
|
}
|
|
|
|
logger.warn('fetchWithRedirects: too many redirects');
|
|
throw new Error('too many redirects');
|
|
}
|
|
|
|
function maybeParseUrl(href: string, base: string): null | URL {
|
|
let result: URL;
|
|
try {
|
|
result = new URL(href, base);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
// We never need the hash
|
|
result.hash = '';
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 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: stringToMIMEType(rawType),
|
|
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;
|
|
};
|
|
|
|
type ValidCharset =
|
|
| 'ascii'
|
|
| 'utf8'
|
|
| 'utf-8'
|
|
| 'utf16le'
|
|
| 'ucs2'
|
|
| 'ucs-2'
|
|
| 'base64'
|
|
| 'latin1'
|
|
| 'binary'
|
|
| 'hex';
|
|
|
|
const VALID_CHARSETS = new Set([
|
|
'ascii',
|
|
'utf8',
|
|
'utf-8',
|
|
'utf16le',
|
|
'ucs2',
|
|
'ucs-2',
|
|
'base64',
|
|
'latin1',
|
|
'binary',
|
|
'hex',
|
|
]);
|
|
|
|
const checkCharset = (charSet: string | null): charSet is ValidCharset => {
|
|
if (!charSet) {
|
|
return false;
|
|
}
|
|
return VALID_CHARSETS.has(charSet);
|
|
};
|
|
|
|
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>,
|
|
httpCharset: string | null,
|
|
abortSignal: AbortSignal,
|
|
logger: Pick<LoggerType, 'warn'> = log
|
|
): Promise<HTMLDocument> => {
|
|
let result: HTMLDocument = emptyHtmlDocument();
|
|
|
|
const buffer = new Uint8Array(MAX_HTML_BYTES_TO_LOAD);
|
|
let bytesLoadedSoFar = 0;
|
|
|
|
try {
|
|
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') {
|
|
if (!checkCharset(httpCharset)) {
|
|
throw new Error(`Invalid charset: ${httpCharset}`);
|
|
}
|
|
chunk = Buffer.from(chunk, httpCharset || 'utf8');
|
|
}
|
|
|
|
const truncatedChunk = chunk.slice(0, buffer.length - bytesLoadedSoFar);
|
|
buffer.set(truncatedChunk, bytesLoadedSoFar);
|
|
bytesLoadedSoFar += truncatedChunk.byteLength;
|
|
|
|
result = parseHtmlBytes(buffer.slice(0, bytesLoadedSoFar), httpCharset);
|
|
|
|
const hasLoadedMaxBytes = bytesLoadedSoFar >= buffer.length;
|
|
if (hasLoadedMaxBytes) {
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.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,
|
|
logger: Pick<LoggerType, 'warn'> = log
|
|
): LinkPreviewMetadata | null => {
|
|
const title =
|
|
getOpenGraphContent(document, ['og:title']) || document.title.trim();
|
|
if (!title) {
|
|
logger.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, [
|
|
'apple-touch-icon',
|
|
'apple-touch-icon-precomposed',
|
|
'shortcut icon',
|
|
'icon',
|
|
]);
|
|
const imageUrl = rawImageHref ? maybeParseUrl(rawImageHref, href) : null;
|
|
const imageHref = imageUrl ? imageUrl.href : 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, resolving with `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,
|
|
logger: Pick<LoggerType, 'warn'> = log
|
|
): Promise<null | LinkPreviewMetadata> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetchWithRedirects(
|
|
fetchFn,
|
|
href,
|
|
{
|
|
headers: {
|
|
Accept: 'text/html,application/xhtml+xml',
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
signal: abortSignal,
|
|
},
|
|
logger
|
|
);
|
|
} catch (err) {
|
|
logger.warn(
|
|
'fetchLinkPreviewMetadata: failed to fetch link preview HTML; bailing'
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
`fetchLinkPreviewMetadata: got a ${response.status} status code; bailing`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!response.body) {
|
|
logger.warn('fetchLinkPreviewMetadata: no response body; bailing');
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
!isInlineContentDisposition(response.headers.get('Content-Disposition'))
|
|
) {
|
|
logger.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) {
|
|
logger.warn(
|
|
'fetchLinkPreviewMetadata: Content-Length is too short; bailing'
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const contentType = parseContentType(response.headers.get('Content-Type'));
|
|
if (contentType.type !== 'text/html') {
|
|
logger.warn('fetchLinkPreviewMetadata: Content-Type is not HTML; bailing');
|
|
return null;
|
|
}
|
|
|
|
const document = await getHtmlDocument(
|
|
response.body,
|
|
contentType.charset,
|
|
abortSignal,
|
|
logger
|
|
);
|
|
|
|
// [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, logger);
|
|
}
|
|
|
|
/**
|
|
* This attempts to fetch an image, resolving with `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,
|
|
logger: Pick<LoggerType, 'warn'> = log
|
|
): Promise<null | LinkPreviewImage> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetchWithRedirects(
|
|
fetchFn,
|
|
href,
|
|
{
|
|
headers: {
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
size: MAX_IMAGE_CONTENT_LENGTH,
|
|
signal: abortSignal,
|
|
},
|
|
logger
|
|
);
|
|
} catch (err) {
|
|
logger.warn('fetchLinkPreviewImage: failed to fetch image; bailing');
|
|
return null;
|
|
}
|
|
|
|
if (abortSignal.aborted) {
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
logger.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) {
|
|
logger.warn('fetchLinkPreviewImage: Content-Length is too short; bailing');
|
|
return null;
|
|
}
|
|
if (contentLength > MAX_IMAGE_CONTENT_LENGTH) {
|
|
logger.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)) {
|
|
logger.warn('fetchLinkPreviewImage: Content-Type is not an image; bailing');
|
|
return null;
|
|
}
|
|
|
|
let data: Uint8Array;
|
|
try {
|
|
data = await response.buffer();
|
|
} catch (err) {
|
|
logger.warn('fetchLinkPreviewImage: failed to read body; bailing');
|
|
return null;
|
|
}
|
|
|
|
if (abortSignal.aborted) {
|
|
return null;
|
|
}
|
|
|
|
// Scale link preview image
|
|
if (contentType !== IMAGE_GIF) {
|
|
const dataBlob = new Blob([data], {
|
|
type: contentType,
|
|
});
|
|
const { blob: xcodedDataBlob } = await scaleImageToLevel(
|
|
dataBlob,
|
|
contentType,
|
|
dataBlob.size,
|
|
false
|
|
);
|
|
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
|
|
|
|
data = new Uint8Array(xcodedDataArrayBuffer);
|
|
}
|
|
|
|
return { data, contentType };
|
|
}
|