diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js index 8ae832d030c..3dd00097063 100644 --- a/ghost/core/core/server/services/oembed/oembed-service.js +++ b/ghost/core/core/server/services/oembed/oembed-service.js @@ -13,6 +13,7 @@ const crypto = require('crypto'); // Some sites block non-standard user agents so we need to mimic a typical browser // Note: the Ghost/5.0 string _may_ be in use by 3rd parties so use caution when updating across majors const USER_AGENT = 'Mozilla/5.0 (compatible; Ghost/5.0; +https://ghost.org/)'; +const DEFAULT_BOOKMARK_ICON = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; // metascraper-amazon's built-in URL test is a substring regex that misfires on // any host ending in a letter followed by `.co/` (e.g. `rangemedia.co`), causing @@ -163,11 +164,15 @@ class OEmbedService { /** * Process and store image from a URL - * @param {string} imageUrl - URL of the image to process + * @param {string|null|undefined} imageUrl - URL of the image to process * @param {string} imageType - What is the image used for. Example - icon, thumbnail - * @returns {Promise} - URL where the image is stored + * @returns {Promise} - URL where the image is stored */ async processImageFromUrl(imageUrl, imageType) { + if (!imageUrl) { + return null; + } + // Fetch image buffer from the URL const imageBuffer = await this.fetchImageBuffer(imageUrl); const store = this.storage.getStorage('images'); @@ -184,6 +189,59 @@ class OEmbedService { return store.saveRaw(imageBuffer, targetPath); } + /** + * Build bookmark metadata from a known oEmbed provider without exposing the + * provider-supplied HTML that embed cards use. + * + * @param {string} url + * @returns {Promise} + */ + async fetchBookmarkDataFromKnownProvider(url) { + const {url: providerUrl, provider} = findUrlWithProvider(url); + if (!provider) { + return; + } + + let oembed; + try { + oembed = await this.knownProvider(providerUrl); + } catch { + // Known-provider metadata is an enhancement for explicit bookmark + // cards. If it fails, fall back to scraping the page as before. + return; + } + + if (!oembed.title) { + return; + } + + const metadata = { + url, + title: oembed.title, + description: oembed.description || null, + author: oembed.author_name || null, + publisher: oembed.provider_name || null, + thumbnail: oembed.thumbnail_url || (oembed.type === 'photo' ? oembed.url : null), + icon: DEFAULT_BOOKMARK_ICON + }; + + if (metadata.thumbnail) { + await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') + .then((processedImageUrl) => { + metadata.thumbnail = processedImageUrl; + }).catch((err) => { + logging.error(err); + }); + } + + return { + version: '1.0', + type: 'bookmark', + url, + metadata + }; + } + /** * @param {string} url * @param {Object} options @@ -391,24 +449,31 @@ class OEmbedService { try { await this.externalRequest.head(metadata.icon); } catch (err) { - metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; + metadata.icon = DEFAULT_BOOKMARK_ICON; logging.error(err); } } } else { - await this.processImageFromUrl(metadata.icon, 'icon') - .then((processedImageUrl) => { - metadata.icon = processedImageUrl; - }).catch((err) => { - metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; - logging.error(err); - }); - await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') - .then((processedImageUrl) => { - metadata.thumbnail = processedImageUrl; - }).catch((err) => { - logging.error(err); - }); + if (metadata.icon) { + await this.processImageFromUrl(metadata.icon, 'icon') + .then((processedImageUrl) => { + metadata.icon = processedImageUrl; + }).catch((err) => { + metadata.icon = DEFAULT_BOOKMARK_ICON; + logging.error(err); + }); + } else { + metadata.icon = DEFAULT_BOOKMARK_ICON; + } + + if (metadata.thumbnail) { + await this.processImageFromUrl(metadata.thumbnail, 'thumbnail') + .then((processedImageUrl) => { + metadata.thumbnail = processedImageUrl; + }).catch((err) => { + logging.error(err); + }); + } } return { @@ -552,6 +617,13 @@ class OEmbedService { } } + if (type === 'bookmark') { + const knownProviderBookmark = await this.fetchBookmarkDataFromKnownProvider(url); + if (knownProviderBookmark) { + return knownProviderBookmark; + } + } + // Not in the list, we need to fetch the content const {url: pageUrl, body, contentType} = await this.fetchPageHtml(url, fetchOptions); diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index 7047dba32ee..a37a3eb8dab 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -272,6 +272,105 @@ describe('oembed-service', function () { assert.equal(response.metadata.title, 'Example'); }); + it('uses allowlisted provider metadata for explicit YouTube bookmark requests', async function () { + const thumbnailUrl = 'https://i.ytimg.com/vi/0i1Xz-xiYSU/hqdefault.jpg'; + const processImageFromUrlStub = sinon.stub(oembedService, 'processImageFromUrl') + .resolves('/content/images/thumbnail/youtube.jpg'); + + nock('https://www.youtube.com') + .get('/oembed') + .query(true) + .reply(200, { + title: 'Meet the people who fly spacecraft, without ever leaving Earth!', + author_name: 'Matt Gray', + author_url: 'https://www.youtube.com/@MattGrayYES', + type: 'video', + version: '1.0', + provider_name: 'YouTube', + provider_url: 'https://www.youtube.com/', + thumbnail_url: thumbnailUrl, + html: '', + width: 200, + height: 113 + }); + + try { + const response = await oembedService.fetchOembedDataFromUrl('https://www.youtube.com/watch?v=0i1Xz-xiYSU', 'bookmark'); + + assert.equal(response.version, '1.0'); + assert.equal(response.type, 'bookmark'); + assert.equal(response.url, 'https://www.youtube.com/watch?v=0i1Xz-xiYSU'); + assert.equal(response.metadata.url, 'https://www.youtube.com/watch?v=0i1Xz-xiYSU'); + assert.equal(response.metadata.title, 'Meet the people who fly spacecraft, without ever leaving Earth!'); + assert.equal(response.metadata.author, 'Matt Gray'); + assert.equal(response.metadata.publisher, 'YouTube'); + assert.equal(response.metadata.thumbnail, '/content/images/thumbnail/youtube.jpg'); + assert.equal(response.metadata.icon, 'https://static.ghost.org/v5.0.0/images/link-icon.svg'); + sinon.assert.calledOnceWithExactly(processImageFromUrlStub, thumbnailUrl, 'thumbnail'); + } finally { + sinon.restore(); + } + }); + + it('does not process missing bookmark images', async function () { + const processImageFromUrlStub = sinon.stub(oembedService, 'processImageFromUrl') + .callsFake(async imageUrl => imageUrl); + + try { + const response = await oembedService.fetchBookmarkData('https://www.example.com', 'Example', 'bookmark'); + + assert.equal(response.metadata.title, 'Example'); + assert.equal(response.metadata.thumbnail, null); + assert.equal(response.metadata.icon, 'https://static.ghost.org/v5.0.0/images/link-icon.svg'); + sinon.assert.notCalled(processImageFromUrlStub); + } finally { + sinon.restore(); + } + }); + + // Regression coverage for https://github.com/TryGhost/Ghost/issues/24741 + // A YouTube bookmark request must build the card from the allowlisted + // oEmbed provider metadata (video title/author/publisher/thumbnail) + // rather than the generic scraped page, and must never leak the + // provider embed HTML into the bookmark card. + it('builds a YouTube bookmark card from allowlisted oEmbed provider metadata (#24741)', async function () { + const thumbnailUrl = 'https://i.ytimg.com/vi/0i1Xz-xiYSU/hqdefault.jpg'; + sinon.stub(oembedService, 'processImageFromUrl') + .resolves('/content/images/thumbnail/youtube.jpg'); + + nock('https://www.youtube.com') + .get('/oembed') + .query(true) + .reply(200, { + title: 'What happens in the European Space Agency\'s Mission Control?', + author_name: 'Matt Gray', + author_url: 'https://www.youtube.com/@MattGrayYES', + type: 'video', + version: '1.0', + provider_name: 'YouTube', + provider_url: 'https://www.youtube.com/', + thumbnail_url: thumbnailUrl, + html: '', + width: 200, + height: 113 + }); + + try { + const response = await oembedService.fetchOembedDataFromUrl('https://www.youtube.com/watch?v=0i1Xz-xiYSU', 'bookmark'); + + assert.equal(response.type, 'bookmark'); + assert.equal(response.metadata.title, 'What happens in the European Space Agency\'s Mission Control?'); + assert.equal(response.metadata.author, 'Matt Gray'); + assert.equal(response.metadata.publisher, 'YouTube'); + assert.equal(response.metadata.thumbnail, '/content/images/thumbnail/youtube.jpg'); + // The provider embed HTML must not leak into the bookmark card + assert.equal(response.metadata.html, undefined); + assert.equal(response.html, undefined); + } finally { + sinon.restore(); + } + }); + it('prefers the standard favicon over an apple-touch-icon in the bookmark fallback', async function () { // With no oembed endpoint and no explicit type, fetchOembedDataFromUrl // falls through to the bookmark fallback (!data && !type). That path @@ -433,6 +532,28 @@ describe('oembed-service', function () { describe('processImageFromUrl', function () { const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/; + it('returns null without fetching when the image URL is missing', async function () { + const externalRequest = sinon.stub(); + const service = new OembedService({ + config: { + getContentPath() { + return '/tmp/content/images'; + } + }, + storage: { + getStorage() { + throw new Error('storage should not be used'); + } + }, + externalRequest + }); + + const storedUrl = await service.processImageFromUrl(null, 'thumbnail'); + + assert.equal(storedUrl, null); + sinon.assert.notCalled(externalRequest); + }); + it('stores downloaded bookmark assets via image storage and returns the adapter URL', async function () { const imageBytes = Buffer.from('img-bytes'); const saveRaw = sinon.stub().resolves('https://storage.ghost.is/c/6f/a3/site/content/images/thumbnail/sample-x.png');