mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-07-13 16:43:53 +01:00
Link previews for image URLs
This commit is contained in:
@@ -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<LoggerType, 'warn'> = log
|
||||
): Promise<null | LinkPreviewImage> {
|
||||
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.
|
||||
}
|
||||
|
||||
+43
-20
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<Uint8Array> {
|
||||
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<Uint8Array> => {
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user