Link previews for image URLs

This commit is contained in:
Jordan Rose
2025-06-30 09:41:09 -07:00
committed by GitHub
parent a1b10d1eff
commit e55efbd6ee
3 changed files with 140 additions and 52 deletions
+38 -2
View File
@@ -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
View File
@@ -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, {