diff --git a/ts/linkPreviews/linkPreviewFetch.ts b/ts/linkPreviews/linkPreviewFetch.ts index e722ba3f53..2a740e8ac4 100644 --- a/ts/linkPreviews/linkPreviewFetch.ts +++ b/ts/linkPreviews/linkPreviewFetch.ts @@ -64,7 +64,7 @@ export type LinkPreviewMetadata = { title: string; description: null | string; date: null | number; - imageHref: null | string; + image: null | string | LinkPreviewImage; }; export type LinkPreviewImage = { @@ -416,7 +416,7 @@ const parseMetadata = ( return { title, description, - imageHref, + image: imageHref, date, }; }; @@ -502,6 +502,19 @@ export async function fetchLinkPreviewMetadata( const contentType = parseContentType(response.headers.get('Content-Type')); if (contentType.type !== 'text/html') { + if (contentType.type && VALID_IMAGE_MIME_TYPES.has(contentType.type)) { + const image = await processImageResponse(response, abortSignal, logger); + if (image) { + // The best we can do for a title is the URL file name. + const title = lastPathComponentOfUrl(response.url); + return { + title, + description: null, + image, + date: null, + }; + } + } logger.warn('fetchLinkPreviewMetadata: Content-Type is not HTML; bailing'); return null; } @@ -575,6 +588,14 @@ export async function fetchLinkPreviewImage( return null; } + return processImageResponse(response, abortSignal, logger); +} + +async function processImageResponse( + response: Response, + abortSignal: AbortSignal, + logger: Pick = log +): Promise { const contentLength = parseContentLength( response.headers.get('Content-Length') ); @@ -629,3 +650,18 @@ export async function fetchLinkPreviewImage( data = new Uint8Array(xcodedDataArrayBuffer); return { data, contentType: newContentType }; } + +/** + * Tries to extract the last path component of `url`, but may end up returning a + * larger chunk, or even the whole thing. + */ +function lastPathComponentOfUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + const lastSlash = parsed.pathname.lastIndexOf('/'); + return parsed.pathname.substring(lastSlash + 1); // This works with the -1 "not found" value too. +} diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index 6ec1086c61..5d12e44195 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -12,6 +12,7 @@ import type { MaybeGrabLinkPreviewOptionsType, AddLinkPreviewOptionsType, } from '../types/LinkPreview'; +import type { LinkPreviewImage as LinkPreviewFetchImage } from '../linkPreviews/linkPreviewFetch'; import * as Errors from '../types/errors'; import type { StickerPackType as StickerPackDBType } from '../sql/Interface'; import type { MIMEType } from '../types/MIME'; @@ -322,31 +323,53 @@ async function getPreview( abortSignal ); if (!linkPreviewMetadata || abortSignal.aborted) { + log.warn('aborted'); return null; } - const { title, imageHref, description, date } = linkPreviewMetadata; + const { title, image, description, date } = linkPreviewMetadata; - let image; - if (imageHref && LinkPreview.shouldPreviewHref(imageHref)) { + let fetchedImage: LinkPreviewFetchImage | null; + + if (typeof image === 'string') { + if (!LinkPreview.shouldPreviewHref(image)) { + log.warn('refusing to fetch image from provided URL'); + fetchedImage = null; + } else { + try { + const fullSizeImage = await messaging.fetchLinkPreviewImage( + image, + abortSignal + ); + if (abortSignal.aborted) { + return null; + } + if (!fullSizeImage) { + throw new Error('Failed to fetch link preview image'); + } + fetchedImage = fullSizeImage; + } catch (error) { + // We still want to show the preview if we failed to get an image + log.warn( + 'getPreview failed to get image for link preview:', + error.message + ); + fetchedImage = null; + } + } + } else { + fetchedImage = image; + } + + let imageAttachment: LinkPreviewImage | undefined; + if (fetchedImage) { let objectUrl: undefined | string; try { - const fullSizeImage = await messaging.fetchLinkPreviewImage( - imageHref, - abortSignal - ); - if (abortSignal.aborted) { - return null; - } - if (!fullSizeImage) { - throw new Error('Failed to fetch link preview image'); - } - // Ensure that this file is either small enough or is resized to meet our // requirements for attachments const withBlob = await autoScale({ - contentType: fullSizeImage.contentType, - file: new Blob([fullSizeImage.data], { - type: fullSizeImage.contentType, + contentType: fetchedImage.contentType, + file: new Blob([fetchedImage.data], { + type: fetchedImage.contentType, }), fileName: title, highQuality: true, @@ -362,7 +385,7 @@ async function getPreview( logger: log, }); - image = { + imageAttachment = { data, size: data.byteLength, ...dimensions, @@ -373,7 +396,7 @@ async function getPreview( } catch (error) { // We still want to show the preview if we failed to get an image log.error( - 'getPreview failed to get image for link preview:', + 'getPreview failed to process image for link preview:', error.message ); } finally { @@ -390,7 +413,7 @@ async function getPreview( return { date: date || null, description: description || null, - image, + image: imageAttachment, title, url, }; diff --git a/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts b/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts index 9a0e58fdca..02bfd18fb0 100644 --- a/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts +++ b/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts @@ -6,7 +6,7 @@ import { Response } from 'node-fetch'; import * as sinon from 'sinon'; import * as fs from 'fs'; import * as path from 'path'; -import { IMAGE_JPEG, stringToMIMEType } from '../../types/MIME'; +import { IMAGE_JPEG, IMAGE_WEBP, stringToMIMEType } from '../../types/MIME'; import type { LoggerType } from '../../types/Logging'; import { @@ -14,6 +14,14 @@ import { fetchLinkPreviewMetadata, } from '../../linkPreviews/linkPreviewFetch'; +async function readFixtureImage(filename: string): Promise { + const result = await fs.promises.readFile( + path.join(__dirname, '..', '..', '..', 'fixtures', filename) + ); + assert(result.length > 10, `Test failed to read fixture ${filename}`); + return result; +} + describe('link preview fetching', () => { // We'll use this to create a fake `fetch`. We'll want to call `.resolves` or // `.rejects` on it (meaning that it needs to be a Sinon Stub type), but we'll also @@ -114,7 +122,7 @@ describe('link preview fetching', () => { title: 'test title', description: 'test description', date: 1587386096009, - imageHref: 'https://example.com/image.jpg', + image: 'https://example.com/image.jpg', } ); }); @@ -170,7 +178,7 @@ describe('link preview fetching', () => { ); assert.propertyVal( val, - 'imageHref', + 'image', orderedImageHrefSources[i].expectedHref ); } @@ -297,7 +305,7 @@ describe('link preview fetching', () => { title: 'test title', description: null, date: null, - imageHref: null, + image: null, } ); @@ -347,7 +355,7 @@ describe('link preview fetching', () => { title: 'test title', description: null, date: null, - imageHref: null, + image: null, } ); @@ -502,7 +510,7 @@ describe('link preview fetching', () => { title: 'test title', description: null, date: null, - imageHref: null, + image: null, } ); }); @@ -547,7 +555,7 @@ describe('link preview fetching', () => { title: 'test title', description: null, date: null, - imageHref: null, + image: null, } ); }); @@ -577,7 +585,7 @@ describe('link preview fetching', () => { title: '🎉', description: null, date: null, - imageHref: null, + image: null, } ); }); @@ -874,7 +882,7 @@ describe('link preview fetching', () => { title: 'foo bar', description: null, date: null, - imageHref: null, + image: null, } ); @@ -1009,7 +1017,7 @@ describe('link preview fetching', () => { 'https://example.com', new AbortController().signal ), - 'imageHref', + 'image', 'https://example.com/image.jpg' ); }); @@ -1030,7 +1038,7 @@ describe('link preview fetching', () => { 'https://example.com', new AbortController().signal ), - 'imageHref', + 'image', 'https://example.com/assets/image.jpg' ); }); @@ -1052,7 +1060,7 @@ describe('link preview fetching', () => { 'https://foo.example', new AbortController().signal ), - 'imageHref', + 'image', 'https://bar.example/assets/image.jpg' ); }); @@ -1073,7 +1081,7 @@ describe('link preview fetching', () => { 'https://example.com', new AbortController().signal ), - 'imageHref', + 'image', null ); }); @@ -1094,21 +1102,42 @@ describe('link preview fetching', () => { 'https://example.com', new AbortController().signal ), - 'imageHref', + 'image', null ); }); + + it('handles being given an image URL directly', async () => { + const fixture = await readFixtureImage('512x515-thumbs-up-lincoln.webp'); + const fakeFetch = stub().resolves( + new Response(fixture, { + headers: { + 'Content-Type': IMAGE_WEBP, + 'Content-Length': fixture.length.toString(), + }, + url: 'https://example.com/d/lincoln.webp?spurious=true', + }) + ); + + const result = await fetchLinkPreviewMetadata( + fakeFetch, + 'https://example.com/d/lincoln.webp?spurious=true', + new AbortController().signal + ); + assert.hasAllDeepKeys(result, { + title: 'lincoln.webp', + description: null, + date: null, + image: { + contentType: IMAGE_WEBP, + }, + }); + assert(typeof result?.image === 'object'); + assert.isNotEmpty(result?.image?.data); + }); }); describe('fetchLinkPreviewImage', () => { - const readFixture = async (filename: string): Promise => { - const result = await fs.promises.readFile( - path.join(__dirname, '..', '..', '..', 'fixtures', filename) - ); - assert(result.length > 10, `Test failed to read fixture ${filename}`); - return result; - }; - [ { title: 'JPEG', @@ -1144,7 +1173,7 @@ describe('link preview fetching', () => { }, ].forEach(({ title, contentType, scaledContentType, fixtureFilename }) => { it(`handles ${title} images`, async () => { - const fixture = await readFixture(fixtureFilename); + const fixture = await readFixtureImage(fixtureFilename); const fakeFetch = stub().resolves( new Response(fixture, { @@ -1188,7 +1217,7 @@ describe('link preview fetching', () => { }); it("returns null if the response status code isn't 2xx", async () => { - const fixture = await readFixture('kitten-1-64-64.jpg'); + const fixture = await readFixtureImage('kitten-1-64-64.jpg'); await Promise.all( [400, 404, 500, 598].map(async status => { @@ -1221,7 +1250,7 @@ describe('link preview fetching', () => { // Most of the redirect behavior is tested above. it('handles 301 redirects', async () => { - const fixture = await readFixture('kitten-1-64-64.jpg'); + const fixture = await readFixtureImage('kitten-1-64-64.jpg'); const fakeFetch = stub(); fakeFetch.onFirstCall().resolves( @@ -1262,7 +1291,7 @@ describe('link preview fetching', () => { it('returns null if the response is too small', async () => { const fakeFetch = stub().resolves( - new Response(await readFixture('kitten-1-64-64.jpg'), { + new Response(await readFixtureImage('kitten-1-64-64.jpg'), { headers: { 'Content-Type': 'image/jpeg', 'Content-Length': '2', @@ -1288,7 +1317,7 @@ describe('link preview fetching', () => { it('returns null if the response is too large', async () => { const fakeFetch = stub().resolves( - new Response(await readFixture('kitten-1-64-64.jpg'), { + new Response(await readFixtureImage('kitten-1-64-64.jpg'), { headers: { 'Content-Type': 'image/jpeg', 'Content-Length': '123456789', @@ -1313,7 +1342,7 @@ describe('link preview fetching', () => { }); it('returns null if the Content-Type is not a valid image', async () => { - const fixture = await readFixture('kitten-1-64-64.jpg'); + const fixture = await readFixtureImage('kitten-1-64-64.jpg'); await Promise.all( ['', 'image/tiff', 'video/mp4', 'text/plain', 'application/html'].map( @@ -1368,7 +1397,7 @@ describe('link preview fetching', () => { it("doesn't read the image if the request was aborted before reading started", async () => { const abortController = new AbortController(); - const fixture = await readFixture('kitten-1-64-64.jpg'); + const fixture = await readFixtureImage('kitten-1-64-64.jpg'); const fakeFetch = stub().callsFake(() => { const response = new Response(fixture, { @@ -1403,7 +1432,7 @@ describe('link preview fetching', () => { it('returns null if the request was aborted after the image was read', async () => { const abortController = new AbortController(); - const fixture = await readFixture('kitten-1-64-64.jpg'); + const fixture = await readFixtureImage('kitten-1-64-64.jpg'); const fakeFetch = stub().callsFake(() => { const response = new Response(fixture, {